Event Bus Architecture for Roguelike Systems: Decouple Combat, UI, Audio, and Progression

If your roguelike combat code is starting to know about UI, sound, achievements, save data, particles, and meta-progression, an event bus can help you separate systems before the project becomes difficult to change. This guide is for developers building turn-based, real-time, or hybrid roguelikes who need practical decoupling without hiding every interaction behind vague global messaging.

The Core Claim

An event bus is useful when many systems need to react to the same gameplay fact, but the system that produced that fact should not know who reacts to it.

For example, when an enemy dies, the combat system may only need to say:

events.emit("enemy:died", {
  enemyId,
  enemyType,
  position,
  killerId,
  floor,
});

Other systems can independently react:

The enemy death event becomes a stable contract. The combat system no longer needs direct references to every downstream system.

When an Event Bus Helps

Use an event bus for cross-system announcements:

Gameplay momentGood eventLikely listeners
Enemy diesenemy:diedXP, drops, UI, audio, stats
Player takes damageplayer:damage_takenUI, audio, camera, analytics
Floor changesdungeon:floor_changedspawner, save system, music, difficulty
Item is equippeditem:equippedstats, UI, build validation
Run endsrun:endedmeta-progression, save, results screen

Avoid using an event bus for tight, immediate domain logic where ordering is part of correctness. Damage calculation, collision resolution, and inventory validation usually deserve explicit function calls or domain services.

A Minimal Typed Event Bus

The simplest useful version has three properties:

  1. Event names are stable.
  2. Payloads are typed.
  3. Subscriptions are easy to remove.
type GameEvents = {
  "enemy:died": {
    enemyId: string;
    enemyType: string;
    position: { x: number; y: number };
    killerId: string;
    floor: number;
  };
  "player:damage_taken": {
    amount: number;
    sourceId: string;
    damageType: "physical" | "fire" | "poison" | "true";
  };
  "run:ended": {
    result: "win" | "death" | "abandon";
    floor: number;
    durationSeconds: number;
  };
};

type Handler<T> = (payload: T) => void;

class EventBus<Events extends Record<string, unknown>> {
  private listeners = new Map<keyof Events, Set<Handler<any>>>();

  on<K extends keyof Events>(event: K, handler: Handler<Events[K]>) {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }

    this.listeners.get(event)!.add(handler);

    return () => {
      this.listeners.get(event)?.delete(handler);
    };
  }

  emit<K extends keyof Events>(event: K, payload: Events[K]) {
    for (const handler of this.listeners.get(event) ?? []) {
      handler(payload);
    }
  }

  clear() {
    this.listeners.clear();
  }
}

This is enough for many small roguelikes. Add priority, replay, async queues, or debug history only when a real workflow needs them.

How to Name Events

Use a domain:fact convention. The event should describe what happened, not what another system should do.

Good:

enemy:died
player:level_gained
item:equipped
dungeon:floor_changed
run:ended

Risky:

showDeathScreen
playEnemyDeathSound
grantDropNow
updateQuestUI

The second group turns the event bus into a remote-control layer. That creates the same coupling problem with a different syntax.

Engine Notes

Phaser

Phaser projects often start with scene-level references. An event bus helps when one scene owns combat while UI, audio, and particle feedback live in different modules. Clear scene-specific subscriptions during scene shutdown so old listeners do not keep reacting after a restart.

Unity

Unity projects can use UnityEvent, C# events, ScriptableObject event channels, or a custom bus. The same rule applies: use events for cross-system announcements, not for hidden gameplay control flow. Keep payload classes small and versionable.

Unreal Engine

Unreal delegates and event dispatchers already express this pattern. They are a good fit for UI notifications, animation hooks, encounter events, and gameplay state broadcasts. For core combat logic, keep authoritative state changes explicit.

Godot

Godot signals are a natural local version of this pattern. For larger projects, use signals for node-level communication and reserve a global bus for game-wide facts such as run state, floor transitions, or meta-progression updates.

Anti-Patterns

Anti-patternWhat goes wrongBetter approach
One global event for everythingPayloads become vague and unsafeUse domain-specific event names
Command-style eventsSystems secretly control one anotherEmit facts, call commands explicitly
No unsubscribe pathRestarting a scene doubles listenersReturn and store unsubscribe functions
Hidden ordering dependenciesA listener must run before another listener to avoid bugsUse explicit orchestration for ordered logic
Event payload driftSystems disagree about what fields existKeep a typed event map and review changes
Debugging blind spotNobody knows why a state changedAdd optional event history in development builds

Practical Checklist

Example Workflow

  1. List the gameplay facts that currently trigger three or more systems.
  2. Convert only those facts into events.
  3. Define the payload for each event.
  4. Move UI, audio, particle, stats, and achievement reactions into listeners.
  5. Keep domain-critical decisions as explicit calls.
  6. Add cleanup tests for scene restart, run restart, and game-over flow.

References

  1. Robert Nystrom. "Observer." Game Programming Patterns.
  2. Robert Nystrom. "Event Queue." Game Programming Patterns.
  3. Unity Technologies. UnityEvent scripting API.
  4. Epic Games. "Delegates and Lambda Functions in Unreal Engine."
  5. Epic Games. "Event Dispatchers in Unreal Engine."
  6. Godot Engine documentation. "Using signals."