A lightweight, zero-allocation signal (event) library for Unity. Decouple your systems with type-safe, struct-based signals — no strings, no boxing, no garbage.
- Type-safe signals — signals are structs, caught at compile time
- Zero GC in steady state — subscription objects are pooled and recycled automatically
- Multiple subscription modes — by type, by specific value, or by predicate
- Reentrant dispatch — subscribe/unsubscribe safely from inside a signal callback
- Listener-based bulk unsubscribe — pass
thisas listener, then unsubscribe everything in one call (great forOnDestroy) - No singletons — create as many
SignalHubinstances as you need (global, per-scene, per-entity)
- Installation
- Quick Start
- Defining Signals
- Subscribing
- Dispatching
- Unsubscribing
- API Reference
- Performance
- Tests
- License
- Open Window → Package Manager
- Click + → Add package from git URL...
- Enter:
https://github.com/actionk/UniSignal.git#1.0.0
Add to your Packages/manifest.json:
"com.actionik.polymorphex.unisignal": "https://github.com/actionk/UniSignal.git#1.0.0"// 1. Define a signal
public struct PlayerDiedSignal : ISignal { }
// 2. Create a hub (or inject one via DI)
var signalHub = new SignalHub();
// 3. Subscribe
signalHub.Subscribe<PlayerDiedSignal>(this, () =>
{
Debug.Log("Player died!");
});
// 4. Dispatch
signalHub.Dispatch(new PlayerDiedSignal());
// 5. Cleanup
signalHub.Unsubscribe(this);A signal is any struct that implements ISignal. It can be completely empty if you only need to notify that something happened:
public struct GameStartedSignal : ISignal { }
public struct GameOverSignal : ISignal { }Signals can carry data. Subscribers can optionally receive it:
public struct DamageSignal : ISignal
{
public int amount;
public Entity source;
}If you want subscribers to match on a specific signal value (not just the type), implement ISignal<T> which requires IEquatable<T>:
public struct ItemPickedUpSignal : ISignal<ItemPickedUpSignal>
{
public int itemId;
public bool Equals(ItemPickedUpSignal other) => itemId == other.itemId;
public override int GetHashCode() => itemId;
}This enables subscribing to, for example, only item ID 42 being picked up.
All Subscribe overloads return a SignalSubscription<T> that can be used to unsubscribe later.
Callback fires for every signal of that type.
// Without data
signalHub.Subscribe<DamageSignal>(this, () =>
{
Debug.Log("Something took damage");
});
// With data
signalHub.Subscribe<DamageSignal>(this, (DamageSignal signal) =>
{
Debug.Log($"Took {signal.amount} damage");
});Same as above, but the callback receives the signal struct:
signalHub.Subscribe<DamageSignal>(this, (DamageSignal signal) =>
{
healthBar.SetValue(healthBar.Value - signal.amount);
});Only fires when the dispatched signal equals the subscribed value. Requires ISignal<T>:
// Without data
signalHub.Subscribe(this, new ItemPickedUpSignal { itemId = 42 }, () =>
{
Debug.Log("Picked up item 42!");
});
// With data
signalHub.Subscribe(this, new ItemPickedUpSignal { itemId = 42 }, (ItemPickedUpSignal signal) =>
{
Debug.Log($"Got item {signal.itemId}");
});Only fires when the predicate returns true:
// Without data
signalHub.Subscribe<DamageSignal>(this,
signal => signal.amount > 50,
() => Debug.Log("Big hit!")
);
// With data
signalHub.Subscribe<DamageSignal>(this,
signal => signal.amount > 50,
(DamageSignal signal) => Debug.Log($"Big hit: {signal.amount}")
);signalHub.Subscribe<DamageSignal>(this,
signal => signal.source != Entity.Invalid,
(DamageSignal signal) =>
{
Debug.Log($"Damage from {signal.source}: {signal.amount}");
}
);You can omit the listener object. This is useful for static or one-off subscriptions, but you won't be able to bulk-unsubscribe by listener:
signalHub.Subscribe<GameStartedSignal>(() => Debug.Log("Game started"));signalHub.Dispatch(new DamageSignal { amount = 25 });Dispatch is reentrant — it's safe to subscribe, unsubscribe, or dispatch again from inside a callback. Changes are queued and applied after the current dispatch completes.
var sub = signalHub.Subscribe<DamageSignal>(this, () => { });
// Either via the hub:
signalHub.Unsubscribe(sub);
// Or directly on the subscription:
sub.Unsubscribe();Removes all subscriptions (across all signal types) registered with this listener object. Ideal for OnDestroy:
signalHub.Unsubscribe(this);Removes only subscriptions of a specific signal type for the given listener:
signalHub.Unsubscribe<DamageSignal>(this);Removes all subscriptions of a signal type, regardless of listener:
signalHub.UnsubscribeAllFrom<DamageSignal>();| Method | Description |
|---|---|
Subscribe<T>(listener, callback) |
Subscribe to all signals of type T |
Subscribe<T>(listener, Action<T>) |
Subscribe with signal data |
Subscribe<T>(listener, predicate, callback) |
Subscribe with a filter predicate |
Subscribe<T>(listener, predicate, Action<T>) |
Subscribe with predicate + signal data |
Subscribe<T>(listener, signal, callback) |
Subscribe to a specific signal value (ISignal<T>) |
Subscribe<T>(listener, signal, Action<T>) |
Subscribe to a specific value with data |
Dispatch<T>(signal) |
Dispatch a signal to all matching subscribers |
Unsubscribe(subscription) |
Remove a single subscription |
Unsubscribe(listener) |
Remove all subscriptions for a listener |
Unsubscribe<T>(listener) |
Remove all T subscriptions for a listener |
UnsubscribeAllFrom<T>() |
Remove all subscriptions of type T |
GetSubscriptionListeners() |
Get all registered listener objects |
All Subscribe overloads are also available without the listener parameter.
public interface ISignal { }Marker interface for signal structs.
public interface ISignal<T> : ISignal, IEquatable<T> { }For signals that can be matched by value using Subscribe(listener, signalValue, callback).
- Object pooling: subscription objects are pooled per signal type — no allocations after warmup
- Indexed for-loops: all internal iteration uses indexed loops to avoid enumerator allocations
- Reentrant dispatch: uses a depth counter instead of copying lists, so nested dispatches are efficient
- Struct signals: no boxing — signals stay on the stack
Unit tests are included in the Tests/ folder. Run them via Unity's Test Runner (Window → General → Test Runner).
Distributed under the MIT License. See LICENSE for more information.
