Vav Labs
Back to blog

Godot pathfinding / 2026-07-11 / 15 min read

Verified as of Godot 4.7 stable; standalone source, ten-check verifier, extracted-package rerun, and Dijkstra's Ghost stacking check completed on 2026-07-11

How to Prevent Two Units From Moving to the Same Tile in Godot

Prevent two Godot units from claiming one tile with a small reservation table: pure previews, atomic confirms, deterministic conflicts, and reversible undo.

Two grid units approach the same junction: one claims the reserved tile while the other request is rejected.

The direct answer

To stop two turn-based units from moving to the same tile in Godot, keep a reservation table outside the pathfinder. Store which unit owns each occupied or claimed cell. A preview checks the table without changing it. Confirm checks the same rule again, releases the unit's old cell, and claims the destination as one state change. Undo restores the reservation state from before that confirm.

AStarGrid2D can tell both units that the tile is reachable because each path query sees a valid route. It doesn't decide which move commits first. That's a game-state problem.

  1. preview_move() is read-only.
  2. confirm_move() validates again and changes occupancy atomically.
  3. A contested target returns the current owner.
  4. undo_last() restores the exact previous reservation state.
  5. Simultaneous requests use one stable conflict rule.
  6. A unit that leaves the board releases every cell it owns.

Choose the reservation model your game needs

The right reservation model depends on when units commit and whether several routes execute at once. Use the smallest model that matches the movement contract:

Your gameUse
Sequential turn-based — one unit moves at a timeThe destination reservation table in this article as written.
Queued or simultaneous turn resolutionAdd the batch resolver with stable ordering shown below.
Real-time grid movementUse ephemeral per-tick reservations, as in Dijkstra's Ghost; use a space-time table when multi-step routes execute concurrently.

The same rule is already live in Dijkstra's Ghost

Dijkstra's Ghost isn't turn-based, but its four hunters hit the same smaller problem every update: two actors can prefer the same next cell even when both are following a valid Dijkstra field.

The live game starts each update with an empty reserved_targets set and visits hunters in a stable order. Each hunter sees the other occupied cells plus targets already reserved earlier in that update. Once a hunter starts moving, its target is added before the next hunter chooses. The later hunter evaluates another legal neighbor instead of stacking into the same cell.

The public verification receipt includes the named occupied_cells_block_chaser_stacking assertion under verify_pre_public_gameplay. The reservation set is rebuilt on the next update because this is real-time next-step selection, not persistent turn ownership.

Download the verified Godot 4.7 proof

The live game proves the behavior in motion. The separate Godot source project proves the generic turn-based contract below without asking you to infer it from a game build.

Its machine-readable verification receipt records ten passing checks on Godot 4.7 stable: pure preview, confirm and conflict ownership, exact undo, empty-history no-op, deterministic arbitration, 2x2 footprints, overlapping footprint movement, lifecycle release, stable serialization, and runnable-scene smoke.

This is a correctness receipt, not a speed claim. The JSON carries the published ZIP's byte count and SHA-256, and the bundled verifier was rerun after extracting that exact ZIP into a clean directory.

Measurement-gap note: this article makes no throughput or latency claim, so it doesn't publish a performance benchmark. The evidence is the named correctness suite and the reproducible source package above.

ArtifactVerified state
Source ZIP8,708 bytes · SHA-256 recorded in the receipt
Headless checks10 passed · 0 failed · Godot 4.7 stable
Package rerunFinal ZIP extracted; bundled check and verifier passed
Live-game checkHunter occupied/reserved target stacking assertion passed

Reachability and ownership are different questions

The movement-range layer answers whether a unit can afford to reach a cell. The pathfinder answers how it gets there. Neither one owns the cell after arrival.

Imagine two units standing on opposite sides of the same empty tile. Both have enough action points. Both path previews are legal. If the UI previews Unit 10, then previews Unit 20 before either move commits, both previews can honestly show the same destination.

That isn't a pathfinding bug. The bug appears only if both moves commit against stale occupancy. Hover can be cheap and disposable; confirm is where game state changes.

StateQuestionMay mutate reservations?
RangeCan this unit afford the destination?No
PreviewIs the move legal against the current board?No
ConfirmCan I claim the destination now?Yes, after revalidation

A small reservation table in GDScript

For a turn-based game that moves one unit at a time, the table can be a simple cell -> agent_id map. This baseline uses Vector2i keys because the code is easy to read. A fixed rectangular board can use a PackedInt32Array later.

The snippets show the readable 1x1 core. The downloadable project is the verified superset with bounds, full-footprint reservations, deterministic request resolution, release, and sorted state serialization.

Every snippet below is a fragment of the same reservation-table implementation, simplified to the 1x1 path where noted. The complete turn_reservation_table.gd file is in the source download.

class_name ReservationTable
extends RefCounted

const EMPTY := 0

var _owner_by_cell: Dictionary[Vector2i, int] = {}
var _undo_stack: Array[Dictionary] = []

func reserved_by(cell: Vector2i) -> int:
    return int(_owner_by_cell.get(cell, EMPTY))

func reserve_start(agent_id: int, cell: Vector2i) -> Dictionary:
    if agent_id <= 0:
        return _failure("INVALID_AGENT", cell)

    var owner := reserved_by(cell)
    if owner != EMPTY and owner != agent_id:
        return _failure("TARGET_OCCUPIED", cell, owner)

    _owner_by_cell[cell] = agent_id
    return _success(cell)

Where the table lives

Give the board or level controller one reservation table for the cells it owns. The selected unit asks that shared table for preview_move(); the turn-command handler calls confirm_move() only after input is committed, then updates AP, logical position, and animation from the successful result.

Use an autoload only when reservation state genuinely survives scene changes or several loaded boards share one authority. Don't put a separate table on every unit: two private tables can both declare the same destination free.

Return context, not one boolean

Use a stable positive id for each unit. Don't use a node's display name as the key, and don't rely on container order to decide winners. The owner id is state. The label is presentation.

In a larger project, replace string codes with an enum or integer constants. The important part is that failure returns context. A bare false makes the caller guess whether the target was occupied, out of bounds, or simply unaffordable.

func _success(cell: Vector2i) -> Dictionary:
    return {
        "ok": true,
        "code": "OK",
        "at_cell": cell,
        "reserved_by": reserved_by(cell),
    }

func _failure(code: String, cell: Vector2i, owner := EMPTY) -> Dictionary:
    return {
        "ok": false,
        "code": code,
        "at_cell": cell,
        "reserved_by": owner,
    }

Preview must not change state

Preview is a question. It shouldn't reserve the destination, release the start, spend AP, or push an undo record.

Hover may run dozens of times as the pointer crosses the board. A cancelled preview shouldn't leave a ghost reservation behind, and opening a tooltip shouldn't become a gameplay action. Hash or copy the table before preview and compare it after; the state must be identical whether preview succeeds or fails.

func preview_move(
    agent_id: int,
    from_cell: Vector2i,
    to_cell: Vector2i
) -> Dictionary:
    if agent_id <= 0:
        return _failure("INVALID_AGENT", from_cell)

    if reserved_by(from_cell) != agent_id:
        return _failure("INVALID_START", from_cell, reserved_by(from_cell))

    var target_owner := reserved_by(to_cell)
    if target_owner != EMPTY and target_owner != agent_id:
        return _failure("TARGET_OCCUPIED", to_cell, target_owner)

    return {
        "ok": true,
        "code": "OK",
        "from_cell": from_cell,
        "to_cell": to_cell,
        "reserved_by": target_owner,
    }

Confirm is a transaction

Confirm can't trust an earlier preview. Another action may have changed the board between hover and click, so confirm validates again. Then it snapshots the cells it will touch, releases the old cell, and claims the new one.

Keep the mutation together. Don't release the old cell in one signal handler and claim the destination in another; that creates an observable state where the unit owns neither cell.

Move the logical state first, then animate the sprite along the confirmed path. If collision, replay, or AI reads the tweening sprite as authoritative state, the board will disagree with itself during every move.

If terrain cost also spends a shared turn budget, keep the reservation and AP write in the same commit boundary; the AP and terrain-cost guide covers the quote and refund side.

func confirm_move(
    agent_id: int,
    from_cell: Vector2i,
    to_cell: Vector2i
) -> Dictionary:
    var validation := preview_move(agent_id, from_cell, to_cell)
    if not bool(validation["ok"]):
        return validation

    var before: Dictionary[Vector2i, int] = {}
    before[from_cell] = reserved_by(from_cell)
    before[to_cell] = reserved_by(to_cell)

    _undo_stack.append({
        "agent_id": agent_id,
        "from_cell": from_cell,
        "to_cell": to_cell,
        "before": before,
    })

    if from_cell != to_cell:
        _owner_by_cell.erase(from_cell)
    _owner_by_cell[to_cell] = agent_id

    return {
        "ok": true,
        "code": "OK",
        "from_cell": from_cell,
        "to_cell": to_cell,
        "reserved_by": agent_id,
    }

Undo restores state; it doesn't invent the reverse move

Calling confirm_move(agent_id, to_cell, from_cell) is tempting, but it isn't undo. The old cell may now have another owner, costs may have changed, and a reverse confirm adds another history record.

Returning OK_EMPTY for an empty history is deliberate: undo with empty history is a successful no-op. The movement system should restore AP, logical unit position, and turn flags beside the reservation snapshot. One undo command should own the whole transaction.

func undo_last() -> Dictionary:
    if _undo_stack.is_empty():
        return {"ok": true, "code": "OK_EMPTY"}

    var record: Dictionary = _undo_stack.pop_back()
    var before: Dictionary = record["before"]

    for cell: Vector2i in before:
        var old_owner := int(before[cell])
        if old_owner == EMPTY:
            _owner_by_cell.erase(cell)
        else:
            _owner_by_cell[cell] = old_owner

    return {
        "ok": true,
        "code": "OK",
        "agent_id": int(record["agent_id"]),
        "from_cell": record["from_cell"],
        "to_cell": record["to_cell"],
    }

Release cells when a unit leaves the board

Death, despawn, and capture end ownership too. A removed unit that still owns its cells leaves an invisible wall: previews report TARGET_OCCUPIED for a tile nobody visibly holds.

Godot explicitly allows erasing dictionary entries while iterating over the keys() array. This scan is fine for a small tactical board; on a large board with frequent removals, keep a reverse agent_id -> cells index.

If your game supports undoing a whole turn, record the release inside the same transaction as the death, not as a loose edit the undo stack never saw.

func release_agent(agent_id: int) -> Dictionary:
    if agent_id <= 0:
        return _failure("INVALID_AGENT", Vector2i(-1, -1))

    var released_cells := 0
    for cell: Vector2i in _owner_by_cell.keys():
        if reserved_by(cell) == agent_id:
            _owner_by_cell.erase(cell)
            released_cells += 1

    return {
        "ok": true,
        "code": "OK" if released_cells > 0 else "OK_EMPTY",
        "agent_id": agent_id,
        "released_cells": released_cells,
    }

Resolve contested moves with a stable rule

Sequential turns have an easy answer: the first confirmed move owns the target, and later confirms fail. Queued or simultaneous turns need an explicit winner.

Useful rules include initiative, command sequence, then stable agent id as the final tie-breaker. Sort requests before applying them. Don't let scene-tree order, dictionary order, or network arrival timing choose by accident.

A batch resolver can mark the first valid request for each destination as the winner and return a result for every loser. Apply winning confirms only after the batch is resolved. This still describes destination claims, not full simultaneous path execution.

For the phase machine, immutable intent queue, complete initiative ordering, and awaited playback around this claim, use the turn order and movement planning guide.

func resolve_single_cell_requests(
    agent_ids: PackedInt32Array,
    target_cells: Array[Vector2i]
) -> PackedByteArray:
    var winners := PackedByteArray()
    winners.resize(agent_ids.size())
    winners.fill(0)
    if target_cells.size() != agent_ids.size():
        return winners

    for index in range(agent_ids.size()):
        var agent_id := agent_ids[index]
        var target := target_cells[index]
        if agent_id <= 0 or not _bounds.has_point(target):
            continue
        var owner := reserved_by(target)
        if owner != EMPTY and owner != agent_id:
            continue

        var lower_request_exists := false
        for other_index in range(agent_ids.size()):
            if other_index == index or target_cells[other_index] != target:
                continue
            var other_id := agent_ids[other_index]
            if other_id <= 0:
                continue
            if other_id < agent_id or (other_id == agent_id and other_index < index):
                lower_request_exists = true
                break
        if not lower_request_exists:
            winners[index] = 1

    return winners

Large units reserve the full footprint

A 2x2 unit doesn't occupy one anchor cell. Reserving only the anchor lets another unit claim one of the other three covered cells.

Preview checks every destination footprint cell. Cells already owned by the moving unit are allowed because the old and new footprints may overlap. Confirm snapshots the union of old and new cells, releases cells no longer covered, and claims every new cell. Undo restores that union.

This is where clearance and reservations meet, but they answer different questions. Clearance asks whether the body fits. Reservation asks whether another unit already owns any part of that footprint.

The separate multi-tile movement guide owns the execution layer: anchor conventions, visual placement, cardinal and diagonal transition checks, and rotation pivots.

func footprint_cells(anchor: Vector2i, size: int) -> Array[Vector2i]:
    var cells: Array[Vector2i] = []
    for y in range(size):
        for x in range(size):
            cells.append(anchor + Vector2i(x, y))
    return cells

Reservations can inform pathfinding without becoming terrain

For a simple tactics game, occupied cells can be copied into AStarGrid2D as solid points before a path query. That works when every occupied tile is a hard blocker.

Many games need a richer policy: allies may be passable but not valid destinations, a unit's own starting cell must remain usable, and a queued reservation may block stopping without blocking route preview. Keep the reservation table as the authority and adapt it into the current query instead of rewriting authored terrain.

If a confirmed reservation changes routes for other units, invalidate or refresh their cached previews. Use the same bounded-update discipline as dynamic blockers; don't rebuild the whole authored map because one unit ended a turn.

When a cell-only table isn't enough

The table in this article is enough when movement is sequential or treated as an atomic board update. It prevents two units from ending on the same tile.

It doesn't prevent two simultaneous routes from entering the same cell on tick 3, swapping across the same edge in opposite directions, or letting a faster unit catch a slower one partway through a route.

For those cases, reserve (cell, time) rather than only cell. You may also need edge reservations such as (from_cell, to_cell, time) to reject head-on swaps. That's the space-time model used in cooperative pathfinding.

Don't build that machinery for a game where units move one at a time. But don't claim a destination dictionary solves simultaneous multi-agent routing either.

What to test before trusting it

Use a tiny board with stable agent ids and assert state before and after every operation. The downloadable receipt runs these contracts rather than treating the code listing as proof by itself.

  1. Preview succeeds without changing any reservation.
  2. Confirm releases the old cell and claims the destination.
  3. Undo restores the exact pre-confirm ownership state.
  4. A second unit receives TARGET_OCCUPIED and the current owner's id.
  5. Failed preview and confirm leave the table unchanged.
  6. Two requests for one target choose the same winner every run.
  7. A 2x2 reservation claims four cells and rejects one-cell overlap.
  8. An overlapping 2x2 move releases only cells no longer covered.
  9. Removing a unit releases every cell it owned.
  10. Save, load, or replay serialization is independent of insertion order.

Common mistakes

Reserving during hover. Preview becomes gameplay state, and cancelled input leaves ghost ownership behind.

Trusting the old preview at confirm. The board may have changed. Validate the destination again.

Moving the sprite before logical state. Other systems see the old cell while the unit is visibly leaving it.

Using one boolean occupied flag. You can reject the move, but you can't name the owner or resolve a conflict consistently.

Undoing with a reverse move. That's a new transaction, not restoration.

Reserving only a large unit's anchor. The remaining footprint becomes available while the unit visibly covers it.

Not releasing on death. A destroyed unit becomes an invisible wall.

Calling a destination map cooperative pathfinding. It prevents shared final cells; it doesn't reserve time or edges along simultaneous routes.

Frequently asked questions

How do I prevent two units from moving to the same tile in Godot?

Keep a reservation table that maps each claimed cell to a stable unit id. Preview checks it without mutation; confirm validates again and atomically moves ownership from the old cell to the destination. If another unit owns the target, return TARGET_OCCUPIED with that owner's id.

Should preview reserve a tile?

No. Preview should be read-only because hover, path inspection, and cancelled input are not committed game actions. Reserve the destination only during confirm.

Is a reservation table part of AStarGrid2D?

No. AStarGrid2D answers path queries over a grid. Destination ownership, turn order, confirm, and undo belong to your game-state layer. Current reservations can still be exposed to a path query as temporary blockers when the movement rules need it.

How do I resolve two simultaneous requests for the same tile?

Use a stable rule such as initiative, command sequence, then agent id as a final tie-breaker. Resolve the request batch before applying the winning confirms; do not let container iteration order choose.

Do large units reserve only their anchor cell?

No. A multi-cell unit must reserve every cell in its destination footprint. Check the whole footprint during preview, snapshot the union of old and new cells during confirm, and restore that union during undo.

Do I need space-time reservations for a turn-based game?

Not when units move sequentially or movement is an atomic board update. Space-time and usually edge reservations are needed when several units execute routes at the same time and can collide between their start and destination cells.