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 XP system grants experience.
- The drop system rolls loot.
- The UI system shows feedback.
- The audio system plays a sound.
- The quest system updates progress.
- The achievement system checks unlocks.
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 moment | Good event | Likely listeners |
|---|---|---|
| Enemy dies | enemy:died | XP, drops, UI, audio, stats |
| Player takes damage | player:damage_taken | UI, audio, camera, analytics |
| Floor changes | dungeon:floor_changed | spawner, save system, music, difficulty |
| Item is equipped | item:equipped | stats, UI, build validation |
| Run ends | run:ended | meta-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:
- Event names are stable.
- Payloads are typed.
- 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-pattern | What goes wrong | Better approach |
|---|---|---|
| One global event for everything | Payloads become vague and unsafe | Use domain-specific event names |
| Command-style events | Systems secretly control one another | Emit facts, call commands explicitly |
| No unsubscribe path | Restarting a scene doubles listeners | Return and store unsubscribe functions |
| Hidden ordering dependencies | A listener must run before another listener to avoid bugs | Use explicit orchestration for ordered logic |
| Event payload drift | Systems disagree about what fields exist | Keep a typed event map and review changes |
| Debugging blind spot | Nobody knows why a state changed | Add optional event history in development builds |
Practical Checklist
- [ ] Each event name describes a fact that already happened.
- [ ] Event payloads are typed and documented.
- [ ] Subscriptions can be removed during scene, level, or run cleanup.
- [ ] Core combat calculations do not depend on listener order.
- [ ] UI, sound, particles, stats, and achievements listen instead of being called directly by combat entities.
- [ ] High-frequency events are throttled, batched, or kept out of the bus.
- [ ] Debug builds can list active listeners and recent events.
Example Workflow
- List the gameplay facts that currently trigger three or more systems.
- Convert only those facts into events.
- Define the payload for each event.
- Move UI, audio, particle, stats, and achievement reactions into listeners.
- Keep domain-critical decisions as explicit calls.
- Add cleanup tests for scene restart, run restart, and game-over flow.
References
- Robert Nystrom. "Observer." Game Programming Patterns.
- Robert Nystrom. "Event Queue." Game Programming Patterns.
- Unity Technologies.
UnityEventscripting API. - Epic Games. "Delegates and Lambda Functions in Unreal Engine."
- Epic Games. "Event Dispatchers in Unreal Engine."
- Godot Engine documentation. "Using signals."