Vav Labs
Back to blog

Godot pathfinding / 2026-07-13 / 13 min read

Verified as of Godot 4.7 stable

Turn order and movement planning in Godot

Build deterministic tactical turns in Godot with explicit phases, movement intents, initiative tie-breakers, conflict resolution, and safe state commits.

Tactical turn-planning diagram with three initiative cards, deterministic command and unit-id tie-breakers, a plan-resolve-animate phase rail, and two units contesting one destination with a winner and rejected result.

The direct answer

How do I manage turn order and queued movement in a Godot tactical RPG? Put the order in an explicit game-state controller, not in _process() or scene-tree order. Snapshot the round queue, collect movement intents without mutating the board, sort every batch with complete tie-breakers, validate each intent when it resolves, commit the winner atomically, and advance the phase only when its visual playback has finished.

AStarGrid2D can propose a path. It does not know whose turn it is, whether two plans compete for one tile, how much AP the move spends, or whether the animation has finished. Those decisions belong to the tactical game-state layer.

You are probably here because

Those failures look unrelated when they live behind separate booleans, signals, and await calls. They are usually different leaks in the same turn contract.

Route the symptom to the relevant turn-system contract
Your symptomGo to
Input is accepted in the middle of the enemy turnGive the battle explicit phases
A unit acts twice, or a defeated unit still gets a turnHandle queue invalidation explicitly
Animation and state drift apart; skipping playback causes chaosKeep simulation and animation separate
Two planned units end on the same tileRevalidate when the plan resolves and use the reservation guide
Equal-initiative units change order "randomly"Use a total ordering rule

Choose the turn model before the queue

"Turn-based" describes several different contracts. Pick one before writing a turn manager.

The complete example supports plan-then-resolve with initiative. A reduced sequential loop appears below because most existing tactical prototypes do not need a batch resolver yet.

For named examples of how faction phases, visible enemy intents, and sequential activations change the surrounding movement contract, compare Fire Emblem, Into the Breach, and XCOM movement in Godot.

Choose the coordinator that matches the game's turn contract
Your gameMovement contract
Sequential initiative: one unit acts at a timeBuild a round queue, expose one active unit, then validate and commit its command before advancing.
Faction phase: one side may use its units in any orderTrack the active faction and a set of units that may still act; initiative within that phase is optional.
Plan then resolve: several units submit orders firstStore immutable intents, sort the resolution batch, then validate and commit in that deterministic order.
Timeline or ATB: actors become ready at different ticksOrder by the next action tick and keep speed or timeline updates as a separate scheduling system.

Scene-tree order is not initiative

Godot documents that processing usually follows tree order. A lower process_priority runs first, and equal priorities fall back to the position in the scene tree.

Callback order is a scheduling detail. Initiative is a game rule. Reparenting a unit, instancing a scene in a different order, or changing a priority for rendering reasons should not change who acts first in combat.

The turn director should own plain actor identifiers and explicit ordering data. Units can still be Nodes; their position in the tree is not the initiative list.

Give the battle explicit phases

A minimal phase machine keeps input, simulation, and animation from racing each other.

Every core snippet below is a fragment of the same turn_order_movement_planner.gd. The complete assembled file, including the small playback adapter, is in the verified download.

Only PLANNING accepts new movement intents. Only RESOLVING may mutate board state. ANIMATING plays results that have already been decided. ROUND_END applies end-of-round effects and prepares the next snapshot.

This is more useful than a loose collection of booleans such as is_moving, waiting_for_ai, and turn_over. One enum gives every command one legal entry point.

enum TurnPhase {
    ROUND_START,
    PLANNING,
    RESOLVING,
    ANIMATING,
    ROUND_END,
}

var _bounds := Rect2i(0, 0, 8, 6)
var _reservation_table: Variant
var _actors: Dictionary = {}
var _plans: Array[Dictionary] = []
var _acted_ids: Dictionary = {}
var _round_id := 0
var _phase := TurnPhase.ROUND_START
var _active_unit_id := 0

Retrofit the system you already have

You do not need to replace the whole battle loop at once. Move it across the boundary in three shippable steps:

Each step improves one failure class without requiring the later steps. The phase enum is useful even if the game remains strictly sequential.

  1. Make order data explicit. Keep the current buttons and movement code, but replace scene-tree or callback order with an initiative row and stable unit id.
  2. Wrap existing gates in one phase enum. Map is_moving, turn_over, and waiting_for_ai onto legal phase transitions. Delete each boolean only after every caller uses the phase.
  3. Turn direct movement calls into intents. Let the old resolver consume the intent first. Add batch ordering and reservation conflicts only when the game actually allows several pending plans.

The reduced loop for sequential turns

If exactly one unit is active, resolve its intent immediately. There is no batch sort and no contested destination between simultaneous plans; the active-unit check is the ownership rule.

After the accepted move finishes playback, advance _active_unit_id to the next valid queue entry and reopen PLANNING. If the move is rejected, keep the same unit active so the player or AI can choose again.

func submit_sequential_move(intent: Dictionary) -> Dictionary:
    if _phase != TurnPhase.PLANNING:
        return _failure(NOT_PLANNING, int(intent.get("unit_id", 0)))
    var unit_id := int(intent.get("unit_id", 0))
    if unit_id != _active_unit_id:
        return _failure(NOT_ACTIVE_UNIT, unit_id)
    _phase = TurnPhase.RESOLVING
    var result := _resolve_one(intent)
    _phase = TurnPhase.ANIMATING if bool(result.get("ok", false)) else TurnPhase.PLANNING
    return result

Snapshot initiative for the round

Build the round order from current actor data, then decide when changes become visible. A simple rule is: speed and status changes affect the next round unless an ability explicitly requeues an actor.

The snapshot prevents a mid-round buff from silently moving a unit that has already acted. Games that deliberately support initiative manipulation can do so through named queue operations instead of a continuously resorted array.

func begin_round() -> Dictionary:
    if _phase != TurnPhase.ROUND_START and _phase != TurnPhase.ROUND_END:
        return _failure(INVALID_PHASE)
    _round_id += 1
    _plans.clear()
    _acted_ids.clear()
    for unit_id: int in _actors.keys():
        var actor: Dictionary = _actors[unit_id]
        actor["round_initiative"] = int(actor["initiative"])
    _phase = TurnPhase.PLANNING
    return _success(RESULT_OK, 0, {"round_id": _round_id})

Store a movement intent, not a bound side effect

A plan should describe the requested action. It should not already move the unit, spend AP, or claim cells.

Data-shaped intents are inspectable in a debugger, easy to include in a receipt, and suitable for save or replay work later. A queue of bound Callable values can execute behavior, but it hides the data needed for validation and useful failure reports.

Each plan owns a copied path so later UI edits cannot mutate the queued command by aliasing the same Array.

func make_move_intent(
    unit_id: int,
    path: Array[Vector2i],
    command_sequence: int
) -> Dictionary:
    var initiative := 0
    if _actors.has(unit_id):
        initiative = int(_actors[unit_id].get("round_initiative", 0))
    return {
        "round_id": _round_id,
        "unit_id": unit_id,
        "path": path.duplicate(),
        "initiative": initiative,
        "command_sequence": command_sequence,
    }

Planning must not mutate the board

Planning may calculate a range, ask AStarGrid2D for a candidate path, quote AP, and show a reservation warning. It must not change occupancy, AP, or unit position.

If the game lets a player revise an order, replace that unit's plan explicitly. Do not accumulate two active plans and hope the resolver guesses which one is newer.

func submit_plan(intent: Dictionary) -> Dictionary:
    if _phase != TurnPhase.PLANNING:
        return _failure(NOT_PLANNING, int(intent.get("unit_id", 0)))
    if int(intent.get("round_id", -1)) != _round_id:
        return _failure(WRONG_ROUND, int(intent.get("unit_id", 0)))

    var unit_id := int(intent.get("unit_id", 0))
    if not _actors.has(unit_id):
        return _failure(UNIT_MISSING, unit_id)
    for existing in _plans:
        if int(existing.get("unit_id", 0)) == unit_id:
            return _failure(UNIT_ALREADY_PLANNED, unit_id)

    _plans.append(intent.duplicate(true))
    return _success(RESULT_OK, unit_id, {"plan_count": _plans.size()})

Use a total ordering rule

Godot's Array.sort_custom() is not stable. If the comparator says two intents are equal, their order may change. Initiative therefore needs secondary and final keys.

The exact policy is a game-design choice. The requirement is that the comparison ends in a unique, stable key. Never use scene-tree position, dictionary iteration order, network arrival timing, or a random comparator as an accidental final tie-breaker.

func intent_before(left: Dictionary, right: Dictionary) -> bool:
    if int(left.get("initiative", 0)) != int(right.get("initiative", 0)):
        return int(left.get("initiative", 0)) > int(right.get("initiative", 0))
    if int(left.get("command_sequence", 0)) != int(right.get("command_sequence", 0)):
        return int(left.get("command_sequence", 0)) < int(right.get("command_sequence", 0))
    return int(left.get("unit_id", 0)) < int(right.get("unit_id", 0))

Revalidate when the plan resolves

A plan is a proposal, not a pre-confirmed move. Earlier winners may occupy its target or change the board before it resolves.

_resolve_one() should skip missing or defeated units, recalculate the final quote against current state, ask the reservation layer for the destination, and commit AP, occupancy, and position atomically. A loser returns a named reason without mutation.

The action-points and terrain-cost transaction owns the quote and confirm details. The turn reservation guide owns contested destination cells.

func resolve_plans() -> Array[Dictionary]:
    if _phase != TurnPhase.PLANNING:
        return []
    _phase = TurnPhase.RESOLVING
    _plans.sort_custom(intent_before)

    var results: Array[Dictionary] = []
    for intent in _plans:
        results.append(_resolve_one(intent))
    _phase = TurnPhase.ANIMATING
    return results

Keep simulation and animation separate

The resolver decides the authoritative result first. Animation presents that result. The next planning phase should not open until the current result batch has finished playing.

The await calls belong in the playback bridge, after resolution has produced authoritative results:

Pass Callable(animation_runner, "play_move") here. That callback is where an existing await tween.finished or await unit.move_done chain fits. The resolver has already committed the cell; the awaited code presents that result and gates the next phase. Skipping or accelerating playback therefore leaves the same authoritative state.

Call play_results() with await from the battle scene because the function is a coroutine, and provide an asynchronous play_move callback. Calling it without await returns coroutine state instead of the final result Dictionary.

For multi-cell bodies, the winning intent still needs the footprint and transition rules from multi-tile movement in Godot.

func play_results(results: Array[Dictionary], play_move: Callable) -> Dictionary:
    if _phase != TurnPhase.ANIMATING:
        return _failure(NOT_ANIMATING)
    for result in results:
        if not bool(result.get("ok", false)):
            continue
        await play_move.call(result)
    return acknowledge_animation_complete()


func acknowledge_animation_complete() -> Dictionary:
    if _phase != TurnPhase.ANIMATING:
        return _failure(NOT_ANIMATING)
    _plans.clear()
    _phase = TurnPhase.ROUND_END
    return _success(RESULT_OK)

Handle queue invalidation explicitly

A unit can be defeated, removed, stunned, or moved before its planned action. The resolver should return a named result and continue; it should not erase items from the array while iterating.

Named results make the turn log useful to UI, tests, replays, and bug reports. "The command did nothing" is not a result contract.

  • UNIT_MISSING
  • UNIT_DEFEATED
  • UNIT_ALREADY_ACTED
  • PATH_INVALID
  • TARGET_RESERVED
  • INSUFFICIENT_AP
  • COMMITTED

Where pathfinding and AI fit

The movement planner consumes paths; it does not replace pathfinding. Use the AStarGrid2D complete reference for the grid query itself and the tactical movement-range guide for the set of legal endpoints.

AI also remains upstream. An influence map can score candidate destinations, but the selected result becomes the same movement intent used by a player. Player input and AI should produce commands through one validation path.

Common failures

Using _process() order as initiative. Engine callback scheduling is not the combat rule.

Sorting only by initiative. sort_custom() is not stable; equal values need explicit tie-breakers.

Mutating during planning. A preview that spends AP or claims occupancy makes cancellation and batch resolution unreliable.

Treating a plan as guaranteed. Revalidate it when it resolves against the current board.

Advancing before animation completes. The UI begins accepting the next turn while the previous result is still playing.

Waiting for animation to change state. Skipped or interrupted visuals then corrupt the simulation.

Deleting queue entries while iterating. Return a skipped result and finish the stable batch instead.

Making the turn director global by default. Keep it battle-scoped unless state truly has to survive scene changes.

Run the turn-planning proof yourself

The Godot 4.7 source package contains the assembled turn planner, the composed reservation helper, a runnable contested-move scene, and its headless verifier.

The machine-readable receipt records 15/15 passed checks, including isolated plan copies, registration-order independence, one deterministic contested-destination winner, playback gating, scene smoke, and a clean extracted-package rerun.

The ZIP is 13,587 bytes with SHA-256 75bfefc611988aa47376e674ba5b1e40cb3365864c27594edca30b94ab364f23. Measurement-gap: this is a correctness receipt for ordering and discrete state transitions, not a runtime, throughput, memory, path-optimality, or released-product benchmark.

Frequently asked questions

How do I manage turn order and queued movement in a Godot tactical RPG?

Use a battle-scoped turn director with explicit phases. Collect data-shaped movement intents without mutation, sort them with initiative plus complete tie-breakers, validate and commit each result through the reservation and AP layers, then advance only after visual playback completes.

Can I use process_priority for initiative in Godot?

No. process_priority controls engine callback order. Initiative is gameplay state and should remain unchanged when a node is reparented or its processing setup changes.

Should initiative be recalculated every turn or once per round?

Either can be a valid game rule, but write it down. A predictable baseline is to snapshot initiative at round start and apply ordinary speed changes next round; abilities that manipulate the current queue should use explicit requeue actions.

Should the turn director be an Autoload?

Usually not. A node owned by the battle scene keeps its state and lifetime clear. Use an Autoload only when the same turn state intentionally survives scene changes or coordinates several loaded scenes.

What order do queued moves resolve in?

Sort them with a complete deterministic rule such as initiative, command sequence, then stable unit id. This article owns that ordering. The destination claim itself belongs to the reservation table, which accepts the first valid winner and rejects the loser without mutation.

Should game state update before or after the movement animation?

Commit authoritative state before playback, then keep the director in an ANIMATING phase until the visual sequence acknowledges completion. Animation presents the result; it does not choose it.