Vav Labs
Back to blog

Godot pathfinding / 2026-07-12 / 14 min read

Verified as of Godot 4.7 stable

Action points and terrain costs in Godot

Build Godot tactical movement where terrain entry costs consume action points, previews stay pure, confirms validate first, and undo restores state.

Top-down Godot tactical grid showing a 1 plus 2 plus 3 terrain-cost route, a 6 AP quote, confirmation at 0 AP, undo restoring 6 AP, and a rejected move that needs 7 AP when only 6 are available.

The direct answer

To make different terrain cost more action points in a Godot tactical RPG, add the cost of each cell when the unit enters it. Skip the starting cell. A route across grass, forest, and swamp might therefore cost 1 + 2 + 3 = 6 AP.

Keep preview and confirm separate. Preview returns the route, exact cost, remaining AP, and current board revision without changing the game state. Confirm checks that the quote is still current and affordable, then updates the unit's cell, occupancy, and AP in one commit block. If any check fails, nothing is written. Undo restores the snapshot captured immediately before confirmation.

The range-search part already has its own implementation in the tactical RPG movement range guide. This article starts where that one stops: a reachable route has been selected, and now the quoted cost and the charged cost have to remain consistent.

Choose the movement contract first

"Action points" can describe several different systems. Pick the one your game actually has before naming variables.

The rest of the article uses shared AP and whole-route confirmation. The other contracts use the same entry-cost function, but their commit boundary differs.

For the practical difference between Fire Emblem-style terrain charging and modern XCOM action bands, see the three-game movement comparison.

Choose the AP commit boundary from the turn system
Your turn systemUse this contract
Movement-only points that reset per unitUse the same terrain-cost math, but call the budget move_points. Spending it shouldn't affect attacks.
Shared AP for movement, attacks, and abilitiesQuote the move's AP cost and show the AP left for the rest of the turn. This is the baseline below.
Movement with overwatch or per-cell interruptsQuote the full route for the UI, but confirm and spend one step at a time so an interrupted move doesn't prepay cells it never enters.
Simultaneous or queued ordersQuote without mutation, then resolve destination reservations and AP spending in a deterministic batch.

Use one cost unit everywhere

Don't let the range overlay count whole AP while path preview uses floats and the confirm button rounds again. That creates a move that costs 5 in one panel, 5.5 in another, and 6 when clicked.

Whole-number terrain costs are enough for many games. If unit profiles or buffs need fractional multipliers, keep an integer fixed-point cost internally and convert only for display. The verified helper uses 1024 internal units per AP; the exact scale matters less than using the same scale everywhere.

Every implementation snippet below is a fragment of the same verified tactical_move_transaction.gd. The complete file, including its small result and normalization helpers, is in the download.

const COST_ONE := 1024
const BLOCKED_COST := -1

static func cost_to_ap(cost: int) -> int:
    if cost <= 0:
        return 0
    @warning_ignore("integer_division")
    return int((cost + COST_ONE - 1) / COST_ONE)

Read authored terrain, then apply the unit profile

TileSet custom data is a good source for stable board facts. Add fields such as terrain and move_cost, read them through TileData, then pass a normalized cell dictionary into the transaction helper's setup(). The custom-data walkability guide covers the editor workflow; AP and live occupancy don't belong in the TileSet.

This keeps the map stable. Infantry, cavalry, swimmers, and flyers can interpret the same terrain differently without repainting the board or mutating one global grid every time selection changes.

The maxi(1, ...) clamp is a rule of this baseline: an authored move_cost = 0 is treated as 1 AP. Dijkstra-style search can support zero-cost edges, but free terrain changes the movement contract and can open large areas without consuming the budget. If the game needs free roads or portals, remove the clamp deliberately and add verifier cases for connected zero-cost regions.

func movement_cost_for(cell: Vector2i, profile: Dictionary) -> int:
    if not _terrain_by_cell.has(cell):
        return BLOCKED_COST
    var terrain_data: Dictionary = _terrain_by_cell[cell]
    var terrain := StringName(terrain_data["terrain"])
    var blocked: Array = profile.get("blocked_terrains", [])
    if blocked.has(terrain):
        return BLOCKED_COST
    var overrides: Dictionary = profile.get("cost_overrides", {})
    var ap_cost := int(terrain_data["move_cost"])
    if overrides.has(terrain):
        ap_cost = int(overrides[terrain])
    # This article baseline deliberately forbids zero-cost entered cells.
    return maxi(1, ap_cost) * COST_ONE

Charge cells when you enter them

The start cell costs zero because the unit already occupies it. Begin at index 1 when totaling a path, and charge every later cell exactly once.

This convention also matches Godot's AStarGrid2D.weight_scale: the weight of a point is applied to travel from a neighbor into that point. You can choose a different game rule, but write it down and use it in range search, path preview, confirmation, AI scoring, and tests. Charging the target in one system and the source in another makes their reported costs disagree.

func path_cost(path: Array[Vector2i], profile: Dictionary) -> int:
    if path.is_empty():
        return BLOCKED_COST
    if not _terrain_by_cell.has(path[0]):
        return BLOCKED_COST

    var total := 0
    for index in range(1, path.size()):
        if _manhattan_distance(path[index - 1], path[index]) != 1:
            return BLOCKED_COST
        var entry_cost := movement_cost_for(path[index], profile)
        if entry_cost == BLOCKED_COST:
            return BLOCKED_COST
        total += entry_cost
    return total

Preview quotes the cost; confirm charges it

Preview shouldn't move the unit, reserve a destination, or subtract AP. It packages the facts the UI needs and records which board state produced them.

Hover can call this repeatedly because it is pure. Canceling a preview has nothing to refund. Preview can also surface current occupancy for friendlier UX; correctness still comes from validating occupancy again at confirmation.

func quote_move(unit_id: int, path: Array[Vector2i]) -> Dictionary:
    if not _units_by_id.has(unit_id) or path.is_empty():
        return _result(false, NO_PATH)
    var unit: Dictionary = _units_by_id[unit_id]
    if path[0] != unit["cell"]:
        return _result(false, NO_PATH)

    var cost := path_cost(path, unit["profile"])
    if cost == BLOCKED_COST:
        return _result(false, TERRAIN_FORBIDDEN)

    var required_ap := cost_to_ap(cost)
    if required_ap > int(unit["ap"]):
        return _result(false, INSUFFICIENT_AP, {
            "unit_id": unit_id,
            "target_cell": path[path.size() - 1],
            "required_ap": required_ap,
            "available_ap": int(unit["ap"]),
            "cost": cost,
        })

    var target := path[path.size() - 1]
    var occupied_by := int(_occupied_by_cell.get(target, 0))
    return _result(true, OK, {
        "unit_id": unit_id,
        "from_cell": unit["cell"],
        "target_cell": target,
        "path": path.duplicate(),
        "cost": cost,
        "required_ap": required_ap,
        "remaining_ap": int(unit["ap"]) - required_ap,
        "board_revision": _board_revision,
        "target_occupied": occupied_by > 0 and occupied_by != unit_id,
        "occupied_by": occupied_by,
    })

Reject stale previews before the first write

A route can become stale after it was shown. A door closes, another unit claims the destination, terrain changes, or the selected unit spends AP on something else. Increment board_revision whenever a change can affect movement legality or cost, and carry that revision in the quote.

Confirmation validates every condition before changing state. It also recalculates the current path cost before the commit block, so a matching revision isn't the only defense against a corrupted quote.

For queued or simultaneous moves, replace the simple occupancy write with the reservation-table contract. The AP rule stays the same: a failed reservation doesn't spend AP.

The revision check is only trustworthy if every movement-relevant mutation increments it. Centralize door, blocker, occupancy, terrain, and reservation changes instead of letting arbitrary nodes edit those structures directly. If a unit can change movement profile between preview and confirm, include that profile's id or revision in the quote and validate it too.

func confirm_move(unit_id: int, quote: Dictionary) -> Dictionary:
    if not bool(quote.get("ok", false)):
        return quote.duplicate(true) if quote.has("reason") else _result(false, INVALID_QUOTE)
    if not _units_by_id.has(unit_id) or int(quote.get("unit_id", 0)) != unit_id:
        return _result(false, WRONG_UNIT)

    var unit: Dictionary = _units_by_id[unit_id]
    var from_cell: Vector2i = quote.get("from_cell", Vector2i(-1, -1))
    var target_cell: Vector2i = quote.get("target_cell", Vector2i(-1, -1))
    if from_cell != unit["cell"]:
        return _result(false, UNIT_MOVED)
    if int(quote.get("board_revision", -1)) != _board_revision:
        return _result(false, STALE_PREVIEW)

    var occupied_by := int(_occupied_by_cell.get(target_cell, 0))
    if occupied_by > 0 and occupied_by != unit_id:
        return _result(false, TARGET_OCCUPIED, {
            "target_cell": target_cell,
            "occupied_by": occupied_by,
        })

    var required_ap := int(quote.get("required_ap", -1))
    if required_ap < 0:
        return _result(false, INVALID_QUOTE)
    if required_ap > int(unit["ap"]):
        return _result(false, INSUFFICIENT_AP, {
            "required_ap": required_ap,
            "available_ap": int(unit["ap"]),
        })

    var path := _typed_path(quote.get("path", []))
    var current_cost := path_cost(path, unit["profile"])
    if current_cost == BLOCKED_COST or current_cost != int(quote.get("cost", -1)):
        return _result(false, STALE_PREVIEW)

    # All validation is complete. The game-state commit begins here.
    _move_history.append({
        "unit_id": unit_id,
        "from_cell": from_cell,
        "to_cell": target_cell,
        "spent_ap": required_ap,
    })
    _occupied_by_cell.erase(from_cell)
    _occupied_by_cell[target_cell] = unit_id
    unit["cell"] = target_cell
    unit["ap"] = int(unit["ap"]) - required_ap
    _units_by_id[unit_id] = unit
    _board_revision += 1

    return _result(true, OK, {
        "unit_id": unit_id,
        "from_cell": from_cell,
        "target_cell": target_cell,
        "spent_ap": required_ap,
        "remaining_ap": int(unit["ap"]),
        "board_revision": _board_revision,
    })

Make insufficient AP explain itself

false is enough to disable a button. It isn't enough to explain the failure to a player or developer.

The UI can render Needs 7 AP; 5 available. A debug panel can show the same payload. And a test can assert the numbers instead of looking for an empty path and guessing why it is empty.

Keep route legality and affordability distinct. NO_PATH means the target couldn't be reached under the movement rules. INSUFFICIENT_AP means a legal route exists but the current action budget can't pay for it.

{
    "reason": "INSUFFICIENT_AP",
    "required_ap": 7,
    "available_ap": 5,
    "target_cell": Vector2i(8, 4),
}

Undo the complete movement state

Undo should restore the cell, AP, and occupancy from the same confirmed command. Restoring only the sprite position leaves the tactical state corrupted.

This is an immediate LIFO undo. If attacks, reactions, hazards, or other units can act after the move, use a broader command log that can reverse every affected system in order. A small movement history shouldn't pretend to be time travel.

func undo_last_move() -> Dictionary:
    if _move_history.is_empty():
        return _result(false, NOTHING_TO_UNDO)

    var move: Dictionary = _move_history[_move_history.size() - 1]
    var unit_id := int(move["unit_id"])
    if not _units_by_id.has(unit_id):
        return _result(false, UNDO_STATE_MISMATCH)

    var unit: Dictionary = _units_by_id[unit_id]
    var from_cell: Vector2i = move["from_cell"]
    var to_cell: Vector2i = move["to_cell"]
    if unit["cell"] != to_cell or int(_occupied_by_cell.get(to_cell, 0)) != unit_id:
        return _result(false, UNDO_STATE_MISMATCH, {
            "unit_id": unit_id,
            "expected_cell": to_cell,
            "actual_cell": unit["cell"],
            "history_preserved": true,
        })

    _move_history.pop_back()
    _occupied_by_cell.erase(to_cell)
    _occupied_by_cell[from_cell] = unit_id
    unit["cell"] = from_cell
    unit["ap"] = mini(int(unit["max_ap"]), int(unit["ap"]) + int(move["spent_ap"]))
    _units_by_id[unit_id] = unit
    _board_revision += 1

    return _result(true, OK, {
        "unit_id": unit_id,
        "restored_cell": from_cell,
        "restored_ap": int(move["spent_ap"]),
        "board_revision": _board_revision,
    })

Where AStarGrid2D fits

AStarGrid2D can choose a terrain-weighted route when each point receives a weight_scale. Keep jumping_enabled off, because the Godot 4.7 docs state that jumping ignores weight scaling.

Its public get_id_path() method returns path cells, not an accumulated-cost result. Calculate the cost with the same movement_cost_for() used by the game, or use a custom result that already carries cost.

For safe terrain weights, destination-entry cost, discounted-road heuristics, diagonals, and per-unit profile grids, use weighted terrain pathfinding in Godot. This article keeps ownership of the AP quote, confirmation, spend, and undo transaction.

One global set of weights can't represent infantry, cavalry, and flyers at the same time when their terrain rules differ. Use a profile-specific query layer, separate cached grids where that is reasonable, or a custom solver.

The AStarGrid2D complete reference covers the API boundary. This article only needs one guarantee: the path shown to the player and the AP charged at confirm must be based on the same cost policy.

Multi-tile units use the same transaction with more cells

For a 2x2 unit, from_cell and target_cell are anchors. Confirmation must validate and transfer the whole footprint, not one point. The multi-tile movement guide covers anchor and transition rules; the AP transaction remains the same.

Capture the old footprint ownership in the undo snapshot, claim the new footprint only after all checks pass, and restore every cell on undo. Don't subtract AP after claiming just one of four destination cells.

Common failures

Charging the starting cell. The unit has already paid whatever brought it there. Path cost begins with the first entered cell.

Calculating cost twice with different rules. The overlay uses terrain metadata, but confirm counts path length. Put cost behind one function.

Spending AP during hover. Preview is disposable. It should have no gameplay side effects.

Trusting a preview forever. Store a board revision and reject a quote after movement-relevant state changes.

Moving first, validating later. Check revision, occupancy, and AP before the first write. A failed confirm shouldn't need cleanup.

Refunding AP but not occupancy. Undo restores the movement command, not one integer.

Enabling AStarGrid2D jumping with terrain weights. The documented behavior ignores weight scaling, so the chosen route may no longer match the AP model.

Run the transaction proof yourself

The standalone Godot 4.7 source project makes one short move across grass, forest, and swamp. It shows the 6 AP quote before confirmation, spends exactly 6 AP on confirm, rejects insufficient AP, stale revision, and occupied-target cases without mutation, then restores the cell, AP, and occupancy on undo.

Its machine-readable correctness receipt reports 13/13 named checks. Twelve logic checks and the runnable scene passed in the proof workspace. The packaged ZIP was then extracted into a clean directory, parsed, and rerun with the same 12 IDs before the thirteenth package check was added.

Measurement-gap: this is a correctness result, not a benchmark. It proves agreement between the preview quote, confirmed spend, failure payloads, occupancy update, and immediate LIFO undo under the declared whole-route contract; it makes no runtime, throughput, or memory claim.

Where this fits in the tactical stack

The range search decides where the unit can go. This transaction decides whether the selected move can still commit and what it costs. Reservations settle ownership when several commands compete for cells. An influence map can then help evaluate what the unit should do, without owning AP state itself.

Keeping those boundaries separate makes later rules easier to add: attacks that consume the remaining AP, terrain buffs, zones of control, interrupted movement, and multi-size units. Authored terrain is interpreted through the unit profile, while action state changes only when the result is confirmed.

The coordinator above these layers is covered in Turn order and movement planning in Godot: it owns phases, queued command ordering, current-state revalidation, and the handoff to animation playback.

Frequently asked questions

How do I make different terrain cost more action points in a Godot tactical RPG?

Store a movement cost for each terrain tile, add the cost when the path enters that cell, skip the starting cell, and compare the total with the unit's current AP. Preview the total without mutation; subtract it only after confirmation passes its stale-state and occupancy checks.

Should terrain cost be charged when entering or leaving a cell?

Charging on entry is the clearest baseline and matches AStarGrid2D point-weight semantics. Other rules can work, but range search, preview, confirm, AI, and tests must all use the same convention.

Should movement points and action points be the same variable?

Only if the game uses one shared budget for movement, attacks, and abilities. If movement has its own allowance, call it move_points and keep it separate from combat AP even though both can use the same terrain-cost functions.

When should a tactical move spend AP?

For whole-route movement, spend AP after confirm validates the current board revision, destination occupancy, and available AP. For movement with per-cell interrupts, spend per confirmed step so an interrupted unit doesn't pay for cells it never enters.

How do I stop a stale path preview from spending AP?

Store a board revision in the preview quote. Increment the revision whenever terrain, blockers, occupancy, doors, or reservations change. Confirmation rejects a quote whose revision no longer matches and asks the UI to preview again.

How should undo refund action points?

Store the exact AP spent with the confirmed move, then restore that value together with the unit's previous cell and occupancy. A simple LIFO history is enough for immediate undo; a game with later reactions or attacks needs a wider command log.

Can AStarGrid2D calculate the action-point cost for me?

It can choose routes using point weights, but its public path methods return path cells or positions, not a game action transaction. Your movement layer still owns the accumulated cost, AP state, stale-preview checks, confirm, and undo.