← Back to Blog

Building a Speed-Based Turn Order System for a Tactics RPG

12 min read
Game DevelopmentTactics RPGGodotC#Turn-Based Combat
Building a Speed-Based Turn Order System for a Tactics RPG

How I implemented a dynamic, speed-based turn order system for my tactics RPG using an accumulator pattern. Units with higher speed take more frequent turns, creating strategic depth in combat.

Building a Speed-Based Turn Order System for a Tactics RPG

When building my tactics RPG in Godot, one of the first major systems I needed to tackle was turn order. I wanted combat where speed actually matters—faster units should act more frequently, not just go earlier in a fixed rotation. This post walks through how I implemented a speed-based turn order system using an accumulator pattern.

Battle Demo

This is an early stage implementation that's working well for basic combat, but there's definitely room for improvement—particularly in areas like performance optimization, tiebreaker logic, and handling edge cases like speed buffs/debuffs.

The Core Concept

Instead of a simple round-robin system where everyone gets one turn per round, I use an accumulator-based approach:

  • Each unit has a "turn value" that increases by their Speed stat every tick
  • When a unit's turn value reaches 100 (the TURN_COST), they get to act
  • After acting, 100 is subtracted from their turn value
  • This continues in a simulation to predict the next 10 turns

This means a unit with Speed 12 will act roughly twice as often as a unit with Speed 6, creating meaningful strategic differences.

System Architecture

The turn order system involves several key components working together:

Key Components

TurnDetails (Battle/TurnDetails.cs) - The brain of the system:

  • Calculates turn order queue
  • Manages the current turn index
  • Displays turn order UI (showing next 10 turns)
  • Emits events when turns change

BattleUnit (Battle/BattleUnit.cs) - Individual unit state:

  • TurnValue: Persistent turn value (survives turn calculations)
  • TempTurnValue: Working value during simulation
  • _turnValueOnNextTurn: Snapshot for accurate UI prediction
  • Attributes.Speed: The stat that determines turn frequency

BattleManager (Battle/BattleManager.cs) - Battle state machine:

  • Receives NextTurnReady events from TurnDetails
  • Sets CurrentTurnUnit when a new turn begins
  • Manages overall battle flow through state transitions

BattleEventBus (Battle/BattleEventBus.cs) - Communication layer:

  • NextTurnReady - Fired when turn order determines next unit
  • UnitTurnFinished - Triggers turn advancement
  • UnitDied - Removes unit from turn order
  • BattleStateChanged - Notifies of state transitions

Turn Value Management

Each unit needs methods to manage their turn values:

// Increase turn value based on unit's speed public void AddSpeed() { workingTurnValue += speed; } // Subtract turn cost when unit takes a turn public void SpendTurnCost(int cost) { workingTurnValue -= cost; } // Save the current working value as the real turn value public void CommitTurnValue() { actualTurnValue = savedTurnValue; } // Reset working value to start fresh simulation public void ResetForSimulation() { workingTurnValue = actualTurnValue; }

These methods separate the "real" turn value from the "working" turn value used during simulation. This is helpful because I need to simulate future turns for the UI without affecting the actual battle state.

The Turn Order Algorithm

Battle Demo

The heart of the system is calculating which units act when. Here's the simplified logic:

private void CalculateTurnOrder() { // Keep turns that already happened PreserveCompletedTurns(); // Reset all units to their actual turn values foreach (var unit in allUnits) { unit.ResetForSimulation(); } // Simulate future turns until we have 10 turns to display while (turnQueue.Count < 10) { // Check if anyone is ready to act (turn value >= 100) var readyUnits = allUnits .Where(unit => unit.workingTurnValue >= 100) .ToList(); if (readyUnits.Count > 0) { // Pick the unit with highest turn value (handles ties) readyUnits.Sort((a, b) => b.workingTurnValue.CompareTo(a.workingTurnValue)); var nextUnit = readyUnits[0]; // Add to queue and subtract the turn cost turnQueue.Add(nextUnit); nextUnit.SpendTurnCost(100); } else { // Nobody ready yet, increase everyone's turn value foreach (var unit in allUnits) { unit.AddSpeed(); } } } UpdateTurnOrderDisplay(); }

How It Works

  1. Preserve completed turns: Keep turns that have already happened
  2. Reset working values: All units start with their actual turn values
  3. Simulation loop:
    • Check if any unit has turn value ≥ 100
    • If yes: Add the unit with the highest value to the queue, subtract 100
    • If no: Add each unit's Speed to their turn value
  4. Repeat until we've calculated 10 future turns

This simulation doesn't affect the actual battle state—it's just for predicting and displaying upcoming turns.

Turn Progression Flow

When a unit finishes their turn, here's what happens:

private void AdvanceToNextTurn() { // Recalculate turn order with new state CalculateTurnOrder(); // Move to next turn in queue currentTurnIndex++; // Save all units' turn values foreach (var unit in allUnits) { unit.CommitTurnValue(); } // Notify battle system that new turn is ready NotifyNextTurnReady(currentUnit); }

The battle manager listens for this notification:

OnNextTurnReady(unit => { currentTurnUnit = unit; battleState = BattleState.AwaitingAction; NotifyTurnStarted(); });

This sets the active unit and transitions the battle to await the player's action or trigger the AI.

Example: Speed Math in Action

Let's see how this plays out with three units:

  • Mage (Speed: 8)
  • Knight (Speed: 5)
  • Thief (Speed: 12)
IterationMageKnightThiefWho Acts?
Start000-
+Speed #1-8644096-
+Speed #97245108Thief acts!
After turn72458-
+Speed #4-51046532Mage acts!
After turn46532-
+Speed #7 more60100102Knight & Thief ready
Tiebreaker---Thief (102 > 100)

In this scenario, the Thief acts first, then the Mage, then the Thief again—all before the Knight gets a single turn. This creates interesting dynamics where speed differences really matter.

Design Decisions

Accumulator Pattern vs. Initiative Rolls

I chose an accumulator over rolling initiative for each round because:

  • Turn order is deterministic and predictable - players can plan ahead
  • It creates meaningful speed differences - a few points of speed compound over time
  • It's easier to visualize - the turn order UI shows exactly who goes when
  • It supports future features like speed buffs affecting turn timing

Multiple Turn Value Variables

Using three different turn value variables might seem excessive, but each serves a purpose:

  • actualTurnValue: The "source of truth" - the real turn progress
  • workingTurnValue: The "workspace" - used for simulating future turns
  • savedTurnValue: The "snapshot" - locked in when predictions are confirmed

This separation has been helpful for keeping the actual battle state separate from UI predictions.

Tiebreaker Logic

When multiple units reach 100 simultaneously, the unit with the higher accumulated value goes first:

// Sort by turn value, highest first readyUnits.Sort((a, b) => b.workingTurnValue.CompareTo(a.workingTurnValue)); var nextUnit = readyUnits[0];

So if Unit A has 105 and Unit B has 102 (both over 100), Unit A goes first. This feels natural—they "earned" their turn slightly earlier.

Dynamic Recalculation

The turn order is recalculated whenever the battle state changes:

  • Battle start
  • Every turn completion
  • Unit death
  • Battle units updated

This ensures the turn order always reflects the current state, which will be especially important when I add speed buffs/debuffs.

Integration with Battle State Machine

The turn system integrates with the battle manager's state machine through events:

private void SetupBattleEvents() { // When turn order determines next unit OnNextTurnReady(unit => { currentTurnUnit = unit; battleState = State.AwaitingAction; NotifyTurnStarted(); }); // State transitions during a turn OnUnitBeginMove(() => battleState = State.SelectingMovement); OnUnitFinishMove(() => battleState = State.SelectingAction); OnUnitAttack(() => battleState = State.ExecutingAttack); // More state transitions... }

Each turn progresses through states: AwaitingAction → SelectingMovement → SelectingAction → ExecutingAttack → TurnComplete, then loops back for the next unit.

Visual Feedback

The turn order UI shows up to 10 future turns with visual indicators:

  • Current unit highlighted with a margin offset
  • Past turns shown semi-transparent (0.5 opacity)
  • Color-coded by team (allies vs enemies)
  • Icons representing each unit

This gives players strategic information—they can see exactly when they'll act again and plan accordingly.

Areas for Improvement

While this implementation has been working for me, I'm sure there are improvements to be made:

Performance Optimization

  • The current system recalculates the entire turn queue every turn, which could be expensive with many units
  • Could potentially cache calculations or use incremental updates
  • The 50-iteration safety limit is arbitrary—could calculate exact number of iterations needed

Tiebreaker Refinement

  • Current tiebreaker (highest turn value) works, but might want more sophisticated logic
  • Could consider unit positioning, stat totals, or deterministic ordering by unit ID
  • Edge case: what if two units have exactly the same turn value and speed?

Speed Buff/Debuff Support

  • The foundation is there, but haven't fully implemented speed modification during battle
  • Need to decide: do speed changes affect current turn value or only future accumulation?
  • May need to recalculate turn order more intelligently when speed changes

Memory Efficiency

  • Currently storing entire turn order list in memory
  • For battles with many units, this could get large
  • Could potentially only calculate next N turns on-demand

Testing Edge Cases

  • What happens with very high speed disparities (Speed 1 vs Speed 20)?
  • How does it handle units joining mid-battle?
  • Does it behave correctly when all enemies are defeated partway through the turn queue?

UI Responsiveness

  • Turn order recalculation happens synchronously on the main thread
  • Could potentially move calculations to background thread for smoother performance
  • Animation timing could be improved for better visual feedback

Related Systems

This turn order system interacts with several other battle systems:

  • Event Bus (Battle/BattleEventBus.cs): All turn changes flow through events for loose coupling
  • Battle Manager (Battle/BattleManager.cs): Consumes turn events and manages overall battle flow
  • Character Attributes (Character/Resources/AttributesData.cs): Defines the Speed stat that drives turn frequency
  • Battle Units (Battle/BattleUnit.cs): Each unit tracks its own turn state and values

Conclusion

Building this speed-based turn order system has been a useful learning experience. The accumulator pattern creates meaningful speed differences in combat while remaining predictable for strategic planning. The separation between actual state and simulated state allows for accurate turn order previews without corrupting the battle state.

The system is functional for basic combat, but as I learn more about game development and optimization techniques, I'll continue refining it—especially around performance, edge cases, and handling dynamic speed changes.

If you're building something similar, I'd recommend starting simple with the core accumulator loop, then layering on features like UI prediction and tiebreakers once the fundamentals are solid.

Key Takeaways

  1. Accumulator patterns create dynamic, speed-based turn order
  2. Separate working state from actual state to enable safe simulation
  3. Event-driven architecture helps decouple turn system from battle management
  4. Visual feedback (showing 10 future turns) helps players plan strategically
  5. Start simple, add complexity incrementally as you learn what works

This post reflects my current implementation as of November 2025. I'm sure there are better ways to handle some of these systems, and I'm looking forward to learning more and improving them over time.