Building an Event-Driven Battle System with Godot
A tactical battle in my game has a lot of moving pieces. The map needs to know which unit is taking its turn. The turn-order UI needs to know when a unit dies. The cutscene system needs to pause the battle and put it back together afterward. The action menu needs to come and go as units move and attack. Coordinating all of that without every system holding a direct reference to every other system is what event-driven architecture is for.

This is an early stage implementation that has been holding up well as I add features, but there are real rough edges — particularly around debugging events that fire silently and the lack of a consistent payload convention across signals.
What I Mean by Event-Driven
Two things publish a message; anyone who cares about that message subscribes. Publishers don't know who is listening, subscribers don't know who emitted. That decoupling means I can add a new system that reacts to "a unit died" without touching the code that emits it.
In Godot with C#, the building block for this is the [Signal] attribute. You declare a delegate, and any node can connect to it. That's the whole primitive — what's interesting is where I declare signals.
Two Kinds of Events
I split things across two different surfaces:
- A central
BattleEventBusnode for scene-wide, multi-listener events that aren't owned by any one entity. - Per-unit
[Signal]declarations onBattleUnitfor lifecycle transitions of that specific unit.
I didn't start with this split. Early on almost everything went on the bus, and every subscriber ended up having to filter "is this MY event?" by checking the sender. When I refactored battle units into a composition shape, I moved per-unit lifecycle events onto signals declared right on BattleUnit. Anyone listening to a unit's Died event subscribes to that unit's Died. No more sender filtering.
The mental rule I use now:
If multiple unrelated systems care about an event and it isn't tied to one entity, put it on the bus. If it's a state transition for one specific unit, put it on the unit.
The Bus Today
The current event bus is small — just signals for things that genuinely cross many systems:
public partial class BattleEventBus : Node { [Signal] public delegate void CutsceneStartedEventHandler(); [Signal] public delegate void CutsceneEndedEventHandler(); [Signal] public delegate void BattleStateChangedEventHandler(int state); [Signal] public delegate void UIBattleRewardsClosedEventHandler(); public void EmitCutsceneStarted() => EmitSignal(SignalName.CutsceneStarted); public void EmitCutsceneEnded() => EmitSignal(SignalName.CutsceneEnded); public void EmitBattleStateChanged(BattleState state) => EmitSignal(SignalName.BattleStateChanged, (int)state); public void EmitUIBattleRewardsClosed() => EmitSignal(SignalName.UIBattleRewardsClosed); }
A few details:
CutsceneStarted/CutsceneEndedare the most load-bearing. When a cutscene starts, the action menu has to hide, the turn order panel has to disappear, and the map has to stop responding to clicks. None of those systems know about each other — they all just listen to the bus.BattleStateChangedships anintrather than the enum. Godot's signal marshaling doesn't carry C# enums across the variant boundary cleanly, so subscribers cast back. I learned this the hard way after a confusing afternoon of "the signal fires but the type is wrong".UIBattleRewardsClosedis the one event that's pure UI-to-game-logic — the rewards modal emits it when the player dismisses, and the map exits the battle scene in response.
Scene Setup

The bus is a regular node parked under the battle scene with a unique name. Any battle-related script grabs it via:
_battleEventBus = GetTree().CurrentScene.GetNode<BattleEventBus>("%BattleEventBus");
No singleton, no autoload — just a node in the scene. When the battle scene tears down, the bus goes with it, which is exactly what I want for scoped lifetime.
The Per-Unit Signals
This is where most of the coordination actually happens now. BattleUnit declares its own signals for each lifecycle step:
public partial class BattleUnit : Node2D { [Signal] public delegate void ActionSelectionEventHandler(); [Signal] public delegate void PreMoveEventHandler(); [Signal] public delegate void MovingEventHandler(); [Signal] public delegate void PreAttackEventHandler(); [Signal] public delegate void AttackingEventHandler(); [Signal] public delegate void WaitingEventHandler(); [Signal] public delegate void DiedEventHandler(BattleUnit unit); [Signal] public delegate void AttackedEventHandler(int damage); public void EmitActionSelection() { State = BattleUnitState.ActionSelection; EmitSignal(SignalName.ActionSelection); } // ... one EmitXxx helper per signal, each also updating State }
The components on a unit — BattleUnitMover, BattleUnitAttacker, BattleActionMenu, BattleUnitAutoCombat — subscribe to their parent unit's signals on _EnterTree:
public override void _EnterTree() { _battleUnit = GetParent<BattleUnit>(); _battleUnit.ActionSelection += OnActionSelection; }
When the unit emits ActionSelection, only that unit's listeners hear it. No filtering, no checking "is this me?".
Died(BattleUnit) is the one per-unit signal that does carry an identifier — and it does it for a specific reason. The unit is dying, so anything that needs to react globally (the turn coordinator, the battle manager checking win conditions) needs to know which unit. That's exactly the case where a per-unit signal with a payload is the right choice over a bus event.
End-to-End: A Player Clicking a Tile
Tracing through what happens when the player clicks a tile to move:
BattleMap._Processpolls the mouse and checks whether the active unit is player-controlled and the battle is inOngoingstate.- On
mouse_left_click, it callsCurrentUnit.Mover.StartMoving(path)directly. No bus event here — there's only one caller and one callee. StartMovingtweens the unit along the path and calls_battleUnit.EmitMoving(). The host's state flips toMoving; theBattleActionMenulistening toMovinghides itself.- When the tween finishes, the mover sets
HadMoved = true, hides the move guide, and emitsActionSelectionagain. - The action menu, also subscribed to
ActionSelection, reopens — this time with the "Cancel Move" option sinceHadMovedis true.
Notice the bus isn't involved at all. Everything flows through per-unit signals because the cast of characters is just "this unit and its components".
End-to-End: A Unit Dies and a Cutscene Plays
Now a flow that uses both:
BattleUnitHealthdrops a unit's HP to zero. It calls_battleUnit.EmitDied(unit).BattleManager.OnBattleUnitDiedis subscribed to every unit'sDied. It checks the dying unit for a childBattleCutsceneTriggerUnitDiednode.- If one exists, the trigger calls
BattleCutscene.PlayCutscene(), which emitsCutsceneStartedon the bus. BattleManageris also subscribed toCutsceneStarted— it flipsBattleStatetoCutscene(remembering the previous state), hides the action menu, hides the turn order panel.- The cutscene runs through its parts (move some units, run some dialogue, add reinforcements). When the last part finishes, the cutscene emits
CutsceneEndedon the bus. BattleManagerhearsCutsceneEnded, restores the previous state, re-shows the UI, and callsCurrentTurnUnit.EmitActionSelection()to wake the current unit back up.
The per-unit Died signal handles "this particular unit just stopped existing". The bus events handle "the whole battle scene needs to gate itself". Different scopes, different surfaces.
Helper Methods for Emitters
Every signal has a wrapper:
public void EmitCutsceneStarted() => EmitSignal(SignalName.CutsceneStarted); // ... and on BattleUnit: public void EmitDied(BattleUnit unit) { State = BattleUnitState.Died; EmitSignal(SignalName.Died, unit); }
Two reasons for the wrapper:
- Type safety.
EmitSignaltakesparams Variant[]so anything compiles. The wrapper makes the contract explicit. - Side effects in one place. The per-unit emitters also update the unit's
Stateenum. Doing that inline at every call site would be a bug magnet.
Gotchas I've Hit
A few that took me longer to find than I'd like:
BattleStateChangedships anint. As mentioned above, Godot signals can't carry C# enums directly. The bus casts the enum on emit, subscribers cast back. Not a bug, but worth flagging once you start adding signal-carried payloads.CutsceneEndedsilently no-ops.BattleManagerearly-returns from itsCutsceneEndedhandler if the currentBattleStateisn'tCutscene. If you ever emitCutsceneEndedwithout a matchingCutsceneStarted, the event fires and does nothing, which is exactly the kind of "wait, my callback ran but… nothing happened" debug session you don't want.- Self-unsubscribing handlers leak if you're sloppy. The battle map's entrance-cutscene handler unsubscribes itself when the cutscene ends. Forget that pattern on a one-shot handler and you'll re-run that initialization every time a cutscene ends, which won't crash but will absolutely produce weird state drift.
- Putting a per-unit event on the bus undoes the refactor. I caught myself once moving
Diedto the bus because "it's used by multiple systems". That's true, but the subscribers don't care about which unit died only after they get the payload — they need to know on subscription. The per-unit signal is the right tool.
Areas for Improvement
A few things I'd like to come back to:
- Event debugging. Right now there's no built-in way to log every emission. A wrapper that prints events in a debug build would save a lot of "is this even firing?" investigations.
- Conventions for event payloads. Right now some events ship an int, some ship a Vector2I, some ship a unit reference. A documented convention would help, especially for any new contributor.
Wrap-up
The biggest thing I've learned working on this isn't that an event bus is good. It's that "use an event bus" is too coarse a rule. The bus is one tool for scene-wide events that genuinely belong to nobody in particular. For everything else — and "everything else" turned out to be most of it — declaring signals on the thing they belong to is a much better fit, and it removes a whole category of "is this for me?" filtering from every subscriber.
As I keep building on this, I'm sure I'll find more patterns to tidy up. But the bus-vs-per-unit-signal split has been the single most useful mental model for keeping the battle system organized.

