Vav Labs
Back to blog

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

Verified as of Godot 4.7 stable

Weighted Terrain Pathfinding in Godot

Make AStarGrid2D prefer roads over mud and swamp with verified terrain weights, correct route-cost math, safe heuristics, and per-unit profiles.

Technical diagram comparing a direct swamp route with cost 12 against a longer road route with cost 6, plus safe AStarGrid2D and diagonal-corner rules.

The direct answer

To make AStarGrid2D prefer roads over mud, swamp, or expensive terrain in Godot 4.7, use three rules:

  1. Keep the cheapest terrain at 1.0. Give normal ground or roads a weight of 1.0, then express mud, swamp, and hazards as larger multipliers. Values below 1.0 can make the built-in estimate overstate the true remaining cost and return a suboptimal route.
  2. Disable Jump Point Search. Set jumping_enabled = false; Godot currently ignores weight scaling while jumping is enabled.
  3. Charge the cell being entered. The destination point's weight multiplies the step cost. The starting cell's weight is not added to the route.

Start from the gameplay problem

A longer road wins when its accumulated entry cost is lower. If the game truly needs discounts below 1.0, override _estimate_cost() with a safe minimum-weight-scaled estimate or return 0.0 for Dijkstra-style search.

Route a weighted-pathfinding symptom to the correct cost decision
What your game needsRoute-cost decision
The character crosses swamp instead of following the roadKeep the road at 1.0, raise the swamp above 1.0, turn jumping off, and compare the complete route costs.
AI should avoid cells under enemy fireCombine authored terrain cost with a shared danger penalty. If danger depends on faction or unit, use a profile grid or custom query layer.
A tractor is fast on dirt but a sports car prefers asphaltOne native grid cannot accept different weights per query. Use one prepared grid per stable vehicle profile or a profile-aware solver.
A diagonal route clips a blocked or dangerous cornerChoose an explicit diagonal_mode. It checks adjacent solid cells but doesn't charge adjacent expensive cells that the route never enters.
The route looks right but its AP total is wrongAdd the base step cost multiplied by each entered cell's weight. Don't charge the starting cell.
A discounted road below 1.0 is ignoredScale the estimate by the minimum legal weight or return 0.0; the built-in estimate can otherwise return a suboptimal route.

Route cost is not cell count

The AStarGrid2D complete reference covers coordinate conversion, grid construction, and the broader API. This page stays on weighted route choice.

An influence map can generate danger values. A danger layer shared by every unit can be folded into one grid's point weights; faction- or unit-specific danger belongs in the profile architecture later in this article.

Suppose the direct route enters four swamp cells at cost 3.0 each. Its total is 12.0. A six-cell road costs 6.0 when every entered road cell is 1.0. The road is geometrically longer, but it is the correct weighted route.

For the four-way baseline, route cost = sum(weight_scale of every path cell after the start). AStarGrid2D compares that accumulated value during search. It doesn't find a shortest path first and add terrain preference afterward.

Build a safe four-way grid

Every helper snippet below comes from the same verified weighted_terrain_pathfinder.gd. The complete file, runnable scene, and headless verifier are in the download.

This baseline has one unit of geometric cost per cardinal step. The cheapest traversable terrain is 1.0, expensive terrain is greater than 1.0, and Jump Point Search is off.

static func build_four_way_grid(
        region: Rect2i,
        weights: Dictionary = {}
) -> AStarGrid2D:
    var grid := AStarGrid2D.new()
    _configure_four_way_grid(grid, region)
    if not apply_weights(grid, weights):
        return null
    return grid


static func _configure_four_way_grid(grid: AStarGrid2D, region: Rect2i) -> void:
    grid.region = region
    grid.diagonal_mode = AStarGrid2D.DIAGONAL_MODE_NEVER
    grid.default_compute_heuristic = AStarGrid2D.HEURISTIC_MANHATTAN
    grid.default_estimate_heuristic = AStarGrid2D.HEURISTIC_MANHATTAN
    grid.jumping_enabled = false
    grid.update()

Apply the complete weight batch after update()

Keep walkability and cost as separate data. A tile may be walkable and still be expensive. Validate a batch before the first write so one invalid cell doesn't leave a partially updated grid.

Direct weight writes take effect without another update(). If a later change to region, cell_size, offset, or cell_shape dirties the grid, the runtime AStarGrid2D update guide shows how to rebuild and replay the authored layer.

The TileMapLayer walkability guide keeps blocked-cell metadata separate. A future movement-cost authoring article will own the TileSet editor workflow; this page consumes normalized weights.

static func apply_weights(grid: AStarGrid2D, weights: Dictionary) -> bool:
    if grid == null or grid.is_dirty():
        return false

    # Validate the entire batch before the first write.
    for cell_value in weights.keys():
        if not cell_value is Vector2i:
            return false
        var cell: Vector2i = cell_value
        var weight := float(weights[cell])
        if not grid.is_in_boundsv(cell) or weight < 0.0 or is_nan(weight) or is_inf(weight):
            return false

    for cell_value in weights.keys():
        var cell: Vector2i = cell_value
        grid.set_point_weight_scale(cell, float(weights[cell]))
    return true

Diagonal movement can cut a corner

diagonal_mode decides whether a point agent may connect two diagonal cells when side-adjacent cells are solid.

The corner rule doesn't inspect the side cells' weights. If a side cell is walkable but costs 5.0, a diagonal can pass beside it without paying 5.0 because the route didn't enter that cell. The diagonal destination is charged: _compute_cost(from, destination) * destination_weight.

Use ONLY_IF_NO_OBSTACLES when a point unit must not squeeze past either solid corner. If fire exposure should make the diagonal edge itself expensive, put the penalty on its destination or implement a documented _compute_cost() transition rule. A multi-cell body needs the separate footprint-transition contract; point-agent diagonal modes don't prove that a 2x2 unit fits.

How each AStarGrid2D diagonal mode handles solid corner cells
ModeCorner rule
DIAGONAL_MODE_ALWAYSAllows the diagonal without checking the two side neighbors.
DIAGONAL_MODE_AT_LEAST_ONE_WALKABLEAllows it while at least one side neighbor is open.
DIAGONAL_MODE_ONLY_IF_NO_OBSTACLESRequires both side neighbors to be open.
DIAGONAL_MODE_NEVERUses cardinal movement only.

The starting-cell paradox: leaving mud is not charged

When a route moves from A to B, Godot applies B's weight. It doesn't charge A again.

For [start, road, mud, goal] with weights [9.0, 1.0, 3.0, 1.0], the four-way route cost is 1.0 + 3.0 + 1.0 = 5.0. The 9.0 stored on start is not part of that route.

If a unit begins in mud and its first step enters a road, that step pays the road's weight. A UI that charges the starting mud again will disagree with the pathfinder, and an AP confirmation layer may reject a route that the preview called affordable. The action-points guide uses the same entry-cost convention for preview, confirm, and undo.

Calculate the cost returned by get_id_path()

get_id_path() returns cells, not a total. With Jump Point Search disabled, the verified four-way helper requires adjacent cardinal steps and sums every entered destination.

Don't use this four-way helper for diagonal movement. A diagonal-cost display must use the same geometric _compute_cost() rule as the configured grid. It also deliberately rejects a JPS path that omits intermediate cells; this weighted baseline keeps jumping disabled.

static func four_way_path_cost(grid: AStarGrid2D, path: Array[Vector2i]) -> float:
    if grid == null or path.is_empty():
        return INVALID_PATH_COST

    var total := 0.0
    for index in range(1, path.size()):
        var step := path[index] - path[index - 1]
        var distance := absi(step.x) + absi(step.y)
        if distance != 1:
            return INVALID_PATH_COST
        total += grid.get_point_weight_scale(path[index])
    return total

Why a road below 1.0 can return the wrong route

A* needs an estimate that doesn't overstate the cheapest remaining cost. Manhattan distance assumes every remaining cardinal step costs at least 1.0. A road weight of 0.25 breaks that assumption.

The Godot 4.7 proof uses a 5 x 2 grid. The direct top route costs 4.0; the six-step discounted road costs 2.25. Built-in Manhattan returns 4.0. A zero or correctly 0.25-scaled estimate returns 2.25.

The easiest correction is authoring: keep roads at 1.0 and raise terrain the route should avoid. If an existing cost set has a minimum below 1.0, scale the whole set upward until its new minimum is at least 1.0. That preserves route ordering and restores admissibility for the built-in geometric estimate. Multiplying every weight by an arbitrary positive constant preserves the mathematically cheapest route, but it doesn't preserve search correctness when the resulting minimum remains below 1.0.

When discounts are a real game rule, subclass AStarGrid2D and scale the estimate by the smallest legal weight. Setting the scale to 0.0 gives Dijkstra-style search.

Godot 4.7 does call both GDScript overrides; the exact observed call counts stay in the receipt because they're implementation detail. Don't set default_estimate_heuristic = 0 expecting a disabled estimate: enum value 0 is Euclidean.

A zero estimate may expand materially more cells than an informed admissible estimate. This article makes no performance claim about that trade-off.

Verified discounted-road route costs by estimate policy
EstimateReturned costOptimal cost
Built-in Manhattan4.02.25
Zero estimate2.252.25
Manhattan scaled by 0.252.252.25
class DiscountAwareGrid:
    extends AStarGrid2D

    var minimum_weight_scale := 0.0
    var estimate_calls := 0
    var compute_calls := 0

    func _estimate_cost(from_id: Vector2i, end_id: Vector2i) -> float:
        estimate_calls += 1
        var dx := absi(end_id.x - from_id.x)
        var dy := absi(end_id.y - from_id.y)
        return float(dx + dy) * minimum_weight_scale

    func _compute_cost(from_id: Vector2i, to_id: Vector2i) -> float:
        compute_calls += 1
        return float(absi(to_id.x - from_id.x) + absi(to_id.y - from_id.y))

Pick the smallest profile architecture that fits

get_id_path() doesn't accept a terrain profile or per-query weight callback. Native point weights are shared state on one AStarGrid2D instance.

Five stable soldier or vehicle types can reasonably use five prepared grids. A tractor profile may treat dirt as 1.0; a sports-car profile can give dirt a higher multiplier. Query the matching grid instead of rewriting one shared weight layer before every request. Remember that runtime blockers and structural rebuilds must stay synchronized across those grids.

Avoid per-query mutation of one shared grid when queries can overlap, defer, or feed a path cache. For tactical reachable cells rather than one destination, use the movement-range guide.

Choose a terrain-cost architecture from the number and stability of unit profiles
Terrain ruleArchitecture
Every unit reads the same costsOne AStarGrid2D and one weight layer.
A few stable profiles read terrain differentlyOne prepared grid per profile, with shared structural updates replayed to each grid.
Many or dynamic profiles use masks and multipliersA custom query layer that reads the profile during expansion and returns total cost.
The player needs every cell within an AP budgetA budgeted Dijkstra/range search, then reconstruct the preview from that result.

What the verification covers

The downloadable Godot 4.7 project contains the assembled helper, runnable comparison scene, verifier, and README. Its machine-readable receipt reports 17/17 named checks and zero failures after a clean extracted-package rerun.

The receipt covers route choice, entry-cost reconstruction, the starting cell, runtime weight edits, JPS, the 4.0 versus 2.25 counterexample, GDScript overrides, diagonal corners, and profile-specific grids. It doesn't cover search speed, memory, TileSet authoring, influence-map generation, AP transactions, or multi-cell movement.

The ZIP is 9,531 bytes with SHA-256 d7abad3952b97987a0432b2e277b78a2e8a52dfbf1368e2207ccb9e96778bf9c. Measurement-gap: this is a deterministic correctness result, not a runtime, throughput, memory, asymptotic, every-project, or released-product benchmark.

Verified weighted-terrain diagram showing the cost-6 detour, the 4.0 versus 2.25 heuristic counterexample, and permissive versus strict diagonal corner rules.
The verified contract separates route cost, heuristic admissibility, and diagonal policy; the downloadable Godot project reproduces every case.

Debug weighted terrain in this order

The order matters because it separates coordinate mistakes, JPS, route math, heuristic admissibility, diagonal policy, rebuild loss, and profile selection.

  1. Confirm the weighted cells use grid IDs, not world positions.
  2. Confirm jumping_enabled is false.
  3. Compare the total cost of the returned route with the intended detour.
  4. Check whether any legal weight is below 1.0 and whether the estimate is scaled accordingly.
  5. Check diagonal_mode and remember that side-cell weights aren't charged.
  6. Check whether a structural update() recreated the point layer.
  7. Check that the query used the intended unit-profile grid.

Where PathForge goes further

PathForge applies terrain masks and fixed-point cost multipliers from the query's agent profile without rewriting an AStarGrid2D weight layer before each request, and returns total cost with each path.

That capability matters when infantry, vehicles, flying units, and large footprints interpret the same board differently. The standalone download above proves the native Godot baseline; PathForge extends the profile and workflow layer.

Frequently asked questions

How do I make AStarGrid2D prefer roads?

Keep roads at the cheapest safe baseline, normally 1.0, and give mud, swamp, slopes, or hazards larger weights. Disable jumping_enabled, then compare total route costs rather than cell count.

Why is the shortest route not always the cheapest route?

Weighted pathfinding minimizes accumulated traversal cost. A six-cell road can correctly beat a four-cell swamp route when the six entered road cells cost less in total.

Why can a road below weight scale 1.0 be ignored?

The built-in estimate can overstate the remaining cost when legal steps cost less than 1.0. Keep the minimum at 1.0, scale an override by the minimum legal weight, or return 0.0 for Dijkstra-style search.

How do I calculate the cost returned by get_id_path()?

Starting at path index one, add each step's geometric cost multiplied by the weight of the destination cell. Use the same cardinal or diagonal distance rule as _compute_cost().

Can each unit type have different terrain costs?

Yes, but one AStarGrid2D stores one weight per point. Use one prepared grid per stable profile when there are few profiles, or a custom profile-aware solver for dynamic masks and multipliers.

When do I reapply terrain weights?

Direct set_point_weight_scale() writes apply without update(). Reapply the authored layer after a structural change dirties the grid and update() rebuilds its points.