event bus vs direct references for game systems: when does decoupling actually help and when is it just noise?

435 views 6 replies

Started using a global event bus on my current project because I kept hearing "decouple your systems" and it seemed like the right thing to do. Six months in, I'm not sure it was the right call for a project at this scale.

The pattern is seductive: your UI doesn't need a direct reference to the inventory system, your audio manager doesn't need to know about everything that makes a sound. Just fire events and subscribe wherever you need them. Clean in theory. In practice I'm spending way too much time tracing event chains through four or five systems to figure out why a sound played twice or why the UI updated before the underlying state was ready.

With direct references, at least the call stack tells you exactly what happened. With events, you're hunting down all the subscribers scattered across the codebase and hoping none of them have side effects that interact in unexpected ways.

I think the actual problem is I've been using events for both notification ("the player died") and coordination ("now update the inventory and UI accordingly"), and they're really only suited for the former.

Some patterns I'm testing now:

  • Events for cross-cutting concerns: achievements, analytics, audio cues
  • Direct references within a bounded subsystem
  • Command pattern for operations that need to be queued or undone
  • ScriptableObject-based event channels (Unity), which keeps decoupling without totally killing debuggability

The ScriptableObject channel approach has been genuinely useful. Ryan Nutter's Unite talk covers it well, and being able to inspect the channel asset in the editor during play mode makes debugging so much easier than a pure code-level bus. You can see exactly how many listeners are subscribed at any given moment, which a typical event system just doesn't give you.

Still not convinced I've found the right balance. Anyone gone deep on this in a larger codebase and come out with a philosophy that actually holds up?

Replying to IronLynx: The cases where I've found a global event bus worth the complexity: when the pub...

The "no shared ancestor" case is the one I've found genuinely defensible. The rest though, I've seen codebases where someone reached for the event bus just to avoid passing a reference through two levels of hierarchy, which really doesn't justify the observability cost. You end up with event names as magic strings scattered across files and no clean way to trace what's actually listening for what without grepping the whole project.

Autoload singletons or explicit dependency injection via _ready get you 80–90% of the decoupling value with almost none of the tracing complexity. I keep the event bus for the specific cases you mentioned and treat everything else as a code smell that probably means the architecture needs a rethink, not more indirection.

The cases where I've found a global event bus worth the complexity: when the publisher and subscriber live in completely separate scene trees with no shared ancestor, or when multiple unrelated systems need to react to the same event without the publisher needing to know who's listening. Audio triggers, achievement unlocks, UI popups reacting to gameplay, those are all legitimate. The bus is doing real work there.

But for something like "player takes damage and the health bar updates," a direct reference or a plain Godot signal on the player node is simpler, more debuggable, and easier to trace. The issue with committing to a global bus too early is that you start routing everything through it by default, even the things where a direct call would be obvious. Six months of that and tracing the flow of any event means grepping for the event name across your whole codebase and hoping nothing subscribes from a place you forgot about.

The question I ask now before adding something to the bus: would I be embarrassed to explain to someone why this couldn't just be a signal? If the answer is no, it goes on the node.

Replying to IronLynx: The cases where I've found a global event bus worth the complexity: when the pub...

That "no shared ancestor" framing clicked for me. I'd add one more case where it actually makes sense: true one-to-many broadcasts where you don't know and actively shouldn't care how many subscribers exist. A day/night cycle transitioning might need to notify shaders, enemy AI schedules, ambient audio, NPC routines, weather particles. The day/night system shouldn't hold references to any of those, and the list will probably change as the project grows. That's where a global event bus stops feeling like overengineering and starts being the right call. Outside of those two cases though, I've basically stopped reaching for it by default.

The question I ask is whether the coupling I'm eliminating is accidental or inherent. If two systems genuinely need to coordinate on a shared event, player died, level loaded, score changed, some coupling is just the nature of the problem. An event bus makes that coupling explicit and loosely typed, which can be fine. But if you're reaching for the bus just because wiring a direct reference felt annoying, you've added indirection without adding clarity, and the next person reading the code has to trace the signal backward to understand what's actually happening.

I've started defaulting to direct references unless I can articulate a specific reason the publisher shouldn't know about its subscribers. In practice that ends up ruling out most bus usage in small-to-mid projects without giving up much.

Replying to PixelLark: The question I ask is whether the coupling I'm eliminating is accidental or inhe...

that accidental vs inherent distinction is something I've been trying to put into words for a while. I'd push it one step further though: even for inherent coupling, the event bus can still be the wrong tool if the systems need to coordinate rather than just notify. events are fire-and-forget. the moment system A needs something back from system B, "is this valid?", "what's your current state?", you've got a query, not an event, and now you're doing async request-response over a pub/sub system which is just pain. direct calls or a shared interface handle that case way better.

Replying to IronReed: that accidental vs inherent distinction is something I've been trying to put int...

The failure mode I keep seeing is teams treating the event bus as a communication style rather than an architectural tool. Every system interaction goes through events because the codebase is "event-driven," not because those specific interactions actually benefit from indirection. You end up needing to grep through fifteen event names just to understand what happens when the player dies, and hoping someone documented the fire order somewhere.

At that point the bus isn't reducing cognitive load, it's adding it. The original question here was "when does decoupling actually help" and I think the honest answer is: less often than the architecture discourse would have you believe. Explicit references are underrated.

Moonjump
Forum Search Shader Sandbox
Sign In Register