Godot pathfinding / 2026-06-05 / 10 min read
Dynamic blockers in Godot: when one tower should not rebuild the whole map
A placed tower or closed door should invalidate a region, not rebuild the whole grid. Update Godot pathfinding locally, without the full-rebuild frame spike.
The problem: one runtime blocker, one frame spike
The world changes at runtime. A player drops a tower, a gate slams shut, a wall takes enough damage to fall, a unit reserves the tile it is standing on. Each of these is a dynamic blocker: one cell flipping from walkable to blocked. The change is tiny. The reaction usually is not.
The common reaction is to treat any occupancy change as a reason to start over: rebuild the navigation data, recompute every agent's path, regenerate the shared field, and hand the whole batch to a single frame. The map was correct a moment ago and one cell moved, but the code behaves as if the entire level just loaded.
That is the frame spike. Like most navigation stalls, it is not about one path being slow; it is about doing far too much work in the frame where the world happened to change. The fix is to make the size of the recompute match the size of the change.
Static grid versus dynamic occupancy
It helps to separate two things that get conflated. The grid is the static structure: which cells exist, how they connect, what they cost to traverse. Occupancy is the runtime state on top of it: which of those cells are blocked right now. A tower does not change the shape of the map. It changes one cell's occupancy.
Godot already respects this split. With AStarGrid2D, marking a cell blocked is a single call and it does not rebuild anything:
The grid keeps all of its structure. The next get_id_path simply refuses to expand that cell. There is no "rebuild" step hiding inside set_point_solid — it is closer to flipping a bit. If your blocker update feels expensive, the cost is almost never the toggle. It is what you do immediately afterwards.
# Flipping one cell's occupancy is O(1). Nothing is rebuilt.
func set_blocker(cell: Vector2i, blocked: bool) -> void:
astar_grid.set_point_solid(cell, blocked)
_dirty_cells.append(cell) Why full rebuilds feel fine in prototypes and awful in production
In a prototype you have one path request, a small grid, and a handful of agents. Clearing everything and recomputing on each change costs a fraction of a millisecond, so it never shows up. The habit forms precisely because the feedback that would correct it is absent.
Three things scale that habit into a stutter. The first is the agent count: "recompute every path" is fine for three units and a frame-spike generator for three hundred. The second is the grid size, because the truly expensive rebuilds — re-baking a NavigationRegion2D polygon, clearing and rewiring a point-based AStar2D, or regenerating a flow field — scale with the whole map, not the change. The third is timing: world changes cluster. A wave lands, a barricade falls, ten towers go down in a build phase, and every one of them triggers the same global recompute inside the same physics tick.
The cell toggle stayed cheap the whole time. What got expensive was treating a local edit as a global event.
Local invalidation: cells, chunks, regions
Local invalidation is the discipline of recomputing only what the change could have affected. It works at three granularities, and you pick the coarsest one your data structure forces on you.
For per-agent A* paths, the unit of invalidation is the cell. A blocked cell can only invalidate paths that were going to travel through it. Nothing else on the map needs to change, so nothing else should be recomputed.
For shared fields — a flow field or a Dijkstra map of the kind used for crowds heading to one destination — the unit is the chunk. Divide the field into fixed regions, mark only the chunk containing the changed cell as dirty along with the neighbors it integrates across, and recompute those. A blocker in the top-left corner does not touch the bottom-right chunk's integration, so leave it alone.
For navigation meshes, the unit is the region. Godot's NavigationServer2D lets you bake regions independently; split the level so a runtime obstacle only forces a re-bake of the region it sits in, and prefer NavigationObstacle2D for things that move rather than re-baking at all. In every case the rule is the same: the recompute is bounded by the change, not by the map.
Path cache policy: when to reuse, when to throw away
Per-agent invalidation needs one cheap piece of bookkeeping: each agent remembers which cells its current path occupies. When a cell is invalidated, you only flag the agents whose route actually crossed it. Everyone else keeps the path they already have, because for them nothing changed.
This is the cache policy stated plainly: reuse a cached path until one of its own cells is invalidated, then throw that one path away and recompute it — ideally not in the triggering frame, but through the same budgeted queue you would use for any batch of requests, so a barricade dropping in front of a crowd spreads its repaths across a few frames instead of paying for all of them at once.
The bookkeeping is not free; storing path cells costs memory and an agent can briefly follow a stale route in the window between a change and its repath. Both are usually fine. A unit that reacts to a new wall one or two frames late is invisible. A dropped frame is not.
# Only the agents whose path crossed the changed cell repath.
func _invalidate_paths_through(cell: Vector2i) -> void:
for agent in agents:
if agent.path_cells.has(cell):
agent.needs_repath = true # queued, not solved inline
func set_blocker(cell: Vector2i, blocked: bool) -> void:
astar_grid.set_point_solid(cell, blocked)
_invalidate_paths_through(cell) Tower defense: validate the route before you commit the blocker
Maze-style tower defense has a rule the pathfinding must enforce: a placement can slow the route but must never seal it. The cheapest way to check is to make the change, ask for a path, and revert if the answer is empty. Because the toggle is O(1) and a single query is cheap, you can do this speculatively before the placement is ever committed to the game state.
This turns "why did my creeps walk through the wall" and "why can nobody reach the exit" into a placement that is simply rejected with a clear reason. It is the same idea as validating input before you trust it, applied to geometry.
# Reject a placement that would fully seal the route.
func can_place_blocker(cell: Vector2i) -> bool:
astar_grid.set_point_solid(cell, true)
var path := astar_grid.get_id_path(spawn_cell, goal_cell)
if path.is_empty():
astar_grid.set_point_solid(cell, false) # revert; would seal the maze
return false
return true Tactical units as temporary blockers
It is tempting to bake other units into the grid so agents path around each other. Do not bake moving things into static occupancy. A unit that is solid this frame and gone the next produces a stream of invalidations and repaths that is far more expensive than the crowding it was meant to prevent.
Moving obstacles belong in a soft cost layer, not the solid grid. Raise the weight of the cells a unit occupies so paths prefer to flow around a crowd, and reset it when the unit leaves, instead of toggling solidity and re-running the whole invalidation machinery. Reserve hard blocking for things that are genuinely static for a while: walls, closed gates, placed towers. Anything that crosses several cells per second, especially a large one, also runs into the clearance problems that come with agent size, which is another reason to keep it out of the static grid.
# Moving units bias the route through cost, not hard blocking.
const CROWD_PENALTY := 4.0
func occupy(cell: Vector2i) -> void:
astar_grid.set_point_weight_scale(cell, CROWD_PENALTY)
func vacate(cell: Vector2i) -> void:
astar_grid.set_point_weight_scale(cell, 1.0) A debug overlay that explains the change
The hardest part of dynamic navigation is not making it fast. It is answering "what just happened" when a unit takes a strange route or freezes in place. A result is not an explanation, and an empty path array tells you nothing about why it was empty.
Build the overlay that does. Draw the cells invalidated this frame, the agents that were flagged for a repath, and — when a query returns nothing — the reason: the goal cell is solid, the start is enclosed, or a placement sealed the only corridor. Most "the pathfinding is broken" reports are really "the pathfinding correctly refused an impossible request and I could not see why." An overlay that names the cause turns a debugging session into a glance.
When the Godot built-ins are enough
None of this is mandatory. If your map is small, your agents are few, and occupancy changes rarely, then set_point_solid followed by a plain repath is the correct amount of engineering. Local invalidation, path-cell bookkeeping, and chunked fields are machinery you build when measurement shows the global recompute is actually costing you frames — not before. This is the same boundary that decides where AStarGrid2D stops being enough in the first place.
The honest version of this article is short: a blocker is a one-cell change, so recompute one cell's worth of consequences. Everything above is just the bookkeeping that keeps that promise as the map, the crowd, and the rate of change grow.