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)
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.
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.
Setup
Workflow
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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);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);
}
}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);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);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