Building a Speed-Based Turn Order System for a Tactics RPG
One of the first systems I had to figure out for my tactics RPG was turn order. I wanted combat where speed actually mattered — not just "who goes first this round", but "how often does this unit get to act compared to the others". The result is a speed-based accumulator. Faster units act more often; slower units have to make their fewer turns count.

This is an early stage implementation that's working well for basic combat, but there's room for improvement — particularly in tiebreaker logic, handling speed buffs and debuffs, and reducing the cost of recalculating the queue on every turn.
The Core Concept
Instead of round-robin, each unit has a turn value that ticks up by its Speed stat. When the value crosses a threshold (I use TURN_COST = 100), that unit gets a slot in the queue and pays 100 back. Everyone keeps ticking. A unit with Speed 12 will fill up roughly twice as fast as a unit with Speed 6, so it gets twice as many turns over time.
That's the whole idea. The rest of the system exists so I can project the next ten turns for the UI, without corrupting the live state, and so the per-unit bookkeeping isn't tangled up with the global coordinator.
Two Pieces, Working Together
I split this across two classes:
BattleUnitTurnMeter— a component attached to each unit. Owns that unit's turn value and exposes a small set of operations (increase, decrease, save, reset).TurnDetails— a global coordinator. Holds the upcoming queue, runs the projection loop, drives the turn order UI, and tells each meter what to do.
I'll start with the meter, since the coordinator is mostly orchestration on top of it.
The Per-Unit Turn Meter
public partial class BattleUnitTurnMeter : Node2D { [Export] private BattleUnit _battleUnit; private int _turnValueOnNextTurn; private int _turnValue; public int TempTurnValue { get; private set; } public void IncreaseTurnValue() { TempTurnValue += _battleUnit.Attributes.Speed; } public void DecreaseTurnValue(int value) { TempTurnValue -= value; } public void SaveTurnValue() { _turnValue = _turnValueOnNextTurn; } public void ResetTurnValue() { TempTurnValue = _turnValue; } public void SaveTurnValueForNextTurn() { _turnValueOnNextTurn = TempTurnValue; } }
The interesting thing here is the three fields:
TempTurnValueis the live accumulator. The projection loop ticks this up and pays out of it._turnValueis the committed baseline — the value the meter rewinds to before each projection pass._turnValueOnNextTurnis a snapshot taken during projection, capturing what the meter will look like at the moment the next real turn happens.
That separation is what lets me simulate the next ten turns for the UI without corrupting the actual battle state. Every projection pass calls ResetTurnValue() to rewind back to the baseline. When a turn actually fires for real, SaveTurnValue() promotes the next-turn snapshot to the new baseline.
Calculating the Next 10 Turns
The coordinator's main job is keeping the queue full. Simplified:
private const int TURN_COST = 100; private const int MAX_TURN_ICON_COUNT = 10; private const int MAX_LOOP = 50; public void RefreshNextTurns(bool includeCurrentTurnIndex = false) { var loop = 0; var nextUnitAdded = false; // Keep history up to and including the current turn _turnOrder = _turnOrder[..(_turnIndex + 1)]; // Rewind every meter to its committed baseline BattleUnits.ForEach(u => u.TurnMeter.ResetTurnValue()); while (_turnOrder.Count - _turnIndex < MAX_TURN_ICON_COUNT && loop < MAX_LOOP) { loop++; var unitsReady = BattleUnits .Where(u => u.TurnMeter.TempTurnValue >= TURN_COST) .ToList(); if (unitsReady.Count > 0) { // Pick the unit with the highest accumulated turn value unitsReady.Sort((a, b) => b.TurnMeter.TempTurnValue.CompareTo(a.TurnMeter.TempTurnValue)); var next = unitsReady[0]; _turnOrder.Add(next); next.TurnMeter.DecreaseTurnValue(TURN_COST); // The first time we pick a unit this pass, snapshot every meter — // this is what "the next turn boundary" looks like for the live state. if (includeCurrentTurnIndex && !nextUnitAdded) { nextUnitAdded = true; BattleUnits.ForEach(u => u.TurnMeter.SaveTurnValueForNextTurn()); } } else { // Nobody ready — every unit accumulates more turn value BattleUnits.ForEach(u => u.TurnMeter.IncreaseTurnValue()); } } }

The shape:
- Preserve history. Truncate the queue to "everything that has already happened, including the current turn". Past entries stay so the UI can dim them.
- Rewind. Reset every meter to its baseline. This is what makes the projection idempotent — running it twice in a row should produce the same result.
- Project. In a loop: if anyone has crossed
TURN_COST, pick the highest, give them a queue slot, subtract 100. Otherwise everyone ticks up by theirSpeed. Repeat until the queue has ten upcoming slots.
The MAX_LOOP = 50 safety guard is in there for the bug I haven't actually hit but could, where a misconfigured unit has Speed = 0 and nobody ever crosses the threshold. Without the cap, the loop would hang.
Speed Math in Action
Here's how three units shake out:
- Mage — Speed 8
- Knight — Speed 5
- Thief — Speed 12
| Iteration | Mage | Knight | Thief | Who acts? |
|---|---|---|---|---|
| Start | 0 | 0 | 0 | — |
| Tick ×8 | 64 | 40 | 96 | — |
| Tick ×9 | 72 | 45 | 108 | Thief |
| After turn | 72 | 45 | 8 | — |
| Tick ×4 | 104 | 65 | 56 | Mage |
| After turn | 4 | 65 | 56 | — |
| Tick ×7 | 60 | 100 | 140 | Thief (140 > 100) |
Even though both the Knight and Thief cross 100 on the same iteration, the Thief had a higher accumulated value at that moment, so he goes first. Ties are handled by the highest-value-wins sort — no special tiebreaker needed.
Over a long fight, the Knight is getting noticeably fewer turns than the others. That's the strategic shape I wanted: equipping a slower weapon is a real trade-off, not just a stat tweak.
Handing Off the Turn
When a unit finishes acting, this is what happens (simplified):
public void NextTurn() { RefreshNextTurns(includeCurrentTurnIndex: true); _turnIndex++; BattleUnits.ForEach(u => u.TurnMeter.SaveTurnValue()); RefreshUI(); // CallDeferred matters here: if the next turn is the SAME unit // (which happens often with big speed disparities), calling // EmitActionSelection() synchronously re-enters the action flow // before the previous signal handler has unwound — double-triggering // the player menu. CurrentUnit?.CallDeferred(nameof(BattleUnit.EmitActionSelection)); }
A few details worth pulling out:
Waitingfires first. The unit's components decide it's done — move + attack + end turn — and emitWaiting. The coordinator subscribes to that signal and runsNextTurn().- Project, advance, commit. Refresh the projection so the queue is fresh, increment the index, then call
SaveTurnValue()on every meter — that promotes each unit's next-turn snapshot to its new baseline. EmitActionSelectionis deferred. Godot'sCallDeferredschedules the call for after the current frame's signal processing finishes. I learned to need this the hard way — synchronous re-entry intoActionSelectionfrom inside the previous turn'sWaitinghandler caused the player menu to open twice when the next unit was the same as the one that just acted.
That last one took me longer to find than I'd like to admit. The bug surfaced as "the player menu sometimes flashes" and it was completely consistent on certain speed configurations.
What's Still Rough
Things I know would benefit from another pass:
- Tiebreaker robustness. Highest accumulated value works fine for now, but when two units roll an identical value (same
Speed, same starting state), the order is whateverSortdecides — which is fine but not deterministic across runs. A stable tiebreaker (level, then unit id) would make replays / save games more predictable. - Speed buffs and debuffs. The foundation is there —
Speedis just read offAttributes— but I haven't decided whether a buff should affect the current accumulator or only future ticks. There's a real game-feel choice in there. - Recalc cost. Every turn I rewind all meters and re-project the next ten. For a 4-on-4 battle that's nothing, but if I ever push toward bigger encounters I'll want to project incrementally instead of from scratch.
Wrap-up
The thing I keep coming back to is how much of this system's complexity is in service of honest UI prediction. The accumulator itself is a one-line trick — add Speed, subtract 100. The reason there are three turn-value fields, a projection loop, and a CallDeferred is that I want the player to see the next ten turns and trust them. Anything that corrupts the live state during projection breaks that trust the moment the actual turn fires and doesn't match what was on screen.
As I learn more, I'll probably find tighter ways to express this. For now, treating turn metering as a small per-unit component with a separate coordinator has been working well — adding a new unit to a battle is just adding the component, and the coordinator picks it up automatically.

