Vav Labs
Back to blog

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

Verified as of Godot 4.7 stable

Runtime AStarGrid2D Updates Without Full Rebuilds

Update doors, blockers, and terrain costs in AStarGrid2D without rebuilding the grid. Learn when update() is required and how to replay point data.

A Godot grid compares an immediate door-cell reroute with a structural region rebuild that replays authored walls, terrain weights, and active blockers.

The direct answer

Do you need to call AStarGrid2D.update() after set_point_solid() in Godot? No. Use set_point_solid() for a door, tower, wall, or other cell-level blocker, and the next path query will read the new state. set_point_weight_scale() and both rectangular fill methods also take effect without update().

Call update() after changing the grid's structure: region, cell_size, offset, or cell_shape. That structural rebuild resets point solidity and weight scales, so keep those layers in your own game state and replay them before the next path query.

In Godot 4.7, an update() call on an already clean grid returns without rebuilding. That clean no-op doesn't erase point edits. The reset happens when a structural property has first made the grid dirty.

Match the symptom to the rule

These failures share one root mistake: structural state, query policy, point data, and cached agent routes have different update contracts.

Route an AStarGrid2D runtime symptom to the correct update rule
If this is happeningUse this rule
A door closes, but the route doesn't changeChange its occupied cells with set_point_solid(), then request a new path. Don't call update().
Walls or costs disappear after update()A dirty structural rebuild cleared native point data. Replay authored solidity, weights, and active blockers.
get_id_path() becomes empty after changing regionThe grid is dirty. Call update(), replay point layers, then query.
Removing one blocker reopens anotherTrack blocker owners or per-cell reference counts instead of writing one boolean per object.
The grid changed, but agents keep following an old routeThe point edit is complete; cached-path invalidation is a separate game-system job.

Choose the smallest runtime update

Older examples may set size, but that property is deprecated in Godot 4.7. Set the complete region, including a negative origin when your TileMapLayer uses one.

Choose the AStarGrid2D operation from the kind of runtime change
What changedNative operationCall update()?What happens next
One blocker cell opens or closesset_point_solid()NoThe next query uses the new solid flag.
A rectangular wall or room changesfill_solid_region()NoEvery in-region cell in the clipped rectangle changes immediately.
A terrain cost changesset_point_weight_scale() or fill_weight_scale_region()NoThe next weighted query reads the new scale.
Diagonal, jumping, or default heuristic policy changesSet the matching propertyNoThe next query uses the new policy.
Region, cell size, offset, or cell shape changesSet the property, then update()YesThe native grid is rebuilt; replay solidity and weights before querying.

Initialize structure before point data

The safe setup order is structural properties, one update(), then authored solidity and weights. Every core snippet below is a fragment of the same runtime_astargrid2d_state.gd; the complete assembled file is in the verified download.

The diagonal and jumping properties don't mark the grid dirty in 4.7; they're query policy. The structural properties do. Point setters reject writes while the grid is dirty, so writing walls before the first update is the wrong order.

If the authored board comes from a TileMapLayer, keep coordinates and initial region construction in the TileMapLayer-to-AStarGrid2D guide. Stable walkability and terrain fields can come from TileMapLayer custom data. The runtime helper consumes normalized cells; it doesn't need to know how the designer painted them.

var grid := AStarGrid2D.new()

func initialize_grid(
    board_region: Rect2i,
    board_cell_size: Vector2 = Vector2.ONE,
    board_offset: Vector2 = Vector2.ZERO,
    authored_solid: Dictionary = {},
    authored_weights: Dictionary = {}
) -> bool:
    if not _structure_is_valid(board_region, board_cell_size):
        return false
    if not _point_layers_are_valid(
        board_region, authored_solid, authored_weights
    ):
        return false

    _base_solid = authored_solid.duplicate(true)
    _base_weights = authored_weights.duplicate(true)
    _blocker_cells.clear()
    _blocker_counts.clear()

    grid.region = board_region
    grid.cell_size = board_cell_size
    grid.offset = board_offset
    grid.diagonal_mode = AStarGrid2D.DIAGONAL_MODE_NEVER
    grid.jumping_enabled = false
    grid.update()
    return replay_point_layers()

Edit door and blocker cells directly

A door changes point occupancy rather than the grid region or the position of every point. Prevalidate a multi-cell write so one invalid id can't leave half a door applied.

There is no update() after these writes. A new get_id_path() call will avoid the closed cells or use them again after the door opens.

fill_solid_region() is useful for an axis-aligned rectangle. Godot clips that rectangle to the grid region. The clipping is convenient for painting tools but can hide an invalid gameplay request. If a door footprint must fit completely inside the board, validate the rectangle first.

func set_cells_solid(cells: Array, solid: bool) -> bool:
    if grid.is_dirty() or not _all_cells_in_bounds(cells):
        return false
    for cell: Vector2i in cells:
        grid.set_point_solid(cell, solid)
    return true

func _all_cells_in_bounds(cells: Array) -> bool:
    for cell: Vector2i in cells:
        if not grid.is_in_boundsv(cell):
            return false
    return true

What update() does in Godot 4.7

When grid.is_dirty() is false, Godot 4.7 returns immediately from update(). It doesn't rebuild the region or clear point data. That early return was added for Godot 4.3, which is why older examples report different behavior.

When a structural setter makes the grid dirty, update() clears stored points and the solid mask, reconstructs positions across the complete region, and marks the grid clean. Solidity and weight scales return to their defaults.

An unconditional update() after set_point_solid() is therefore a redundant no-op on a clean 4.7 grid. It becomes destructive when the same code path has also changed region, offset, cell_size, or cell_shape. The call now does a real rebuild, and point layers disappear unless you replay them.

Keep point data outside the grid

The grid is an index, not the source of truth. Keep the data required to reconstruct it in ordinary game state.

Once a structural property marks the grid dirty, get_point_data_in_region() rejects the read. Resizing first and then asking the dirty grid what it used to contain is too late. Preserve the layers before the change, or own them outside AStarGrid2D from the beginning.

If the new region is smaller, decide whether out-of-bounds blocker owners are discarded or retained for a later expansion. Make that lifecycle explicit rather than leaving it to dictionary leftovers.

  • Authored solid cells from the level or TileMapLayer.
  • Authored weight scales.
  • Active runtime blocker ids and their cells.
  • Per-cell blocker counts when owners overlap.
var _base_solid: Dictionary = {}      # Vector2i -> true
var _base_weights: Dictionary = {}    # Vector2i -> float
var _blocker_cells: Dictionary = {}   # blocker id -> Array[Vector2i]
var _blocker_counts: Dictionary = {}  # Vector2i -> int

func replay_point_layers() -> bool:
    if grid.is_dirty():
        return false

    for raw_cell in _base_solid.keys():
        var cell: Vector2i = raw_cell
        if grid.is_in_boundsv(cell) and bool(_base_solid[cell]):
            grid.set_point_solid(cell, true)

    for raw_cell in _base_weights.keys():
        var cell: Vector2i = raw_cell
        if grid.is_in_boundsv(cell):
            grid.set_point_weight_scale(cell, float(_base_weights[cell]))

    for raw_cell in _blocker_counts.keys():
        var cell: Vector2i = raw_cell
        if grid.is_in_boundsv(cell) and int(_blocker_counts[cell]) > 0:
            grid.set_point_solid(cell, true)
    return true

Reconfigure once, then replay

Structural changes are valid at runtime, but they use a different path from a door toggle.

Don't query between the structural writes and update(). get_id_path() and get_point_path() reject a dirty grid and return an empty path. Point setters reject that state as well.

This is a full native rebuild because the meaning or position of every stored point may have changed. Keep ordinary doors, blockers, and cost changes on the point-data path so structural rebuilds remain rare.

func reconfigure_grid(
    next_region: Rect2i,
    next_cell_size: Vector2,
    next_offset: Vector2,
    next_cell_shape: AStarGrid2D.CellShape = AStarGrid2D.CELL_SHAPE_SQUARE
) -> bool:
    if not _structure_is_valid(next_region, next_cell_size):
        return false
    grid.region = next_region
    grid.cell_size = next_cell_size
    grid.offset = next_offset
    grid.cell_shape = next_cell_shape
    if grid.is_dirty():
        grid.update()
    return replay_point_layers()

Track overlapping blocker owners

A single boolean is insufficient when two runtime objects cover one cell. A closed gate and a temporary force field may share a tile; opening the gate must not remove the force field from pathfinding.

Use a count per cell and restore authored solidity when the last runtime owner leaves. The authored check prevents that last owner from reopening a wall that was already solid in the level.

func _claim_blocker_cell(cell: Vector2i) -> void:
    var next_count := blocker_count_at(cell) + 1
    _blocker_counts[cell] = next_count
    grid.set_point_solid(cell, true)

func _release_blocker_cell(cell: Vector2i) -> void:
    var next_count := maxi(0, blocker_count_at(cell) - 1)
    if next_count == 0:
        _blocker_counts.erase(cell)
    else:
        _blocker_counts[cell] = next_count
    var remains_solid := authored_solid_at(cell) or next_count > 0
    grid.set_point_solid(cell, remains_solid)

func blocker_count_at(cell: Vector2i) -> int:
    return int(_blocker_counts.get(cell, 0))

func authored_solid_at(cell: Vector2i) -> bool:
    return bool(_base_solid.get(cell, false))

Replace a moving blocker atomically

A moving object uses the same ownership data. Validate its complete next footprint before releasing any previous cell, then release only cells it left and claim only cells it entered.

The verifier includes overlap-release and invalid-batch fixtures. Scheduling hundreds of moving blockers belongs to a later high-frequency policy layer, not this correctness baseline.

func replace_runtime_blocker(
    blocker_id: StringName,
    next_cells: Array
) -> bool:
    if blocker_id.is_empty() or grid.is_dirty():
        return false
    var normalized := _unique_cells(next_cells)
    if not _all_cells_in_bounds(normalized):
        return false

    var previous: Array[Vector2i] = []
    if _blocker_cells.has(blocker_id):
        previous = _copy_cells(_blocker_cells[blocker_id])

    for cell in previous:
        if not normalized.has(cell):
            _release_blocker_cell(cell)
    for cell in normalized:
        if not previous.has(cell):
            _claim_blocker_cell(cell)

    if normalized.is_empty():
        _blocker_cells.erase(blocker_id)
    else:
        _blocker_cells[blocker_id] = normalized.duplicate()
    return true

Point edits and cached paths are separate jobs

set_point_solid() updates the native grid. It doesn't notify agents or rewrite path arrays they already hold.

If a newly blocked cell appears in a cached route, that route is illegal and should be invalidated. Opening a cell or lowering a cost may leave the old route legal but no longer cheapest. The game decides whether to keep it until its normal repath point or refresh it immediately.

The dynamic-blocker guide owns path-cell bookkeeping, queued repaths, and cell/chunk/region invalidation. Keep those policies outside the low-level writer so one door signal doesn't solve every affected route in the same frame.

Terrain costs use the point-data path too

Runtime mud, fire, or a temporary road can change weight_scale without a rebuild.

This establishes only the update boundary. The weighted terrain pathfinding guide owns safe weight authoring, returned-route cost reconstruction, sub-1.0 heuristic safety, and the documented fact that jumping ignores weight scaling.

Lowering a cost doesn't necessarily invalidate an existing route. It can make another route cheaper. That's a refresh-policy choice, not a reason to rebuild the grid.

func set_runtime_weight(cell: Vector2i, weight_scale: float) -> bool:
    if grid.is_dirty() or not grid.is_in_boundsv(cell) or weight_scale < 0.0:
        return false
    grid.set_point_weight_scale(cell, weight_scale)
    return true

Common failures

Calling point setters before the first update. The grid is dirty and rejects the write.

Using update() as a generic refresh. Point-local changes don't need it; a later structural edit turns the same call into a real rebuild.

Assuming a dirty update preserves obstacles. It resets solidity and weights, so replay them.

Changing region and querying immediately. A dirty grid rejects the path query and returns an empty array. Use the broader empty-path guide when the failing layer isn't yet known.

Trying to snapshot after the grid is dirty. get_point_data_in_region() rejects that state too.

Unblocking by boolean when owners overlap. One object can reopen another object's cell.

Releasing a runtime blocker without checking authored solidity. A level wall becomes walkable.

Editing the grid while agents keep cached routes forever. The native state is correct, but the agents still follow stale data.

Assuming a rectangular fill validates the whole request. The native fill clips to the grid region.

What the verification covers

The Godot 4.7 source package contains the assembled runtime state helper, a runnable door/blocker scene, and the headless verifier. The machine-readable receipt records 14/14 published checks, plus a green scene smoke.

The named checks cover point-local rerouting, immediate weight edits, clean-update preservation, dirty-update resets, authored-layer replay, overlapping owners, moving-blocker replacement, invalid-batch rejection, and the extracted-package rerun.

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

Godot 4.7 proof scene with a closed red runtime door, a yellow route detouring around authored wall cells, and a clean grid status after zero update calls for the toggle.
Verified scene state: closing the runtime door reroutes immediately while AStarGrid2D remains clean and update() is not called.

Where this fits in the navigation stack

Build the initial board from a TileMapLayer, then use this runtime layer for doors, blockers, and temporary costs. The complete reference owns the rest of the native API.

If a point edit is correct but too many agents repath in one frame, move up to the dynamic-blocker invalidation guide. A tower-defense placement adds a speculative route-validation transaction. Multi-cell units add the footprint execution contract before a destination is legal for the complete body.

Frequently asked questions

Do I need to call AStarGrid2D.update() after set_point_solid()?

No. In Godot 4.7, set_point_solid() changes point data immediately and the next path query reads it. Call update() only after structural changes such as region, cell_size, offset, or cell_shape.

Why did AStarGrid2D lose my solid cells after update()?

A dirty update() reconstructs the region and resets solidity and weight scales. Keep authored walls, costs, and active blockers in your own source of truth, then replay them. A clean update() is a no-op in Godot 4.3 through 4.7.

How do I open or close a door in AStarGrid2D at runtime?

Call set_point_solid(cell, true) to close its occupied cells and set_point_solid(cell, false) to open them. Don't call update(). Then refresh or invalidate any cached route affected by the change.

Why is get_id_path() empty after I changed the grid region?

Changing region makes the grid dirty. get_id_path() rejects a dirty grid, so call update() before querying and replay the solidity and weights cleared by the structural rebuild.

Does fill_solid_region() need update()?

No. fill_solid_region() takes effect without update() and clips its input rectangle to the grid region. Validate first if your game requires the entire rectangle to remain in bounds.

Does changing diagonal_mode require update()?

No in Godot 4.7. diagonal_mode changes query policy without making the grid dirty. cell_shape is different: it changes stored point placement and requires update().

Can I resize AStarGrid2D at runtime?

Yes. Set the new region, call update(), then replay authored solidity, weights, and active blockers before the next path query. Decide explicitly what happens to blocker owners outside a smaller region.