Vav Labs
Back to blog

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

Verified as of Godot 4.7 stable docs and runnable proof scene checked on 2026-07-07

Tactical RPG movement range in Godot

Build tactical RPG movement range in Godot with TileMapLayer, AStarGrid2D, terrain costs, unit blockers, reachable-cell highlights, and path preview.

The direct answer

Tactical RPG movement range is a budgeted Dijkstra field, not one A* path. A* answers how do I reach this one target? Movement range answers which cells can this unit afford to enter this turn?

The practical Godot setup is simple: use TileMapLayer as the authored board, build an AStarGrid2D for path preview, then run a small cost-limited search over neighboring cells to find the reachable range.

A useful rule is this: a unit with move_points = 5 may enter any walkable, unoccupied cell whose cheapest accumulated cost is less than or equal to 5. That one sentence is the system. Everything else is data plumbing.

  1. Read walkability and terrain cost from tile data.
  2. Keep runtime units and blockers outside the tilemap.
  3. Run a budgeted range search from the selected unit.
  4. Store the cheapest cost and predecessor for each reached cell.
  5. Draw the reachable overlay from that result.
  6. Preview the selected route from the same predecessor data.

Range and path are different questions

A tactical tactics UI usually needs two layers. The blue or green area is the movement range. The line to the hovered cell is the path preview. Treating those as the same problem is how projects end up running dozens of A* queries every time the cursor moves.

For a square tactical board, start with a four-direction baseline: north, south, east, and west. That matches the Fire Emblem-style rule most developers mean when they say grid movement range. Add diagonals only after the range search and the path preview share the same diagonal policy.

The range result should be the authority. A* can still be useful for a one-off path query, but when terrain is weighted, the cleanest preview is usually the predecessor chain produced by the same range search that decided the cell was reachable.

Start with board data, not the pathfinder

The board needs a few explicit rules. Some belong in the TileSet; some belong in runtime state.

Tile data is a good place for stable authored facts: walkable or not, base movement cost, terrain type, and maybe tags such as swamp, road, forest, water, or bridge. Runtime state is a better place for units, temporary blockers, doors, hazards, and reservations.

DataGood sourceWhy
Walkable tileTileSet custom dataThe rule is authored with the map and does not depend on a unit.
Base movement costTileSet custom dataMud, road, water, and forest are terrain rules.
Unit occupancyRuntime dictionary/setUnits move every turn; do not repaint the map to track them.
Temporary blockerRuntime overlayDoors, traps, ice walls, and reservations should be reversible.
Movement profileUnit dataInfantry, cavalry, flyers, and swimmers can interpret the same terrain differently.

Build the grid from TileMapLayer

The AStarGrid2D is still useful. It gives you a standard grid pathfinder for click-to-cell routing, validation, and comparison. The important part is to build it from the same board facts that drive the movement range.

This setup treats empty cells inside get_used_rect() as solid by default. That avoids the common bug where holes inside an authored board become walkable void because the grid's default point state is open.

var astar := AStarGrid2D.new()
var board_layer: TileMapLayer
var board_region := Rect2i()

func rebuild_navigation_grid() -> void:
    board_region = board_layer.get_used_rect()
    var tile_size := board_layer.tile_set.tile_size

    astar.region = board_region
    astar.cell_size = Vector2(tile_size)
    astar.offset = Vector2(tile_size) / 2.0
    astar.diagonal_mode = AStarGrid2D.DIAGONAL_MODE_NEVER
    astar.default_estimate_heuristic = AStarGrid2D.HEURISTIC_MANHATTAN
    astar.update()

    # Start closed, then open only authored walkable cells.
    astar.fill_solid_region(board_region, true)

    for cell in board_layer.get_used_cells():
        if is_walkable_cell(cell):
            astar.set_point_solid(cell, false)
            astar.set_point_weight_scale(cell, float(base_move_cost(cell)))

Read walkability and cost from tile custom data

Godot's TileSet custom data is the clean source for gameplay walkability. Collision can be useful for prototypes, but tactics rules usually diverge from physics quickly: cover, projectiles, selection, bridges, and movement do not always mean the same thing.

Keep the helper boring and strict. Missing tile data should usually mean blocked, because silent walkable void is harder to debug than a missing route.

func tile_data(cell: Vector2i) -> TileData:
    return board_layer.get_cell_tile_data(cell)

func is_walkable_cell(cell: Vector2i) -> bool:
    var data := tile_data(cell)
    if data == null:
        return false
    if not data.has_custom_data("walkable"):
        return false
    return bool(data.get_custom_data("walkable"))

func base_move_cost(cell: Vector2i) -> int:
    var data := tile_data(cell)
    if data == null:
        return 999999
    if data.has_custom_data("move_cost"):
        return max(1, int(data.get_custom_data("move_cost")))
    return 1

Compute the reachable cells

For movement range, run a cost-limited search. Each open cell stores the cheapest known cost to reach it and the previous cell used to get there. Stop expanding a branch when the next cost would exceed the unit's movement budget.

This is deliberately four-way. If you later support diagonals, add diagonal neighbors, diagonal costs, corner-cutting rules, and a matching AStarGrid2D.diagonal_mode. Do not let the range search and the path preview disagree.

const ORTHOGONAL_NEIGHBORS: Array[Vector2i] = [
    Vector2i.RIGHT,
    Vector2i.LEFT,
    Vector2i.DOWN,
    Vector2i.UP,
]

func compute_movement_range(
        start: Vector2i,
        move_points: int,
        moving_unit: Node,
        profile: MovementProfile) -> Dictionary:
    var frontier: Array[Vector2i] = [start]
    var cost_so_far := { start: 0 }
    var came_from := { start: start }

    while not frontier.is_empty():
        var current := pop_lowest_cost_cell(frontier, cost_so_far)

        for offset in ORTHOGONAL_NEIGHBORS:
            var next := current + offset

            if not astar.is_in_boundsv(next):
                continue
            if not can_enter_cell(next, moving_unit, profile):
                continue

            var next_cost: int = cost_so_far[current] + movement_cost_for(next, profile)
            if next_cost > move_points:
                continue

            if not cost_so_far.has(next) or next_cost < cost_so_far[next]:
                cost_so_far[next] = next_cost
                came_from[next] = current
                if not frontier.has(next):
                    frontier.append(next)

    return {
        "cost": cost_so_far,
        "came_from": came_from,
    }

func pop_lowest_cost_cell(frontier: Array[Vector2i], cost_so_far: Dictionary) -> Vector2i:
    var best_index := 0
    var best_cost: int = cost_so_far[frontier[0]]

    for index in range(1, frontier.size()):
        var cell := frontier[index]
        var cost: int = cost_so_far[cell]
        if cost < best_cost:
            best_cost = cost
            best_index = index

    return frontier.pop_at(best_index)

Preview the route from the same result

When the player hovers a highlighted cell, reconstruct the path from came_from. That keeps the preview consistent with the movement range, including terrain costs and unit-specific rules.

AStarGrid2D.get_id_path() is still useful for plain click-to-cell routing. For weighted tactical movement, prefer the predecessor chain from the range search so the displayed route matches the cost number on screen.

func reconstruct_range_path(came_from: Dictionary, start: Vector2i, goal: Vector2i) -> Array[Vector2i]:
    if not came_from.has(goal):
        return []

    var path: Array[Vector2i] = []
    var current := goal

    while current != start:
        path.push_front(current)
        current = came_from[current]

    path.push_front(start)
    return path

Occupied cells need a policy

Units are not tiles. Keep occupied cells in runtime state, then decide the policy explicitly. A common tactics rule is: enemy-occupied cells block entry, friendly-occupied cells block stopping, and the moving unit's own cell is allowed.

The important part is that range search, path preview, and final move validation all call the same helper. If each system has its own occupancy interpretation, a hovered route will eventually lie to the player.

var occupied_by_cell: Dictionary = {}

func can_enter_cell(cell: Vector2i, moving_unit: Node, profile: MovementProfile) -> bool:
    if not astar.is_in_boundsv(cell):
        return false
    if astar.is_point_solid(cell):
        return false
    if movement_cost_for(cell, profile) >= 999999:
        return false

    var occupant = occupied_by_cell.get(cell)
    return occupant == null or occupant == moving_unit

func can_stop_on_cell(cell: Vector2i, moving_unit: Node, profile: MovementProfile) -> bool:
    if not can_enter_cell(cell, moving_unit, profile):
        return false
    var occupant = occupied_by_cell.get(cell)
    return occupant == null or occupant == moving_unit

Terrain profiles are where tactics begin

A movement profile lets the same board support different unit types without duplicating the map. Infantry can pay normal swamp cost, cavalry can pay more, flyers can ignore it, and swimmers can treat water as cheap instead of blocked.

The exact terrain model is game-specific. The useful baseline is to put the variation behind movement_cost_for(), then let the range search call that one function.

class_name MovementProfile

var ignores_swamp := false
var swamp_cost := 3

func movement_cost_for(cell: Vector2i, profile: MovementProfile) -> int:
    var cost := base_move_cost(cell)

    if is_swamp(cell) and profile.ignores_swamp:
        return 1

    if is_swamp(cell):
        return max(cost, profile.swamp_cost)

    return cost

func is_swamp(cell: Vector2i) -> bool:
    var data := tile_data(cell)
    if data == null or not data.has_custom_data("terrain"):
        return false
    return String(data.get_custom_data("terrain")) == "swamp"

Draw an overlay, not a second board

The range overlay should be presentation. Do not edit the TileMapLayer just to show reachability. Draw cells through a separate overlay node, a highlight TileMapLayer, a MultiMesh, or whatever matches your renderer.

The overlay reads the range result and skips the selected unit's current cell if you want the UI to show only destinations.

func highlight_range(range_result: Dictionary, start: Vector2i) -> void:
    clear_range_overlay()

    for cell in range_result["cost"].keys():
        if cell == start:
            continue
        draw_reachable_cell(cell, int(range_result["cost"][cell]))

Tests worth running before trusting it

Movement range bugs are usually small mismatches between systems. Test the board rules before you wire animation and UI polish around them.

Measurement-gap: the downloadable proof scene is a correctness smoke test, not a performance benchmark. Its headless verification runner writes tactical-rpg-movement-range-verification.json with 9 passing checks for range cost, blockers, terrain policy, and path-preview consistency.

  • A unit with 0 movement can only remain on its start cell.
  • A unit with 1 movement reaches exactly the four walkable neighbors in the four-way baseline.
  • A cost-3 swamp consumes three points and changes both the range boundary and the hover preview.
  • An occupied enemy cell blocks entry.
  • The moving unit's own start cell does not block itself.
  • Empty cells inside the used rectangle follow your explicit policy.
  • Weighted terrain preview uses the came_from path from the range result.
  • Diagonal movement is either unsupported in v1 or implemented consistently in both range and path.

Where this fits in a larger tactics stack

Movement range is one tactical layer. Once it is stable, the next layers usually become obvious: attack range, danger maps, influence maps, reservations, action-point spending, and AI evaluation over the same grid.

That is why the data model matters more than the first overlay. If every layer reads the same board facts - walkability, cost, occupancy, and unit profile - the game can answer richer questions without forking the navigation logic.

A browser companion for this article now lives in the PathForge tactical range playground. Use it for fast inspection, then download the Godot proof scene when you want the runnable source.

For the adjacent pieces, keep the TileMap to navigation grid build step, the AStarGrid2D reference, and the influence maps article nearby.

Frequently asked questions

How do I show movement range in a Godot tactical RPG?

Run a budgeted Dijkstra-style search from the selected unit. Add each walkable neighbor only if the accumulated movement cost stays within the unit's move points, then draw the reached cells as the movement overlay.

Should movement range use AStarGrid2D?

Use AStarGrid2D for grid setup, validation, and path queries, but compute movement range with a cost-limited search. A* answers one target path; movement range needs every reachable cell within a budget.

How do terrain costs work in tactical movement range?

Give each cell a movement cost, usually from TileSet custom data. The range search adds the cost of entering each neighbor and rejects cells whose accumulated cost exceeds the unit's movement budget.

How do I stop units from moving through occupied cells?

Keep a runtime occupied-cell dictionary and make range search, hover preview, and final move validation call the same can_enter_cell() helper. Do not store moving units as permanent tile data.

Can this support diagonal movement?

Yes, but add it deliberately. The baseline in this article is four-way movement. For diagonals, the range search, diagonal cost, corner-cutting rule, and AStarGrid2D.diagonal_mode must all match.

Should the hover path use get_id_path() or the range result?

For weighted tactical movement, prefer reconstructing the path from the range result's came_from data. That guarantees the preview matches the movement cost shown to the player.