Presentationcom.zoa.feedback · v0.1.0

ZOA Feedback

One gameplay event in, many presentation channels out, with the routing held in data rather than in code.

Firing a weapon should produce a sound, a muzzle flash, a recoil kick, a controller rumble, and a tracer. Writing that as five calls at the trigger-pull site couples the weapon to five subsystems and makes the mix impossible to tune without touching gameplay code. Feedback inverts it: the weapon fires one event, and a graph decides which channels respond.

The parts are small and separable. A FeedbackEventId names what happened. A FeedbackChannelId names an output modality. An IFeedbackGraph holds the mappings between them, each carrying an intensity scale, a delay, and an optional asset reference. IFeedbackService owns the dispatch, and one IFeedbackChannelHandler per modality does the actual work.

The core assembly is pure C# with no Unity dependency, positions carried as float triples rather than Vector3, so the router and the graph builder can be unit-tested outside play mode. The Unity layer adds the authoring asset, the scene installer, and a notification service that rides the same graph so a toast can carry a sound without the publisher knowing.

Depends on (1)

Depended on by (0)

Nothing yet. This is a leaf.

How it works

Concepts

Events, channels, and the mappings between them

FeedbackEventId and FeedbackChannelId are the same shape: readonly structs wrapping a string with ordinal equality. DefaultFeedbackEvents names twenty-two built-ins across weapon, impact, player, interaction, and hit-confirmation groups. DefaultChannels names ten: sfx, vfx, decal, tracer, haptics, camera_shake, camera_recoil, hit_marker, screen_effect, and ui_flash.

A FeedbackMapping is one routing edge: this event fires, this channel responds, scaled by IntensityScale, delayed by Delay seconds, optionally naming an AssetReference for the handler to look up, and gated by a mutable IsEnabled flag. Many mappings can share an event id, which is how one weapon_fired fans out to sound, flash, recoil, and rumble.

Because the ids are strings rather than enums, a project adds its own events and channels without editing the package. A tutorial system inventing a "tutorial_step_completed" event and routing it to ui_flash needs no code in this package at all.

The router is the graph's only consumer

FeedbackRouter implements IFeedbackService. Fire looks up the active graph's mappings for the incoming event id and walks them. A disabled mapping is skipped, a mapping with a positive delay is queued, and everything else dispatches immediately to every active handler registered for that channel.

Handler registration is many-to-one: GetHandlers returns a list, so two systems can both serve the sfx channel without one displacing the other. SetChannelActive flips IsActive on every handler for a channel, which is how a settings screen mutes haptics or disables screen effects globally without unregistering anything.

The graph is data the router reads. LoadGraph replaces the active graph outright, and Fire with no graph loaded is a safe no-op, so FeedbackSceneInstaller can register the router before any graph exists.

Delays are queued, and something has to pump them

The router is engine-free, so it has no frame to tick on. Mappings with a positive Delay go into an internal queue, and ProcessDelayed decrements each entry by a supplied delta and dispatches the ones that reach zero. PendingDelayedCount exposes the queue depth for diagnostics.

That means a delayed mapping only fires if something calls ProcessDelayed every frame. The scene installer registers the service but does not pump it, so a project that authors delays needs a MonoBehaviour that calls ProcessDelayed(Time.deltaTime) in Update. StopAll clears the queue along with stopping every handler, which is the right behaviour on a scene transition or a death.

Graphs are built fluently or authored as assets

FeedbackGraphBuilder is the code path. Map adds an event-to-channel edge with optional intensity, delay, and asset reference; Build produces an immutable graph that indexes its mappings by event id in the constructor, so GetMappingsForEvent is a dictionary hit rather than a scan.

FeedbackGraphAsset is the authoring path, a DefinitionBase ScriptableObject holding a graph slug and a list of serialized mappings, each with an event id, a channel id, an intensity scale, and a delay. The Feedback Graph Wizard walks identity, event selection, channel routing, and review, and FeedbackGraphAssetEmitter writes the result to Assets/ZOA/Generated/FeedbackGraphs by default.

Both routes converge on the same runtime shape, so a project can prototype a graph in code and later move it into an asset without changing any consumer.

Notifications ride the same graph

GameNotification is a presentation request: tell the player that something happened. It carries a scope, a severity, localization ids for its title and body, an optional icon and action id, a duration, and a dedupe key. The contract is explicit that publishing a notification must never affect the gameplay action that produced it.

GameNotificationService is the fan-out hub. It suppresses a duplicate while an entry with the same non-empty dedupe key is still active, keeps a rolling history of sixty-four notifications, and raises Published outside its own lock so a subscriber can re-enter the service. A duration of zero or less makes the entry sticky until Clear removes its scope. The clock is injectable, defaulting to Time.unscaledTime so pausing does not strand dedupe keys.

NotificationFeedbackChannelHandler is the bridge between the two systems. It subscribes to Published and fires a ui_notification FeedbackContext on the feedback service, mapping severity to intensity: Error is 1, Warning 0.75, Success 0.5, and Info 0.35. The publishing gameplay code knows only IGameNotificationService, and the graph decides whether a notification also flashes or chimes.

In the editor

Screens

Screenshot pending

/screenshots/feedback-graph-wizard-routing.png

The wizard shell inside the Workbench with the four-step rail on the left and Routing active, showing several mapping rows that each pair an event id with a channel id and expose intensity and delay fields, with the issue tray clean at the bottom.

The Feedback Graph Wizard on its Routing step.

Screenshot pending

/screenshots/feedback-channel-browser.png

The Workbench with the Feedback module selected and Channel Browser active, listing sfx, vfx, decal, tracer, haptics, camera_shake, camera_recoil, hit_marker, screen_effect, and ui_flash with their descriptions.

The channel browser listing the ten built-in feedback channels.

Setup

Workflow

  1. 01

    Install the services

    Drop FeedbackSceneInstaller on a scene root. It registers a FeedbackRouter and a GameNotificationService, constructs the notification bridge, and is safe to have present in several scenes: an installer that finds a service already registered reuses it and will not unregister it on destroy.

  2. 02

    Implement the channels you actually render

    The package ships routing; rendering the output is your job. Write one IFeedbackChannelHandler per modality your project supports, give each its ChannelId, and register it with the service. A channel with no handler simply produces nothing, so a project can adopt sfx and camera_recoil and ignore the other eight.

  3. 03

    Author the graph

    Run the Feedback Graph Wizard for the guided path, or compose one with FeedbackGraphBuilder in code. Either way the routing lives in one place, so tuning the mix means editing mappings rather than hunting call sites.

  4. 04

    Load the graph and fire events

    Call LoadGraph once during bootstrap, then Fire a FeedbackContext at each gameplay moment. Set Intensity to reflect the moment, a low-calibre weapon at 0.3 and a sniper at 1.0, and let each handler scale its own output by it.

  5. 05

    Pump delayed mappings if you use them

    Add a MonoBehaviour that calls FeedbackRouter.ProcessDelayed(Time.deltaTime) every frame. Without it, mappings with a non-zero Delay queue and never dispatch. Nothing in the package pumps the queue for you.

  6. 06

    Publish notifications instead of touching the HUD

    Gameplay systems call IGameNotificationService.Publish. The toast HUD subscribes to Published directly, so notifications are visible before any feedback graph exists, and the bridge adds sound or flash once a graph routes ui_notification.

Surface

Key types

IFeedbackService

interface

The dispatch surface. Fire an event, register and unregister channel handlers, load a graph, and mute channels wholesale.

  • void Fire(FeedbackContext context)
  • void RegisterHandler(IFeedbackChannelHandler handler)
  • bool UnregisterHandler(IFeedbackChannelHandler handler)
  • IReadOnlyList<IFeedbackChannelHandler> GetHandlers(FeedbackChannelId channelId)
  • void LoadGraph(IFeedbackGraph graph)
  • IFeedbackGraph ActiveGraph { get; }
  • void StopAll()
  • void SetChannelActive(FeedbackChannelId channelId, bool active)

FeedbackRouter

service

The default IFeedbackService. Pure C# with no Unity dependency; delayed mappings queue internally and need ProcessDelayed pumped from a MonoBehaviour.

  • void ProcessDelayed(float deltaTime)
  • int PendingDelayedCount { get; }

IFeedbackChannelHandler

interface

One output modality. Implement it once per channel your project actually renders, and register it with the service. IsActive is settable so a channel can be muted without being torn down.

  • FeedbackChannelId ChannelId { get; }
  • bool IsActive { get; set; }
  • void HandleFeedback(FeedbackContext context)
  • void StopAll()

IFeedbackGraph

interface

The routing table. Named, and queryable by event id; GetMappingsForEvent returns an empty list rather than null for an unmapped event.

  • string GraphId { get; }
  • string DisplayName { get; }
  • IReadOnlyList<FeedbackMapping> Mappings { get; }
  • IReadOnlyList<FeedbackMapping> GetMappingsForEvent(FeedbackEventId eventId)

FeedbackMapping

class

One routing edge with its tuning: which event, which channel, an intensity scale, a delay in seconds, an optional asset reference, and a mutable enabled flag.

  • FeedbackEventId EventId { get; }
  • FeedbackChannelId ChannelId { get; }
  • float IntensityScale { get; }
  • float Delay { get; }
  • bool IsEnabled { get; set; }
  • string AssetReference { get; }

FeedbackContext

class

What accompanies a fired event: position and normal as float triples, an intensity scalar, a source id, a surface tag, and the distance to the player for attenuation and level-of-detail decisions.

  • FeedbackEventId EventId { get; }
  • float PositionX, PositionY, PositionZ
  • float NormalX, NormalY, NormalZ
  • float Intensity { get; set; }
  • string SourceId { get; set; } string SurfaceTag { get; set; }
  • float DistanceToPlayer { get; set; }

FeedbackGraphBuilder

class

Fluent construction of an immutable graph. Build indexes the mappings by event id up front so runtime lookups do not scan.

  • FeedbackGraphBuilder Map(FeedbackEventId eventId, FeedbackChannelId channelId, float intensityScale = 1f, float delay = 0f, string assetReference = null)
  • FeedbackGraphBuilder AddMapping(FeedbackMapping mapping)
  • IFeedbackGraph Build()

DefaultChannels

class

The ten built-in channel ids: SFX, VFX, Decal, Tracer, Haptics, CameraShake, CameraRecoil, HitMarker, ScreenEffect, and UIFlash.

DefaultFeedbackEvents

class

The built-in event ids, grouped by weapon, impact, player, interaction, and hit confirmation. WeaponFired, BulletImpact, PlayerDamaged, EnemyKilled, and HeadshotHit are among them.

IGameNotificationService

interface

The publish hub for player-facing notifications. Publication never throws into the caller, and a non-empty dedupe key suppresses a duplicate while the first is active.

  • event Action<GameNotification> Published
  • event Action<NotificationScope> Cleared
  • void Publish(GameNotification notification)
  • void Clear(NotificationScope scope)

GameNotification

class

One notification: scope, severity, localization ids for title and body with optional args, an icon and action id, a duration, and a dedupe key. Title and body are localization ids so the presentation layer resolves the active language.

  • string Id; string DedupeKey;
  • NotificationScope Scope; NotificationSeverity Severity;
  • string TitleLocalizationId; string BodyLocalizationId; List<string> BodyArgs;
  • string IconId; float DurationSeconds; string ActionId;

GameNotificationService

service

The default hub. Dedupes by active key, keeps a sixty-four entry rolling history, raises events outside its lock, and takes an injectable clock defaulting to Time.unscaledTime.

  • const int HistoryCapacity = 64
  • GameNotification[] History { get; }
  • int ActiveCount { get; }
  • void Reset()

NotificationFeedbackChannelHandler

class

Bridges the two systems. Subscribes to Published and fires a ui_notification FeedbackContext, mapping severity to intensity and swallowing any exception so feedback never bubbles back into the publisher.

  • static readonly FeedbackEventId NotificationEvent = new("ui_notification")
  • void Dispose()

Surface

Authoring assets

FeedbackGraphAsset

asset

The authoring asset for a graph. Holds a stable graph slug and a list of serialized mappings, each naming an event id, a channel id, an intensity scale, and a delay. The same event id may appear on several rows to fan out.

  • Tools/ZOA/Feedback/Feedback Graph
  • string GraphId { get; }
  • IReadOnlyList<SerializedFeedbackMapping> Mappings { get; }

FeedbackSceneInstaller

component

Registers IFeedbackService and IGameNotificationService and wires the notification bridge between them. Idempotent, reuses anything already registered, and unregisters only what it created. Execution order -9400.

  • ZOA/Feedback/Feedback Scene Installer
  • IFeedbackService Feedback { get; }
  • IGameNotificationService Notifications { get; }
  • void EnsureRegistered()
  • void HandleDestroy()

Usage

Examples

Building a weapon feedback graphcsharp
using ZOA.Feedback.Core.Graph;
using ZOA.Feedback.Core.Models;
using ZOA.Messaging;
using ZOA.Feedback.Core.Contracts;

var graph = new FeedbackGraphBuilder("rifle_feedback", "Rifle Feedback")
    .Map(DefaultFeedbackEvents.WeaponFired, DefaultChannels.SFX)
    .Map(DefaultFeedbackEvents.WeaponFired, DefaultChannels.VFX)
    .Map(DefaultFeedbackEvents.WeaponFired, DefaultChannels.CameraRecoil, intensityScale: 0.8f)
    .Map(DefaultFeedbackEvents.WeaponFired, DefaultChannels.Haptics, intensityScale: 0.5f)
    .Map(DefaultFeedbackEvents.BulletImpact, DefaultChannels.VFX)
    .Map(DefaultFeedbackEvents.BulletImpact, DefaultChannels.Decal, delay: 0.02f)
    .Map(DefaultFeedbackEvents.BulletImpact, DefaultChannels.SFX)
    .Build();

FoundryServiceRegistry.Get<IFeedbackService>().LoadGraph(graph);
One event, four channels. Changing how loud the rumble is means editing this table, not the weapon.
Firing an event from gameplaycsharp
using UnityEngine;
using ZOA.Feedback.Core.Contracts;
using ZOA.Feedback.Core.Models;
using ZOA.Messaging;

public sealed class WeaponFeedback : MonoBehaviour
{
    private IFeedbackService _feedback;

    private void Awake() => FoundryServiceRegistry.TryResolve<IFeedbackService>(out _feedback);

    public void ReportShot(Vector3 muzzle, string weaponId, float intensity)
    {
        if (_feedback == null) return;

        var ctx = new FeedbackContext(DefaultFeedbackEvents.WeaponFired)
        {
            PositionX = muzzle.x,
            PositionY = muzzle.y,
            PositionZ = muzzle.z,
            Intensity = intensity,
            SourceId = weaponId,
        };

        _feedback.Fire(ctx);
    }
}
Positions are float triples because the core assembly carries no UnityEngine reference. The Unity-side caller unpacks its Vector3.
Implementing a channel handlercsharp
using ZOA.Feedback.Core.Contracts;
using ZOA.Feedback.Core.Models;

public sealed class CameraRecoilChannel : IFeedbackChannelHandler
{
    public FeedbackChannelId ChannelId => DefaultChannels.CameraRecoil;
    public bool IsActive { get; set; } = true;

    public void HandleFeedback(FeedbackContext context)
    {
        // Scale the kick by the context's intensity.
        ApplyKick(context.Intensity);
    }

    public void StopAll() => ResetKick();

    private void ApplyKick(float intensity) { /* ... */ }
    private void ResetKick() { /* ... */ }
}

// Registration, and a settings screen muting the channel later:
service.RegisterHandler(new CameraRecoilChannel());
service.SetChannelActive(DefaultChannels.Haptics, false);
Publishing a deduped notificationcsharp
using ZOA.Feedback.Core.Notifications;
using ZOA.Messaging;

var notifications = FoundryServiceRegistry.Get<IGameNotificationService>();

notifications.Publish(new GameNotification(
    NotificationScope.Inventory,
    NotificationSeverity.Warning,
    titleLocalizationId: "inventory.full.title",
    bodyLocalizationId: "inventory.full.body",
    durationSeconds: 3f,
    dedupeKey: "inventory.full"));

// A second publish inside the three-second window is suppressed.
// On a level reset, drop the scope's active keys:
notifications.Clear(NotificationScope.Inventory);
A duration of zero or less makes the notification sticky, active until Clear removes its scope.

Tooling

Editor tools

Feedback Graph Wizard

Tools > ZOA > Advanced > Define > Characters > Feedback > Graph Wizard

A four-step wizard on FoundryWizardShell with a step rail and issue tray: Identity names the graph, Events selects the gameplay events to route, Routing maps each to channels with intensity and delay, and Review confirms before emission. The menu item routes into the Workbench rather than opening a floating window.

Feedback Workbench module

ZOA Workbench > Feedback

Registered as "feedback" in the workflow.character lane at order 138. Four capabilities: a graph browser that edits, validates, duplicates, and deletes FeedbackGraphAssets, a channel browser over the built-in channels, an event browser over the built-in events, and an architecture overview.

FeedbackGraphAssetEmitter

Writes a wizard snapshot to a FeedbackGraphAsset, defaulting to Assets/ZOA/Generated/FeedbackGraphs and generating a unique asset path so an emit never silently overwrites an existing graph.

Read this

Notes and caveats

See also