I keep coming back to event buses for one simple reason: game code often needs to say “something happened” without dragging half the project into the conversation.

A player dies. A quest advances. The HUD flashes. A sound plays. Analytics records a milestone. None of those systems needs to know the others exist. They just need a clean way to react to the same moment.

That is where an event bus earns its keep.

It is not magic. It is not a replacement for all references. It is just a transport layer for notifications. Used well, it keeps gameplay code readable. Used badly, it becomes a global junk drawer with a nicer name.

What I mean by an event bus

An event bus sits between the thing that raises a message and the things that care about it.

The sender publishes an event. The bus delivers it to subscribers. The sender does not need direct references to the listeners.

That is the whole shape.

player, enemy, UI, system
      event bus
   ┌──────┼────────┬────────┐
   ▼      ▼        ▼        ▼
hud    audio     quest    save system

I like this pattern when the relationship is one-to-many and the sender should stay ignorant of the receivers.

That last part matters. An event bus is not a place to store state. It is not a service locator with better branding. It is not a substitute for a proper ownership model.

If I want to ask “what is the current score?” I should read score state. If I want to announce “the score changed,” I can publish an event.

That distinction keeps the architecture honest.

Why direct calls stop scaling

Direct references are fine when two objects are obviously related.

A door listens to a trigger. A health bar watches a health component. A small feature with two or three moving parts can be clearer with direct calls than with any event system.

The trouble starts when the same style spreads everywhere.

Then I get code that looks like this:

  • enemy death calls score code
  • score code calls UI code
  • UI code calls audio code
  • audio code calls achievement code
  • achievement code calls analytics code

Each link looks harmless on its own. Together they form a chain of invisible assumptions.

The project still works, but every change starts to feel expensive. If I rename a method or split a subsystem, I have to hunt through a pile of unrelated callers. If I load a different scene, some object still thinks the old one should be there. If I want to test one piece in isolation, I need to fake half the game.

That is the real cost.

An event bus helps because it turns those hard references into contracts around meaning.

Not “call this object.”

“When this happens, anyone who cares may react.”

That is a much better boundary for cross-cutting game events.

The diagram I keep in mind

This is the mental model I use when I decide whether to reach for a bus.

Event bus flow diagram

The sender only knows about the bus. The bus knows about subscribers. The subscribers only know the message type they care about.

That fan-out is the point.

If I need a single response from a specific object, a bus is probably the wrong tool. If I need a broadcast that should reach several systems without forcing them to know about each other, the bus starts to look right.

A small bus is enough

I do not start with a giant framework.

I usually want something close to this:

public interface IEventBus
{
    IDisposable Subscribe<T>(Action<T> handler);
    void Publish<T>(T message);
}

That is enough for most gameplay code.

The subscription returns IDisposable so the listener can detach cleanly. I like that because it makes lifetime obvious. When the listener dies, the subscription should die too.

A tiny implementation looks like this:

public sealed class EventBus : IEventBus
{
    private readonly Dictionary<Type, List<Delegate>> _subscribers = new();

    public IDisposable Subscribe<T>(Action<T> handler)
    {
        if (!_subscribers.TryGetValue(typeof(T), out var list))
        {
            list = new List<Delegate>();
            _subscribers[typeof(T)] = list;
        }

        list.Add(handler);
        return new Subscription(() => list.Remove(handler));
    }

    public void Publish<T>(T message)
    {
        if (!_subscribers.TryGetValue(typeof(T), out var list))
            return;

        // Ceiling: this copies the listener list so publish stays safe if a handler unsubscribes mid-flight.
        // If this becomes hot, split the bus by feature or move to a type-specific dispatch path.
        var snapshot = list.ToArray();
        for (int i = 0; i < snapshot.Length; i++)
        {
            ((Action<T>)snapshot[i]).Invoke(message);
        }
    }

    private sealed class Subscription : IDisposable
    {
        private Action? _dispose;

        public Subscription(Action dispose)
        {
            _dispose = dispose;
        }

        public void Dispose()
        {
            Interlocked.Exchange(ref _dispose, null)?.Invoke();
        }
    }
}

That is not the only possible shape, and it is not the final word. It is the smallest version I am willing to carry in a real project.

If a team wants a more explicit setup, I will often break this into message channels or feature-specific buses. If a project is tiny, I may even use plain C# events instead.

The point is not to worship the bus. The point is to make communication cheaper to change.

The lifetime rule that saves me from pain

The biggest mistake I see with buses is not the dispatch code. It is the lifetime story.

If a listener subscribes and never unsubscribes, the bus can keep it alive longer than intended. That is how a clean pattern turns into a memory leak or a scene bug that only appears after a restart.

So I keep one rule in mind:

subscribe on enable, unsubscribe on disable, or dispose the token when the owner goes away.

In Unity code, that usually looks like this:

public sealed class HudScoreListener : MonoBehaviour
{
    [SerializeField] private ScoreView scoreView = null!;
    private IEventBus _bus = null!;
    private IDisposable? _subscription;

    public void Construct(IEventBus bus)
    {
        _bus = bus;
    }

    private void OnEnable()
    {
        _subscription = _bus.Subscribe<ScoreChanged>(OnScoreChanged);
    }

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

    private void OnScoreChanged(ScoreChanged message)
    {
        scoreView.SetScore(message.Value);
    }
}

That is boring code, which is exactly what I want here.

The bus can only stay tidy if ownership is obvious. If the project uses a scene lifetime scope, I keep the bus inside that scope. If the project has a persistent application root, I keep the bus there and treat it like a real long-lived service, not a magical global.

I do not default to a static bus unless the project is extremely small and I am willing to pay the maintenance cost later.

The other lifetime rule: events are not state

This one trips people up because the names sound similar.

An event says something changed. A state object says what is true now.

Those are different tools.

If a UI element joins late, it should not need to replay a pile of old events just to figure out the current value of health, ammo, or quest progress. That information belongs in state.

The event bus should carry transitions:

  • enemy died
  • item picked up
  • inventory changed
  • objective completed
  • scene entered

The state holder should answer present-tense questions:

  • how much health remains
  • how many coins the player has
  • which quest is active
  • which menu is open

If I mix those two responsibilities, I get a bus that starts acting like a hidden database. Then every new subscriber has to ask, “Did I miss the important thing earlier?” That is not a bus problem. That is a design problem.

What I use it for in practice

There are a few places where I reach for a bus quickly:

  • combat and damage notifications
  • quest or objective progression
  • inventory and currency changes
  • UI reactions to gameplay changes
  • audio triggers that should not live inside gameplay code
  • editor tooling that wants to listen to gameplay state without owning it

Those are all places where a message is often better than a direct dependency.

A typical example is a damage flow.

The health component owns the actual numbers. When health reaches zero, it publishes a death event. A spawn system may listen and schedule a respawn. The score system may listen and award points. The UI may listen and flash a red frame. The audio system may listen and play a sting.

None of those subscribers should be making decisions about health itself. They should be responding to the result.

That separation keeps combat code focused.

Where I stop and use something simpler

I do not use an event bus for everything.

If two systems are local and stable, a direct reference is usually easier to follow. If one system owns another, I often prefer a method call. If a feature is purely procedural, a bus can be the wrong shape entirely.

I also avoid using a bus to hide bad ownership. Sometimes the reason two objects are talking so much is that they should be a single object, or a nested subsystem, or a small state machine. A bus can silence the smell without fixing it.

That is the lazy mistake.

Good architecture removes unnecessary coupling. Bad architecture just moves it out of sight.

The three failure modes I watch for

When event buses go wrong, they usually go wrong in one of three ways.

1. Everything becomes an event

If every method turns into a message, the code stops expressing intent. The bus becomes a dumping ground for everything the team does not want to model directly.

That is not decoupling. That is avoidance.

2. The bus becomes global state

A static bus is tempting because it is convenient. It is also dangerously invisible.

Now any code in the project can publish anything at any time. That makes the dependency graph harder to see and the test setup harder to control. If I have to reach for a global, I want to be sure the gain is worth the fog.

3. Subscribers forget to leave

This is the classic leak. The object dies, but the bus still holds a delegate, so something that should be gone is still reachable.

Unity scene changes are especially good at exposing this kind of mistake in ugly ways. If the listener is tied to a MonoBehaviour, I make unsubscribing part of the component lifecycle, not an afterthought.

Unity-specific tradeoffs

Unity gives a few different ways to model loose communication.

C# events are the cleanest option when the code owns the relationship and lifetime is clear. UnityEvent is useful when I want inspector wiring or designer-driven setup. ScriptableObject channels can be handy when I want a data asset to stand in for a message channel and expose it through the editor.

Those tools overlap, but they are not the same.

For code-heavy gameplay systems, I usually prefer plain C# events or a typed event bus because the flow stays explicit and the subscription lifecycle is straightforward.

For content-heavy workflows, I might use UnityEvent or a ScriptableObject channel if the editor integration is worth it.

The tradeoff is simple:

  • more editor convenience usually means more hidden wiring
  • more explicit code usually means easier tracing and debugging

I pick the one that matches the team’s pain, not the one that sounds elegant in a vacuum.

Debugging and tracing are part of the design

A bus is only good if I can still answer “who reacted to this?”

If I cannot trace the flow, I have not simplified anything. I have just made the code harder to follow.

So I keep a few habits:

  • use message types with clear names
  • keep messages small and specific
  • avoid giant generic payloads
  • log selectively in debug builds if the flow is tricky
  • avoid publishing from places that already have an obvious direct caller

I also try to keep the bus boundary narrow. The more systems the bus touches, the more careful I have to be about naming, ownership, and lifetime.

That is why I prefer a few clear buses or channels over one giant one when a project grows.

The shape I reach for

If I had to reduce the pattern to one sentence, it would be this:

Use an event bus when one thing needs to announce a change to many things, and none of those listeners should own the sender.

That rule is boring, but it is reliable.

Once I start using the bus to mask ownership problems, I am already losing. Once I use it as a broadcast layer for genuine cross-cutting notifications, it pays for itself quickly.

I keep the sender focused. I keep the listeners separate. I keep the message small. I keep the lifetime explicit.

That is enough.

Takeaway

I do not use event buses because they are fashionable. I use them when they make communication easier to change without making ownership harder to see.

If I keep the bus scoped, typed, and disposable, it stays useful. If I let it become global state or a dumping ground for every relationship in the game, it turns into the same mess I was trying to avoid.

References

These sources shaped the framing and tradeoffs in this post: