Skip to content

kids.kapish.signals

unity

Typed event dispatch with three layered patterns -- pure C# Signal<T>, ScriptableObject SignalAsset<T>, and MonoBehaviour listener/emitter bridges for inspector-wired UnityEvents.

Package Identity

FieldValue
Namekids.kapish.signals
Display nameCarrot.Signals
Version0.2.0 (v2)
Unity2022.3+
LicenseMIT

Architecture: Three Layers

v2 layers three usage patterns on top of the same pure-C# core:

Layer 3: SignalListener / SignalEmitter      (MonoBehaviour, inspector-wired UnityEvents)
              |
              v  references
Layer 2: SignalAsset / SignalAsset<T>         (ScriptableObject, cross-scene event bus)
              |
              v  wraps
Layer 1: Signal / Signal<T>                   (pure C#, code-first)

All three layers dispatch through the same Signal<T> mutation logic. The higher layers are purely wrappers -- no duplicated event machinery.

Layer 1 -- Pure C# Signal<T>

Unchanged from v1. The core primitive. Used by other Carrot packages (kids.kapish.scenes, kids.kapish.cameras, kids.kapish.persistence, kids.kapish.statemachines, kids.kapish.input, kids.kapish.entityreact, kids.kapish.characters.controllers, kids.kapish.flexui, and core) for internal code-first events. No Unity dependency in the types themselves; they just happen to live in a Unity-aware assembly now.

Layer 2 -- SignalAsset<T>

A ScriptableObject that owns a Signal<T> instance. The asset is the stable reference shared between emitters and listeners -- both sides reference the asset, neither references each other. Classic decoupled event-bus-as-asset pattern.

  • Non-generic SignalAsset is concrete with a [CreateAssetMenu] under Carrot/Signals/Signal.
  • Generic SignalAsset<T> is abstract -- Unity cannot serialize open generics, so each type needs a concrete subclass with its own [CreateAssetMenu]. Built-in variants: int, float, string, bool, GameObject, Vector2, Vector3. Users can subclass for custom types.
  • OnDisable clears handlers. This is deliberate: ScriptableObjects survive scene changes but their listeners usually don't, and domain reloads will call OnDisable before tearing down. Clearing prevents zombie handlers from leaking across reloads.

Layer 3 -- MonoBehaviour bridges

  • SignalEmitter -- single-method Dispatch() MonoBehaviour targeting a SignalAsset. Lets you fire a signal from any inspector trigger (UnityEvent, Button.onClick, animation event, etc.) without writing a custom component.
  • SignalListener / SignalListener<T> -- subscribes to a SignalAsset in OnEnable, unsubscribes in OnDisable, invokes a UnityEvent / UnityEvent<T> response. Same concrete-subclass-per-type rule as assets.

These exist so the signal system can replace the classic UnityEvent-on-a-MonoBehaviour pattern without forcing every designer-facing interaction through code.

Dispatch Safety

The core design problem signals solve over raw event Action<T> is safe mutation during dispatch. If a handler adds or removes listeners while the signal is dispatching, modifying the handler list directly would cause InvalidOperationException (collection modified during enumeration).

Signals solve this with a deferred mutation pattern:

Dispatch(value)
  -> set dispatching = true
  -> iterate handlers[0..N] by index (not enumerator)
  -> set dispatching = false
  -> remove once-handlers from main list
  -> flush pendingRemoves (remove from handlers + onceHandlers)
  -> flush pendingAdds (append to handlers)

When dispatching is true, Add() and Remove() push to pendingAdds/pendingRemoves instead of mutating the handler list. After dispatch completes, FlushPending() applies the deferred changes.

The handler list is iterated with a for loop by index rather than foreach, which avoids allocating an enumerator and sidesteps InvalidOperationException even though the list isn't actually mutated during iteration.

Once Handlers

Once registers a handler in both handlers (for dispatch) and onceHandlers (for tracking). After dispatch completes, all entries in onceHandlers are removed from handlers, then onceHandlers is cleared. This happens before FlushPending, so once-handlers are removed before any pending adds/removes are applied.

Unsubscribe Pattern

Add() and Once() both return an Action that calls Remove(handler). This matches the TypeScript @carrot/signals API where subscribe returns an unsubscribe function, making it easy to capture disposal in a single variable without holding a reference to both the signal and the handler.

Listener Lifecycle (Layer 3)

SignalListener.Connect() is called from OnEnable, Disconnect() from OnDisable. The listener holds the unsubscribe Action returned by Signal.Add and invokes it on disconnect. This gives correct behaviour across:

  • Scene load / unload
  • GameObject / component enable toggle
  • Domain reload in the editor

Reassigning the Signal property at runtime (e.g. from code) disconnects the old asset and reconnects to the new one if the listener is currently enabled.

Custom Editors

Editor assembly provides a custom inspector for each SignalAsset variant:

  • Draws the default inspector above
  • Runtime Listener Count (read-only)
  • Test Value field for the typed variants (int, float, string, bool, GameObject, Vector2, Vector3)
  • Dispatch button, gated on Application.isPlaying -- disabled with a help-box outside Play mode

The editor inspectors are stateful per-inspector instance -- the test value persists while the inspector is alive but isn't serialised to the asset. This is intentional: the asset shouldn't remember debug values.

Design Decisions

Unity-aware, but pure core stays pure -- v1 had noEngineReferences: true because there was nothing Unity-specific in the assembly. v2 adds ScriptableObjects and MonoBehaviours, so the assembly must reference UnityEngine. The Signal / Signal<T> types themselves still don't use any Unity APIs and could be lifted out if we ever needed a no-engine variant.

Class, not struct -- Signals are mutable reference types with internal state (handler lists, dispatch flag). Value semantics would be incorrect here.

No thread safety -- Dispatch, add, and remove are not thread-safe. Matches Unity's single-threaded execution model. If you need cross-thread signalling, wrap in a lock or marshal onto the main thread.

No weak references -- Handlers are stored as strong Action<T> references. Subscribers must unsubscribe (or rely on SignalAsset.OnDisable / SignalListener.OnDisable) to avoid leaks. Same contract as C# events.

List, not HashSet -- Handler storage uses List<T> for ordered iteration and zero-alloc indexed access. Duplicate subscriptions are allowed (same handler added twice will fire twice). Matches C# event semantics.

Concrete typed subclasses over generic inspector hacks -- Unity can't serialize open generic ScriptableObjects or MonoBehaviours, so each payload type needs a concrete subclass. Built-in set covers the common cases; users add more as needed. This is the standard Unity idiom and it keeps the inspector honest.

Clear-on-OnDisable for SignalAssets -- Prevents stale handler leaks across domain reloads and play-mode transitions. Listeners re-subscribe in their own OnEnable, so this is self-healing.

Adoption Across Carrot

All first-party Carrot Unity packages now use Signal<T> internally for cross-system events. Examples: kids.kapish core, kids.kapish.scenes, kids.kapish.cameras, kids.kapish.persistence, kids.kapish.statemachines, kids.kapish.input, kids.kapish.entityreact, kids.kapish.characters.controllers, kids.kapish.flexui. This package is effectively the event backbone for the Unity side of Carrot.

Dependencies

None. This package has no dependencies on other Carrot packages or third-party packages.

File Structure

Runtime/
  Carrot.Signals.asmdef                # noEngineReferences: false
  Signal.cs                            # Layer 1: non-generic pure C# signal
  Signal{T}.cs                         # Layer 1: generic typed signal
  SignalAsset.cs                       # Layer 2: non-generic ScriptableObject
  SignalAsset{T}.cs                    # Layer 2: abstract typed base
  SignalAssetInt.cs
  SignalAssetFloat.cs
  SignalAssetString.cs
  SignalAssetBool.cs
  SignalAssetGameObject.cs
  SignalAssetVector2.cs
  SignalAssetVector3.cs
  SignalEmitter.cs                     # Layer 3: dispatch MonoBehaviour
  SignalListener.cs                    # Layer 3: non-generic listener
  SignalListener{T}.cs                 # Layer 3: abstract typed listener
  SignalListenerInt.cs
  SignalListenerFloat.cs
  SignalListenerString.cs
  SignalListenerBool.cs
  SignalListenerGameObject.cs
  SignalListenerVector2.cs
  SignalListenerVector3.cs

Editor/
  Carrot.Signals.Editor.asmdef         # Editor platform only, references Carrot.Signals
  SignalAssetEditor.cs                 # Base inspector (dispatch button, listener count)
  SignalAssetIntEditor.cs
  SignalAssetFloatEditor.cs
  SignalAssetStringEditor.cs
  SignalAssetBoolEditor.cs
  SignalAssetGameObjectEditor.cs
  SignalAssetVector2Editor.cs
  SignalAssetVector3Editor.cs

Usage Guide

Typed event dispatch with three patterns: pure C# Signal<T>, ScriptableObject SignalAsset<T>, and MonoBehaviour bridges for UnityEvent-style wiring. Pick the layer that fits the coupling you need.

Pattern 1 -- Pure C# Signal<T>

Use this for code-first events between systems/services. This is what the rest of the Carrot Unity packages use internally.

Typed signal

csharp
using Carrot.Signals;

var onHealthChanged = new Signal<int>();

// Subscribe -- Add() returns an unsubscribe Action
Action unsub = onHealthChanged.Add(hp => Debug.Log($"Health: {hp}"));

onHealthChanged.Dispatch(100); // "Health: 100"
onHealthChanged.Dispatch(75);  // "Health: 75"

unsub();
onHealthChanged.Dispatch(50);  // nothing -- handler removed

Non-generic signal (void events)

csharp
using Carrot.Signals;

var onGameOver = new Signal();

Action unsub = onGameOver.Add(() => Debug.Log("Game over!"));
onGameOver.Dispatch(); // "Game over!"

MonoBehaviour pattern

csharp
using Carrot.Signals;
using UnityEngine;

public class Enemy : MonoBehaviour
{
    public readonly Signal<int> OnDamaged = new();
    public readonly Signal OnDeath = new();

    private int health = 100;

    public void TakeDamage(int amount)
    {
        this.health -= amount;
        this.OnDamaged.Dispatch(amount);

        if (this.health <= 0)
        {
            this.OnDeath.Dispatch();
        }
    }
}

public class HealthBar : MonoBehaviour
{
    [SerializeField] private Enemy enemy;

    private Action unsubDamage;
    private Action unsubDeath;

    void OnEnable()
    {
        this.unsubDamage = this.enemy.OnDamaged.Add(amount => this.UpdateDisplay());
        this.unsubDeath  = this.enemy.OnDeath.Once(() => this.gameObject.SetActive(false));
    }

    void OnDisable()
    {
        this.unsubDamage?.Invoke();
        this.unsubDeath?.Invoke();
    }

    private void UpdateDisplay() { /* ... */ }
}

Pattern 2 -- SignalAsset<T> (ScriptableObject)

Use this when sender and receiver shouldn't reference each other directly -- cross-scene events, designer-authored event graphs, plugin/mod-style extensions.

Create the asset

Right-click in the Project window: Create > Carrot > Signals > Signal (int) (or any of the typed variants, or the non-generic Signal). Save it somewhere like Assets/Signals/OnDamageDealt.asset.

Dispatch from code

csharp
using Carrot.Signals;
using UnityEngine;

public class Enemy : MonoBehaviour
{
    [SerializeField] private SignalAssetInt onDamageDealt;

    public void DealDamage(int amount) => this.onDamageDealt.Dispatch(amount);
}

Subscribe from code

csharp
using Carrot.Signals;
using UnityEngine;

public class ScoreCounter : MonoBehaviour
{
    [SerializeField] private SignalAssetInt onDamageDealt;

    private int score;
    private Action unsub;

    void OnEnable()  => this.unsub = this.onDamageDealt.Signal.Add(amount => this.score += amount);
    void OnDisable() => this.unsub?.Invoke();
}

Both components drop the same .asset into their inspector field. Neither knows about the other.

Custom payload types

Want a SignalAsset<MyCustomType>? Subclass and add a [CreateAssetMenu]:

csharp
using Carrot.Signals;
using UnityEngine;

[CreateAssetMenu(menuName = "MyGame/Signals/Damage Event")]
public class SignalAssetDamageEvent : SignalAsset<DamageEvent> { }

public readonly struct DamageEvent
{
    public readonly int Amount;
    public readonly GameObject Source;
    public readonly GameObject Target;
}

Pattern 3 -- SignalListener<T> + UnityEvent

Use this for designer-facing, inspector-wired responses. Replaces the classic "UnityEvent on a MonoBehaviour that someone has to call" pattern with a decoupled signal in the middle.

Wiring a listener

  1. Add a SignalListenerInt component to a GameObject (via Component > Carrot > Signals > Signal Listener (int)).
  2. Drop your SignalAssetInt asset into the Signal field.
  3. Configure the Response UnityEvent in the inspector, same as any other UnityEvent field -- point it at MyHealthBar.SetHealth or whatever.

That's it. When any emitter calls onDamageDealt.Dispatch(25), every active listener pointed at that asset fires its response with 25.

Wiring an emitter without code

Add a SignalEmitter component, assign the SignalAsset, then call SignalEmitter.Dispatch() from any UnityEvent slot -- Button.onClick, animation events, collision events routed through another MonoBehaviour, etc. Useful when you want to fire a signal from pure inspector wiring.

Switching signals at runtime

The Signal property on SignalListener<T> is settable -- assigning a new asset disconnects from the old one and reconnects to the new one automatically (if the listener is currently enabled).

csharp
this.listener.Signal = this.alternateSignalAsset;

Custom Editor Dispatch Button

Every SignalAsset has a custom inspector that, in Play mode, shows:

  • Listener Count -- live subscriber count
  • Test Value (typed variants) -- a field matching the payload type
  • Dispatch button -- fires the signal

Typical workflow:

  1. Enter Play mode.
  2. Select the SignalAssetInt asset in the Project window.
  3. Type 42 into the Test Value field.
  4. Click Dispatch.

Every listener subscribed to the asset fires with 42. Outside Play mode the button is disabled with a help-box reminding you to enter Play mode. Lets you smoke-test reactive UI, state transitions, and gameplay flows without writing debug hooks.

Unsubscribe Patterns

csharp
Action unsub = signal.Add(MyHandler);

// Later:
unsub();

Via Remove

csharp
Action<int> handler = value => Debug.Log(value);
signal.Add(handler);
signal.Remove(handler);

Clearing all listeners

csharp
signal.Clear(); // removes everything

SignalAsset.OnDisable calls Clear() automatically on the wrapped signal, so domain reloads and play-mode exits don't leak stale handlers.

Once (Single-Fire)

Once() subscribes a handler that fires exactly once, then auto-removes itself:

csharp
var onReady = new Signal();

onReady.Once(() => Debug.Log("Ready!"));

onReady.Dispatch(); // "Ready!"
onReady.Dispatch(); // nothing -- handler was auto-removed

Once() also returns an unsubscribe Action, so you can cancel before it fires:

csharp
Action cancel = onReady.Once(() => Debug.Log("Ready!"));
cancel();
onReady.Dispatch(); // nothing

Dispatch-Safe Behaviour

It's safe to add or remove handlers from inside a handler during dispatch. Changes are deferred and applied after the current dispatch completes:

csharp
var signal = new Signal<string>();

signal.Add(msg =>
{
    Debug.Log($"First: {msg}");

    // This add is deferred -- the new handler won't fire during this dispatch
    signal.Add(msg2 => Debug.Log($"Late joiner: {msg2}"));
});

signal.Dispatch("hello");
// Output: "First: hello"

signal.Dispatch("world");
// Output: "First: hello", "Late joiner: world"

Removing during dispatch is also deferred:

csharp
var signal = new Signal();
Action unsub = null;

unsub = signal.Add(() =>
{
    Debug.Log("Self-removing handler");
    unsub(); // deferred -- won't affect current dispatch
});

signal.Add(() => Debug.Log("Second handler"));

signal.Dispatch();
// Output: "Self-removing handler", "Second handler"

signal.Dispatch();
// Output: "Second handler" -- first handler was removed after previous dispatch

ListenerCount

Check how many handlers are currently subscribed:

csharp
var signal = new Signal<int>();

signal.Add(x => { });
signal.Once(x => { });

Debug.Log(signal.ListenerCount); // 2

signal.Dispatch(0);
Debug.Log(signal.ListenerCount); // 1 (once-handler removed)

SignalAsset.ListenerCount forwards to the wrapped signal and is also visible live in the custom inspector.

Comparison with event Action<T>

Featureevent Action<T>Signal<T>
SubscribemyEvent += handlersignal.Add(handler)
UnsubscribemyEvent -= handlerunsub() or signal.Remove(handler)
Unsubscribe returns disposableNoYes (Add returns Action)
Single-fireManual (remove inside handler)signal.Once(handler)
Safe add/remove during dispatchNo (throws InvalidOperationException)Yes (deferred)
Clear allSet to null (only from declaring type)signal.Clear()
Listener countNo built-in waysignal.ListenerCount
Dispatch from outside declaring typeNo (only declaring class can invoke)Yes (Dispatch is public)
ScriptableObject-backed asset formNoSignalAsset<T>
UnityEvent bridge componentNoSignalListener<T>
Thread-safeNoNo

Which Pattern Should I Use?

If you...Use
Own both ends of the wire and they're in the same codebaseSignal<T>
Want zero direct references between sender and receiverSignalAsset<T>
Want designers to wire responses in the inspectorSignalListener<T> + UnityEvent
Want to fire a signal from a Button, animation event, or UnityEvent without writing a componentSignalEmitter
Need to smoke-test a signal-driven system from the editorCustom inspector Dispatch button on the SignalAsset

Carrot