Event Bus

Type-safe, synchronous publish/subscribe between game systems.

Type-safe publish/subscribe between game systems · Synchronous, main-thread-only · Subscribe returns an IDisposable token · No-op replacement: NullEventBus

What it does

The Event Bus lets systems talk without knowing about each other. A publisher calls bus.Publish(new PlayerDamagedEvent { ... }); every handler registered with bus.Subscribe<PlayerDamagedEvent>(handler) receives it synchronously, in the order the handlers subscribed. Event types are matched at compile time — no reflection, no dynamic lookups — so the bus works safely in IL2CPP (ahead-of-time compiled) builds. It is the plumbing between the Input service, scene transitions, the UI panel stack, and your own game code.

Quick example

Define an event class, subscribe in OnEnable, unsubscribe in OnDisable:

using System;
using CommonGameSystem.Core;
using UnityEngine;

// Any plain class works as an event.
public class PlayerDamagedEvent
{
    public int Amount;
}

[DefaultExecutionOrder(100)]
public class DamageUI : MonoBehaviour
{
    private IEventBus _bus;
    private IDisposable _subscription;

    private void Awake()
    {
        _bus = ServiceLocator.Resolve<IEventBus>();  // Cache it once
    }

    private void OnEnable()
    {
        _subscription = _bus.Subscribe<PlayerDamagedEvent>(OnPlayerDamaged);
    }

    private void OnDisable()
    {
        _subscription?.Dispose();
    }

    private void OnPlayerDamaged(PlayerDamagedEvent ev)
    {
        Debug.Log($"Damage: {ev.Amount}");
        // Update UI, play a sound, etc.
    }
}

Publishing is one line from anywhere on the main thread:

_bus.Publish(new PlayerDamagedEvent { Amount = 12 });

For a handler that should only live inside one scope, using disposes the token automatically:

private void RunScopedListener()
{
    using var token = _bus.Subscribe<PlayerDamagedEvent>(ev => Debug.Log(ev.Amount));
    // The handler is active here...
}   // ...and unsubscribed automatically when the scope ends.

Full API surface

IEventBus interface

  • IDisposable Subscribe<TEvent>(Action<TEvent> handler) where TEvent : class Register a handler for events of type TEvent. Returns a token whose Dispose() unsubscribes — disposing twice is safe and does nothing the second time. Event types must be reference types (class or record). Throws ArgumentNullException if the handler is null.

  • void Publish<TEvent>(TEvent ev) where TEvent : class Synchronously call every handler registered for the exact type TEvent, in subscription order (first subscribed, first called). If no handlers exist, the call returns silently. Throws ArgumentNullException if the event is null. A handler that throws is caught and logged; the remaining handlers still run.

EventBusOptions tuning (optional)

The default bus is registered by Bootstrap with default options. If you construct your own bus (for tests, or an isolated sub-bus), you can tune it:

var bus = new DefaultEventBus(
    EventBusOptions.Default.With(
        initialListCapacity: 8,
        warnSubscribersPerType: 100));
  • WarnSubscribersPerType (default 50): Editor-only threshold; logs one warning per event type if its subscriber count exceeds this. Usually a sign of a subscription leak.
  • WarnPublishDepth (default 8): Editor-only threshold; warns once if publish calls nest deeper than this (a handler publishing another event, and so on). The warning re-arms once the publish call stack fully unwinds.
  • InitialListCapacity (default 4): memory tuning — the starting size of each per-type subscriber list.

EventBusOptions is an immutable struct: start from EventBusOptions.Default and override individual values with .With(...).

EventBusDiagnostics (Editor only)

Available only in Editor builds, for inspecting bus state:

#if UNITY_EDITOR
int count = EventBusDiagnostics.SubscribeCount<MyEvent>(_bus);
int depth = EventBusDiagnostics.PeekPublishDepth(_bus);
long warnings = EventBusDiagnostics.WarningsEmitted;
#endif

Turning it off

ServiceLocator.Replace<IEventBus>(new NullEventBus());

NullEventBus silently discards every publish and returns harmless no-op tokens from Subscribe. Use it to disable event routing in tests or special builds without changing any caller code. Other services (Input, UI, Scene Flow) keep working — they simply stop receiving events. No logging, no side effects.

Behavior & edge cases

  • Main thread only. All Subscribe and Publish calls must come from Unity's main thread. Debug builds assert this. Release builds skip the check for speed, so a background-thread call there can corrupt state — never do it. To publish from a worker thread, queue the work back to the main thread first (the Scheduler service has a run-on-main-thread dispatch for exactly this).

  • Dispatch runs on a snapshot. Each Publish iterates a snapshot of the subscriber list taken when the publish starts. That makes it safe for a handler to subscribe or unsubscribe — even itself — during dispatch: the change simply takes effect for the next publish, not the one in flight. The snapshot is a small per-publish allocation; at extremely high publish rates with many subscribers this shows up as garbage-collector pressure, so batch very chatty updates where you can.

  • Cache the bus, don't Resolve every time. Resolve<IEventBus>() is a dictionary lookup. Calling it in Update pays that cost every frame. Get it once in Awake or Start and keep the reference.

  • Event types must be class or record, not struct. This keeps dispatch simple and avoids boxing allocations.

  • No inheritance-based dispatch. Subscribing to a base event type does not receive derived event types. Subscribe<BaseEvent> handlers are not called when you Publish<DerivedEvent>. Subscribe to the exact type you publish.

  • Dispose the subscription token. Forgetting Dispose() leaves the handler registered forever — a memory leak that also keeps the subscribing object alive. Unsubscribe in OnDisable/OnDestroy, or use using for scoped handlers.

  • Handler exceptions don't break dispatch. If one handler throws, the exception is logged and the next handler still runs. Your publish call always completes normally.

  • Custom event classes need link.xml for IL2CPP. If you build with IL2CPP, add your event classes to your project's link.xml so the build's code stripping does not remove them:

    <assembly fullname="YourNamespace" preserve="all">
        <type fullname="YourNamespace.PlayerDamagedEvent" preserve="all"/>
    </assembly>
    
  • Deferred Event Queue — queue events now, flush them later (the asynchronous sibling of this bus)
  • Configuration — publishes ConfigurationChanged<TGroup> events through this bus
  • Scheduler — run-on-main-thread dispatch for publishing from worker threads
  • Bootstrap — startup order
  • Service Locator — resolving and caching the bus