Sample image of my game
← Back to all posts

Building an Event-Driven Battle System with Godot

10 min read
GodotC#Game DevelopmentArchitecture
Building an Event-Driven Battle System with Godot

How I coordinate the battle systems in my tactics RPG using a small event bus for scene-wide events and per-unit Godot signals for everything else — and the rule I use to decide which belongs where.

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.

Battle Demo

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 BattleEventBus node for scene-wide, multi-listener events that aren't owned by any one entity.
  • Per-unit [Signal] declarations on BattleUnit for 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 / CutsceneEnded are 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.
  • BattleStateChanged ships an int rather 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".
  • UIBattleRewardsClosed is 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

Battle nodes structure

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:

  1. BattleMap._Process polls the mouse and checks whether the active unit is player-controlled and the battle is in Ongoing state.
  2. On mouse_left_click, it calls CurrentUnit.Mover.StartMoving(path) directly. No bus event here — there's only one caller and one callee.
  3. StartMoving tweens the unit along the path and calls _battleUnit.EmitMoving(). The host's state flips to Moving; the BattleActionMenu listening to Moving hides itself.
  4. When the tween finishes, the mover sets HadMoved = true, hides the move guide, and emits ActionSelection again.
  5. The action menu, also subscribed to ActionSelection, reopens — this time with the "Cancel Move" option since HadMoved is 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:

  1. BattleUnitHealth drops a unit's HP to zero. It calls _battleUnit.EmitDied(unit).
  2. BattleManager.OnBattleUnitDied is subscribed to every unit's Died. It checks the dying unit for a child BattleCutsceneTriggerUnitDied node.
  3. If one exists, the trigger calls BattleCutscene.PlayCutscene(), which emits CutsceneStarted on the bus.
  4. BattleManager is also subscribed to CutsceneStarted — it flips BattleState to Cutscene (remembering the previous state), hides the action menu, hides the turn order panel.
  5. The cutscene runs through its parts (move some units, run some dialogue, add reinforcements). When the last part finishes, the cutscene emits CutsceneEnded on the bus.
  6. BattleManager hears CutsceneEnded, restores the previous state, re-shows the UI, and calls CurrentTurnUnit.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. EmitSignal takes params Variant[] so anything compiles. The wrapper makes the contract explicit.
  • Side effects in one place. The per-unit emitters also update the unit's State enum. 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:

  • BattleStateChanged ships an int. 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.
  • CutsceneEnded silently no-ops. BattleManager early-returns from its CutsceneEnded handler if the current BattleState isn't Cutscene. If you ever emit CutsceneEnded without a matching CutsceneStarted, 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 Died to 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.

Resources