← Back to Blog

Building an Event-Driven Battle System with Godot

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

My experience implementing an event bus pattern in my tactics RPG using Godot and C#. A look at how this architecture helps decouple game systems for cleaner, more maintainable code.

Introduction

When building a tactics RPG, one of the biggest challenges is managing communication between different game systems. In my game, the battle system needs to notify the UI when units take damage, trigger animations when attacks happen, coordinate cutscene triggers, and update the turn order display—all while keeping these systems decoupled.

Battle Demo

Built with Godot 4.5 using C# (.NET 8.0), my game features a speed-based turn order system with grid-based tactical movement. This post shares how I implemented an event bus pattern using Godot's signal system to coordinate multiple battle events across different systems.

Note: This is an early stage implementation that's working for my needs right now, but I'm sure there are improvements to be made—particularly around event ordering, error handling, and performance optimization as the game grows. I'll touch on some of these considerations throughout the post.

What is an Event Bus?

An event bus is a centralized communication hub where different parts of your game can send and receive messages without directly referencing each other. Think of it as a message board where:

  • Publishers emit events without knowing who's listening
  • Subscribers listen for events without knowing who emitted them
  • Events carry specific data relevant to that event

This creates loose coupling between systems, which helps with maintaining larger codebases.

Benefits of Event-Driven Architecture

  • Loose Coupling: Systems don't need direct references to each other
  • Extensibility: Easy to add new features without modifying existing code
  • Debugging: Central location to monitor all system communications
  • Scalability: New subscribers can be added without breaking existing functionality

Implementation Using Godot Signals

Battle Event Bus Signal Flow Diagram

Godot has a built-in signal system that works well for implementing an event bus pattern. The BattleEventBus is a simple Node that declares signals for different battle events:

public partial class BattleEventBus : Node { [Signal] public delegate void UnitAttackingEventHandler(BattleUnit attacker, BattleUnit? defender); [Signal] public delegate void UnitDiedEventHandler(BattleUnit unit); [Signal] public delegate void NextTurnReadyEventHandler(BattleUnit unit); [Signal] public delegate void BattleStateChangedEventHandler(int state); // Helper methods for type-safe emission public void EmitUnitDied(BattleUnit unit) { EmitSignal(SignalName.UnitDied, unit); } public void EmitBattleStateChanged(BattleState state) { EmitSignal(SignalName.BattleStateChanged, (int)state); } // ... more signals and helper methods }

Scene Node Setup

Battle nodes structure

The BattleEventBus is added as a scene node in the battle map. Battle-related scripts reference it using Godot's unique name lookup:

// Battle scripts get a reference to the event bus node _battleEventBus = GetTree().CurrentScene.GetNode<BattleEventBus>("%BattleEventBus");

Example: Battle State Management

The BattleManager subscribes to various events and transitions between states accordingly:

// Combat events trigger state changes _battleEventBus.UnitAttacking += (_, _) => { BattleState = BattleState.Attacking; }; // Handle unit death _battleEventBus.UnitDied += unit => { BattleUnits.Remove(unit); // Check win/loss conditions if (Enemies.Count == 0 || Allies.Count == 0) { _battleEventBus.EmitBattleEnded(); } }; // Cutscene events pause battle flow _battleEventBus.CutsceneSignal += _ => { BattleState = BattleState.Cutscene; };

The BattleManager doesn't need to know about the UI or animation systems—it just manages state transitions in response to events.

Example: Unit Death with Cutscene Event Flow

When a unit dies, multiple systems need to respond. Here's how it flows through the event bus:

// 1. BattleUnit emits death event private void Die() { _battleEventBus!.EmitUnitDied(this); // Check for cutscene trigger if (CutsceneSignalOnDied != null) { _battleEventBus.EmitCutsceneSignal( CutsceneSignalOnDied.CutsceneSignal ); } } // 2. BattleManager removes unit and sets the battle state to cutscene (this pauses the battle flow) _battleEventBus.UnitDied += unit => { BattleUnits.Remove(unit); // Check win/loss conditions if (Enemies.Count == 0 || Allies.Count == 0) { _battleEventBus.EmitBattleEnded(); } }; _battleEventBus.CutsceneSignal += _ => { BattleState = BattleState.Cutscene; }; // 3. TurnDetails UI updates turn order display _battleEventBus.UnitDied += unit => { RemoveUnitFromTurnOrderDisplay(unit); }; // 4. BattleUnitCutscene triggers death cutscene of the unit _battleEventBus.CutsceneSignal += HandleCutsceneSignal; // 5. After cutscene ends, BattleManager resumes battle flow _battleEventBus.CutsceneEnded += _ => { BattleState = _previousBattleState; };

Multiple systems respond to the same event, but none of them know about each other. The BattleUnit doesn't need references to TurnDetails or the cutscene system—it just emits one event.

Example: Turn-Based System

My game uses a speed-based turn system. Here's how the event bus coordinates turn transitions:

// TurnDetails announces the next turn if (unit.TempTurnValue >= TURN_COST) { _battleEventBus!.EmitNextTurnReady(unit); } // BattleManager receives the event and starts the turn _battleEventBus.NextTurnReady += unit => { CurrentTurnUnit = unit; _battleEventBus.EmitBattleTurnStarted(); }; // BattleUnit acts on its turn _battleEventBus.BattleTurnStarted += () => { if (IsCurrentTurnUnit && IsEnemy) { EnemyAIMove(); } else { // Action menu is shown } };

Event Timing and Synchronous Execution

One important aspect of Godot's signal system is that signals are processed synchronously in subscription order. This means:

  1. When you emit a signal, all connected callbacks execute immediately
  2. Callbacks execute in the order they were connected
  3. Control returns to the emitter only after all subscribers finish

This can be both helpful and challenging:

Helpful: Guaranteed execution order for critical operations Challenging: Long-running callbacks can block the game

For non-critical events, deferring signal emission to the next frame can help:

// Deferred emission - happens on next frame CallDeferred( MethodName.EmitSignal, BattleEventBus.SignalName.BattleUnitsUpdated );

Things I've Learned So Far

After working with this system for a few months, here are some things that have been helpful:

1. Create Helper Methods for Emitting Events

Instead of calling EmitSignal directly everywhere, create helper methods:

// Easier to use and maintains consistency public void EmitUnitDied(BattleUnit unit) { EmitSignal(SignalName.UnitDied, unit); } // Usage: _battleEventBus.EmitUnitDied(this);

2. Document Your Events

I try to keep documentation about:

  • All available events
  • What data each event carries
  • When each event is emitted
  • What systems subscribe to each event

3. Event vs. Direct Call

Not everything should be an event. Use direct calls when:

  • You need a return value
  • It's a 1-to-1 relationship
  • Performance is critical
  • The operation requires confirmation

Use events when:

  • Multiple systems need to react
  • Systems should be decoupled
  • The operation is one-way (fire-and-forget)
  • You want optional subscribers

4. Avoid Event Chains

Be careful of events triggering other events in long chains. This creates hard-to-debug behavior and can lead to stack overflow errors:

// Dangerous: Event chain EventA → EventB → EventC → EventD // Better: Single event with comprehensive data EventActionComplete → (Multiple independent subscribers)

What's Been Working Well

The event bus approach has made a few things easier in my game:

  1. Adding the cutscene system was simpler since I didn't have to modify existing battle or unit code
  2. Implementing the turn order UI just required subscribing to existing events
  3. Adding new features has been more straightforward by adding new subscribers

Areas for Improvement

As with any early implementation, there are several things I'd like to improve:

  1. Event Debugging Tools

    • Add logging to track event emissions and subscriptions
    • Build a debug view to visualize event flow
    • Better error handling when subscribers throw exceptions
  2. Performance Monitoring

    • Profile event emission overhead as the number of subscribers grows
    • Consider event pooling for frequently emitted events
    • Look into async event handling for non-critical updates
  3. Event Documentation

    • Build a system to auto-generate event documentation
    • Add runtime validation for event parameters
    • Create better tooling for discovering available events
  4. Testing

    • Improve unit testing for event-driven code
    • Add integration tests for complex event chains
    • Build mock event bus for isolated testing

Conclusion

Using an event bus pattern with Godot's signal system has been helpful for keeping my tactics RPG battle system organized. While it adds a layer of indirection, the benefits in code organization and flexibility have been worth it so far.

Things that have been helpful:

  • Referencing the event bus as a scene node in battle-related scripts
  • Creating helper methods for emitting signals
  • Documenting events as I go
  • Being mindful of synchronous execution
  • Thinking about when to use events vs. direct calls

If you're building a game system in Godot with C#, an event-driven architecture might be worth considering early on. It's made adding new features easier for me, though I'm sure there are trade-offs I haven't encountered yet as the project grows.

Resources