doc 6 of 6

Part 05 — paths that look like decisions

You will build: A repath policy on a staggered timer with a moved-far-enough test, a string-pulled path with the waypoints that changed nothing removed, a crate you can drop that agents route around, and a defined behaviour for an enemy that has no path to you at all.

The follower from part 04 works. It crosses the mesh, it stops without vibrating, it slides past its twin. It also gets exactly one path, at the moment you set target_position, and then commits to it forever. Move the player two rooms over and the enemy runs the old route with total confidence.

The fix is obvious and it is a trap.

The trap: repath every frame#

The reasoning is airtight. A stale path is the problem. A path computed this frame cannot be stale. So compute it this frame, every frame.

Open the chaser from part 04 and put the target assignment in _physics_process:

func _physics_process(delta: float) -> void:
	_agent.target_position = _target.global_position
	if _agent.is_navigation_finished():
		return
	var next_point: Vector2 = _agent.get_next_path_position()
	velocity = global_position.direction_to(next_point) * speed
	move_and_slide()

Run it with one enemy. It is the best chase you have written. The enemy reacts the instant you change direction, it never commits to a dead route, it feels like it is watching you.

Now duplicate the enemy until there are twenty of them.

Two things break, and the order matters. The frame time collapse is the one you expect. The other one arrives first, and it arrives with a single enemy on screen if you stand in the right place.

Walk sideways past a pillar until you are almost exactly opposite the enemy — the point where going left around the pillar and going right around it cost nearly the same. The enemy shivers. It does not pick. It vibrates in place, sometimes for as long as you stand still.

Two separate causes, both worth understanding, because both survive into the fixed version if you do not name them:

Near-ties flip. Every frame the search runs again on a target that moved a few pixels. When two routes are within a rounding error of each other, a few pixels is enough to swap which one wins. The agent gets a path going left, moves two pixels, gets a path going right, moves two pixels back. Nothing is broken. The search is answering a slightly different question sixty times a second and the answers genuinely disagree.

The first waypoint can be behind you. A fresh path starts from where the agent is standing right now, and the first waypoint it produces is often slightly behind the agent's current heading — the corner it already rounded. The agent steers back toward it, which moves it backward, which produces a new path whose first waypoint is now ahead. Back and forth.

Sit with this for a second, because it is the real lesson of this part: the every-frame version is worse than part 01's naive steering, on the exact metric you adopted pathfinding to fix. Steering at least moved in a straight line. This thing has a route and cannot walk it.

Measure before you take advice#

The internet will tell you to repath every 0.2 seconds, or 0.5, or on a distance threshold. All of those numbers were correct for somebody else's level.

Get your own number first. Put this on any node in your scene and call it once:

extends Node2D

@export var query_target: Node2D = null
@export var sample_count: int = 200

func measure_query_cost() -> void:
	if query_target == null:
		push_warning("measure_query_cost needs a query_target")
		return
	var map: RID = get_world_2d().get_navigation_map()
	var origin: Vector2 = global_position
	var destination: Vector2 = query_target.global_position
	var path: PackedVector2Array = PackedVector2Array()

	var started_usec: int = Time.get_ticks_usec()
	for _i in sample_count:
		path = NavigationServer2D.map_get_path(map, origin, destination, true)
	var elapsed_usec: int = Time.get_ticks_usec() - started_usec

	var per_query_usec: float = float(elapsed_usec) / float(sample_count)
	print("path points: %d | per query: %.1f us" % [path.size(), per_query_usec])

The loop runs the query many times because a single query is too fast and too noisy to time on its own. Time.get_ticks_usec() has microsecond resolution, and one query can land inside a couple of ticks of it.

Run it from your worst case — the longest route in your level, corner to opposite corner, through the most complicated geometry you have. Not the friendly case.

Then do the arithmetic by hand, once. A frame at 60fps is 16,666 microseconds. That is the whole budget: physics, rendering, your gameplay code, everything. Say your query came back at 180 µs. Twenty enemies repathing every frame is 20 × 180 = 3,600 µs, or 21% of the entire frame spent on pathfinding alone. Say it came back at 900 µs on a big level. Twenty enemies is 18,000 µs and you are already over budget before a single sprite is drawn.

That number, from your level, ends the repath-interval argument better than any recommendation. It also tells you which fix you need. If your worst case is 40 µs you have room to be sloppy. If it is 900 µs you need the budget queue at the end of this part.

For the grid side, time get_point_path the same way. Grid cost tracks cell count, so the number you want is from the largest open region in your map, where the search has the most cells to expand into.

The policy: interval, threshold, offset#

Three parts, each fixing a different failure.

class_name Chaser
extends CharacterBody2D

@export var speed: float = 120.0
@export var repath_interval: float = 0.3
@export var repath_move_threshold: float = 24.0

@onready var _agent: NavigationAgent2D = $NavigationAgent2D

var _target: Node2D = null
var _last_queried_target: Vector2 = Vector2.INF
var _repath_timer: Timer = null

func _ready() -> void:
	_target = get_tree().get_first_node_in_group("player")

	_repath_timer = Timer.new()
	_repath_timer.wait_time = repath_interval
	_repath_timer.one_shot = false
	_repath_timer.timeout.connect(_on_repath_timer_timeout)
	add_child(_repath_timer)

	await get_tree().create_timer(randf_range(0.0, repath_interval)).timeout
	_repath_timer.start()

func _on_repath_timer_timeout() -> void:
	if _target == null:
		return
	if NavigationServer2D.map_get_iteration_id(_agent.get_navigation_map()) == 0:
		return

	var target_position: Vector2 = _target.global_position
	var moved: float = target_position.distance_squared_to(_last_queried_target)
	if moved < repath_move_threshold * repath_move_threshold:
		return

	_last_queried_target = target_position
	_agent.target_position = target_position

func _physics_process(_delta: float) -> void:
	if _agent.is_navigation_finished():
		return
	var next_point: Vector2 = _agent.get_next_path_position()
	velocity = global_position.direction_to(next_point) * speed
	move_and_slide()

Read what each piece is actually for.

The interval caps how often the search runs. At 0.3 seconds a single agent costs you three or four queries per second instead of sixty. That is the frame-time fix, and it is a factor of twenty.

The threshold stops the timer from wasting queries on a target that has not gone anywhere. A player standing still produces zero path queries with this test and 3.3 per second without it. distance_squared_to avoids a square root — compare against the threshold squared and the comparison is identical. _last_queried_target starts at Vector2.INF so the first check always fails and the first tick always queries.

The offset is the await in _ready. Without it, twenty enemies spawned in the same frame all tick on the same frame forever, and your 3,600 µs of work arrives as a spike every 0.3 seconds instead of spread across eighteen frames. Players do not feel an average. They feel a hitch. A random delay before the first start() scatters them permanently, because a repeating Timer keeps whatever phase it began with.

Do not do the offset by passing the delay to start(). That argument overwrites wait_time, so an agent that started with a 0.07-second offset repaths every 0.07 seconds for the rest of its life — the exact opposite of what you were reaching for. A one-shot scene-tree timer before the first start() leaves wait_time alone.

Name the cost out loud#

Slowing the repath fixes both symptoms at once. Frame time drops by the factor you chose, and the vibration disappears, because a path is now given a third of a second to be walked before anything is allowed to disagree with it.

And it makes staleness visible for the first time. That is the trade, and it is not free:

  • Longer interval — cheaper, and the enemy runs confidently toward where you used to be. At 0.5 seconds against a fast player, you will watch an enemy commit hard to a doorway you left half a second ago. Some games want that; it reads as an enemy that was fooled.
  • Shorter interval — more responsive, more CPU, and below roughly 0.1 seconds the near-tie flip starts creeping back.

Tune it by walking a tight circle around a pillar and watching the enemy. Too long and it visibly lags a beat behind your turns. Too short and it shivers at the tie point. The window between those two is wider than you expect, which is why the exact number matters less than having one.

The threshold has its own trade. Too large and a player making small repeated adjustments never triggers a repath, so the enemy follows a route that is 20 pixels wrong until the timer's own patience runs out. Set it below the width of a doorway. A target that moved less than a door is still behind the same door, and the existing path still gets you there.

Why the path still looks robotic#

The chase is now cheap and stable. It still does not look like something made a decision. The reason depends entirely on which system produced the path, and the two fixes are not interchangeable.

Grid paths are stair-steps. An AStarGrid2D returns cell centres, and its edges only run at 45 and 90 degrees, because those are the only edges the graph has. There is no 22-degree move to find. The search is not being crude; you handed it a graph with eight directions and it used them. A diagonal corridor comes back as a staircase, and an agent walking that staircase looks like it is following instructions rather than going somewhere.

Mesh paths hug walls. map_get_path with optimize set to true already anchors the route to polygon corners, so it does not stair-step. It runs the shortest legal line, which means it shaves every corner as tightly as the geometry permits. The route is correct and the agent grazes each doorframe.

Different problems. Straightening a mesh path further does almost nothing, because the funnel already did that work. Adding steering smoothing to a grid path hides the staircase without shortening it. Know which one you have before you reach for a fix.

Fix one: string-pulling#

The technique is a rubber band. Take waypoint A, look at waypoint C. If a clear line runs from A to C, then B changed nothing — the agent was going to pass through that neighbourhood anyway — so drop it. Advance until the line breaks, keep the last waypoint that worked, start again from there.

class_name PathSmoother
extends Node2D

@export var agent_radius: float = 8.0
@export var wall_mask: int = 1

var _probe_shape: CircleShape2D = null
var _probe_params: PhysicsShapeQueryParameters2D = null

func _ready() -> void:
	_probe_shape = CircleShape2D.new()
	_probe_shape.radius = agent_radius
	_probe_params = PhysicsShapeQueryParameters2D.new()
	_probe_params.shape = _probe_shape
	_probe_params.collision_mask = wall_mask

func string_pull(points: PackedVector2Array) -> PackedVector2Array:
	if points.size() <= 2:
		return points

	var pulled: PackedVector2Array = PackedVector2Array()
	pulled.append(points[0])

	var anchor: int = 0
	var probe: int = 2
	while probe < points.size():
		if _line_is_clear(points[anchor], points[probe]):
			probe += 1
		else:
			anchor = probe - 1
			pulled.append(points[anchor])
			probe = anchor + 2

	pulled.append(points[points.size() - 1])
	return pulled

func _line_is_clear(from: Vector2, to: Vector2) -> bool:
	var space: PhysicsDirectSpaceState2D = get_world_2d().direct_space_state
	_probe_params.transform = Transform2D(0.0, from)
	_probe_params.motion = to - from
	var result: PackedFloat32Array = space.cast_motion(_probe_params)
	return result[0] >= 1.0

cast_motion sweeps the shape along the motion vector and hands back two floats: the safe proportion of the motion first, the unsafe proportion second. Both are 1.0 when the sweep hit nothing, so result[0] >= 1.0 reads as "the whole line was clear". Anything less is the fraction of the way the shape travelled before something stopped it, and the waypoint stays in the path.

Three things about this code are load-bearing.

It sweeps a circle, not a ray. This is the part that goes wrong. A RayCast2D between two waypoints tests an infinitely thin line, and your agent is not infinitely thin. The ray clears the corner, the agent's collision shape does not, and the smoothed path pushes the agent into the wall it was routed around. Sweep a shape the size of the agent — same radius, same collision mask, same layers — or the smoother produces routes the agent cannot physically walk. If you are on a grid built from a TileMapLayer, note that the grid's solid cells and the physics collision shapes are two different sources of truth. String-pulling tests physics. If those two disagree anywhere in your level, this is where you find out.

The shape and query parameters are built once. Allocating a CircleShape2D per test would put dozens of allocations inside a call you make on every repath. Building them in _ready and mutating transform and motion costs nothing.

It is not free. A path with 30 waypoints costs somewhere between 30 and 100 shape sweeps to pull. That is real work — sometimes comparable to the search itself. Measure it with the same Time.get_ticks_usec() block. What you get back is a shorter route with fewer waypoints, which also makes every subsequent frame's steering cheaper and stops the agent from decelerating into corners it does not need to visit.

Call it once, where the path arrives — not in _physics_process. On the grid side that is directly after get_point_path. Smoothing the same unchanged path sixty times a second is the every-frame trap wearing a different hat.

String-pulling is mostly a grid technique. A mesh query with optimize set to true already funnels the route through polygon corners, which is the same idea implemented inside the navigation server with the mesh's own connectivity data. Running your own string pull over a funnelled path will find a few waypoints to drop and will mostly spend physics queries confirming that the server was right.

Fix two: steering smoothing#

Different tool, different job. String-pulling changes the route. Steering smoothing changes only how the agent moves along it.

@export var speed: float = 120.0
@export var turn_sharpness: float = 8.0

func _physics_process(delta: float) -> void:
	if _agent.is_navigation_finished():
		return
	var next_point: Vector2 = _agent.get_next_path_position()
	var desired: Vector2 = global_position.direction_to(next_point) * speed
	velocity = velocity.lerp(desired, 1.0 - exp(-turn_sharpness * delta))
	move_and_slide()

Instead of snapping the velocity to point at the next waypoint, the agent turns toward it over a few frames. The corners round off. The staircase becomes a curve. The route is byte-for-byte identical.

The 1.0 - exp(-sharpness * delta) form is there so the smoothing behaves the same at 60fps and 144fps. A bare lerp(desired, 0.2) blends a fixed fraction per frame, so the turn rate silently changes with your physics tick rate. Higher turn_sharpness means a tighter turn; around 8 is a noticeable ease, around 20 is barely visible.

It costs nothing and it does not fix a bad route. An agent that turns beautifully while walking a staircase is still walking a staircase. Use both on a grid; use this one alone on a mesh.

One interaction to watch: smoothing widens the agent's turning arc, so it can now overshoot a waypoint it used to hit exactly. If corners start producing loops, that is path_desired_distance from part 04 being too small for the new turning radius, not a bug in the smoothing.

Dynamic obstacles: ask one question first#

The player drops a crate. A barrel rolls across the corridor. Another enemy is standing in the doorway. These look like one problem — "something is in the way that was not there at bake time" — and they are two, split by a single question:

Does the thing stop moving?

If it settles, it becomes part of the world. Change the world data, then requery. This is pathfinding.

If it never settles, changing the world data for it is pointless, because your data would be wrong again by the time the path came back. This is avoidance, which works on velocity and never touches the path.

Get this wrong in either direction and the symptom is confusing. Rebake for a rolling barrel and your frame time dies for nothing. Rely on avoidance for a crate wedged in a doorway and agents walk into it forever, nudging sideways, because avoidance only pushes them around obstacles that are near their route — it cannot route them through a different door.

The crate that settles#

Grid side first, because it is the cheap one:

class_name GridWorld
extends Node2D

signal navigation_changed()

@export var tile_layer: TileMapLayer = null

var _astar: AStarGrid2D = AStarGrid2D.new()

func block_cell_at(world_position: Vector2) -> void:
	var cell: Vector2i = tile_layer.local_to_map(tile_layer.to_local(world_position))
	if not _astar.is_in_boundsv(cell):
		return
	if _astar.is_point_solid(cell):
		return
	_astar.set_point_solid(cell, true)
	navigation_changed.emit()

func clear_cell_at(world_position: Vector2) -> void:
	var cell: Vector2i = tile_layer.local_to_map(tile_layer.to_local(world_position))
	if not _astar.is_in_boundsv(cell):
		return
	_astar.set_point_solid(cell, false)
	navigation_changed.emit()

Toggling set_point_solid at runtime and requerying is genuinely cheap. There is no rebuild, no bake, no thread. The flag flips and the next search sees it.

Two details that bite. set_point_solid(cell) with no second argument sets the cell solid, because the parameter defaults to true — pass false explicitly to clear it, or your unblock function is a second block function. And an out-of-region id sets nothing and pushes an error — Point (x, y) out of bounds and the region it was compared against — which is why is_in_boundsv guards both calls. A crate dropped one tile outside your region gives you no obstacle and one error line naming the cell that was refused.

This also sharpens part 02's ordering rule into something you can act on. update() is for changes to region, cell_size or offset. It rebuilds the point set, and rebuilding the point set clears every solid flag you have set. So update() is not a "refresh the grid" call you sprinkle around after edits — call it in a running level and every wall you marked evaporates. Runtime obstacle edits need set_point_solid and nothing else.

Mesh side, the crate needs the polygon rebuilt:

class_name MeshWorld
extends Node2D

signal navigation_changed()

@export var region: NavigationRegion2D = null

var _rebake_queued: bool = false

func _ready() -> void:
	region.bake_finished.connect(_on_bake_finished)

func request_rebake() -> void:
	if _rebake_queued:
		return
	_rebake_queued = true
	region.bake_navigation_polygon()

func _on_bake_finished() -> void:
	_rebake_queued = false
	navigation_changed.emit()

If your crate scene has a StaticBody2D with a collision shape, and your NavigationPolygon parses static colliders, the rebake picks it up with no extra setup. If the crate has no collider you want parsed, that is what NavigationObstacle2D with affect_navigation_mesh and carve_navigation_mesh is for — see the next section, because the defaults will surprise you.

bake_navigation_polygon() bakes on a thread by default, so the polygon is not ready when the call returns. Querying immediately gets you the old mesh and no error. Wait for bake_finished. The _rebake_queued guard exists because a player who drops five crates in one second would otherwise start five bakes over the top of each other.

Rebaking is expensive — orders of magnitude past a set_point_solid flip. That is the grid-versus-mesh trade from the overview showing up in gameplay rather than in theory: if your world changes constantly at runtime and it is tile-shaped, the grid is not merely adequate, it is the better tool.

Both worlds emit navigation_changed. Agents listen and drop their assumption:

func _on_navigation_changed() -> void:
	_last_queried_target = Vector2.INF

That is all it takes. The next timer tick sees an impossible distance, fails the moved-far-enough test, and queries fresh. The agent keeps walking its old path for up to one interval, which is the same staleness you already priced. If a whole level's worth of agents needs telling, this is a good candidate for /systems/event-bus/01-building-the-bus rather than direct connections from every enemy to the level.

The node whose name lies#

NavigationObstacle2D does not block pathfinding.

Read that again if you have been assuming otherwise, because the name plants the assumption and the engine never corrects it. An obstacle affects avoidance velocity only. Paths route straight through it. Agents with avoidance_enabled swerve around it at the last moment; agents without avoidance walk through the space as if it were empty, and even the swerving ones are still following a route that was computed as though it were not there.

Both opt-ins — affect_navigation_mesh and carve_navigation_mesh — default to false. Until you set them and rebake, the obstacle is a velocity hint and nothing more.

This produces one of the most disorienting bugs in the whole system: you place an obstacle, you see agents nudge around it, you conclude it works, and then agents start walking through it whenever they approach from an angle where the swerve is not triggered. The behaviour is not intermittent. It was never blocking. You were watching avoidance the whole time.

The obstacle also needs a navigation map and outline vertices to function correctly. An obstacle with default radius of 0 and no vertices is doing nothing at all.

The barrel that never settles#

For something that keeps moving, do not touch the mesh:

class_name RollingBarrel
extends CharacterBody2D

@export var roll_speed: float = 90.0
@export var roll_direction: Vector2 = Vector2.RIGHT

@onready var _obstacle: NavigationObstacle2D = $NavigationObstacle2D

func _physics_process(_delta: float) -> void:
	velocity = roll_direction.normalized() * roll_speed
	move_and_slide()
	_obstacle.velocity = velocity

The obstacle is a child, so it travels with the body. The line that matters is the last one.

An obstacle should not be moved every single frame, because each positional change requires a rebuild of the avoidance map. Feeding the velocity property instead is not a stylistic preference — it changes what the avoidance simulation is allowed to do. With a velocity, agents predict where the barrel will be and start steering early. Without one, they react to where it has already jumped, which produces last-instant sidesteps and, when two agents react to the same jump, the pair of them shuffling into each other.

Give a moving obstacle a radius and a velocity, and leave vertices empty. The vertex outline is the static form. Its position is the thing you must not be changing every frame.

No path at all#

Every project postpones this and every project eventually ships it as a bug report titled "enemy stands still".

The player builds a wall across the only corridor. The player stands on scenery that has no navigation mesh under it. An enemy spawns inside geometry. The search runs, finds nothing, and returns an empty array — which is not a failure. It is the search telling you something true about your level.

Detecting it#

On the grid, get_id_path and get_point_path return an empty array when the target cannot be reached, and code that does path[0] next crashes with an index error. Passing true for allow_partial_path gets you a route to the reachable point closest to the target instead. Guard for empty anyway — a partial path can still come back empty if the agent itself is standing on a solid cell.

On the mesh, the agent has is_target_reachable(), which reads the path it already holds and costs you nothing extra. Its answer refers to the target the agent last computed for, not one you assigned a microsecond ago, so check it on the tick after you set target_position:

func _on_repath_timer_timeout() -> void:
	if _target == null:
		return
	if NavigationServer2D.map_get_iteration_id(_agent.get_navigation_map()) == 0:
		return

	if not _agent.is_target_reachable():
		_on_no_route()
		return

	var target_position: Vector2 = _target.global_position
	if target_position.distance_squared_to(_last_queried_target) < repath_move_threshold * repath_move_threshold:
		return
	_last_queried_target = target_position
	_agent.target_position = target_position


func _on_no_route() -> void:
	# A placeholder until the next section. Replace it with one of the behaviours there.
	push_warning("No route to target.")

If you need the answer about a destination before committing to it — deciding whether an enemy should even enter a chase state — run the query yourself and inspect the result:

## How far short of the point you asked for still counts as arriving there.
@export var reachable_slack: float = 8.0

func has_route_to(destination: Vector2) -> bool:
	var map: RID = get_world_2d().get_navigation_map()
	var path: PackedVector2Array = NavigationServer2D.map_get_path(map, global_position, destination, true)
	if path.is_empty():
		return false
	return path[path.size() - 1].distance_to(destination) <= reachable_slack

An empty path means no route. A non-empty path whose last point is nowhere near where you asked means the search got as close as the geometry permits and stopped — the mesh equivalent of a partial path. reachable_slack is where you draw that line: how far short of the requested point still counts as arriving. A few pixels covers the mesh snapping your destination onto the nearest polygon; set it much larger and "stopped at the wall outside the room" starts passing as success. The trade is honest: this costs a full extra query, so run it at decision points, not on a timer. is_target_reachable() is free because it reuses work the agent already did.

If the target is merely standing off-mesh — on a rock, in a doorway your bake shaved away — NavigationServer2D.map_get_closest_point(map, to_point) snaps their position onto the mesh, and pathing to that snapped point is usually what the player expected anyway.

Deciding what happens#

Detection is the small half. The real question is behavioural, and no API answers it:

  • Give up. Leave the chase state, return to whatever was running before. Honest, cheap, and reads as the enemy losing interest.
  • Return to patrol. The same thing with somewhere to go. Almost always better, because the enemy is visibly doing something.
  • Walk to the last known position. Route to the closest reachable point to the player and wait there. This one reads as intelligence and costs nothing extra — you already have the partial path.
  • Ask for another route. A flying variant, a different navigation layer, a door it can open. Real work, and the payoff is an enemy that appears to know the building.
  • Break the wall. If the player can build it, letting enemies attack it turns your pathfinding failure into a mechanic. Expensive, and it changes the game.

Pick deliberately. What you must not do is nothing, because "no path" plus no behaviour equals an enemy standing perfectly still — and a motionless enemy is the single option players reliably read as broken. They will not think it lost track of them. They will think your game crashed that one guy.

This is a state-machine decision more than a pathfinding one. chase failing to find a route should transition to search or patrol, not set a flag inside chase. If the enemy has no states yet, /systems/state-machine/01-when-you-need-one is the shorter road to that structure.

The budget that outlasts the optimisation#

One last idea, and it survives every specific technique above.

Even with intervals and thresholds, requests arrive in clumps. The player opens a door and forty enemies notice in the same frame. Your careful phase offsets are irrelevant, because this was an event, not a clock. Frame time spikes and the player sees a hitch.

Stop letting agents run their own queries. Let them ask, and serve a fixed number per frame.

extends Node

@export var requests_per_frame: int = 4

var _queue: Array[Dictionary] = []
var _queued: Dictionary = {}

func request(requester: Node, work: Callable) -> void:
	var id: int = requester.get_instance_id()
	if _queued.has(id):
		_queued[id]["work"] = work
		return
	var entry: Dictionary = {"id": id, "requester": requester, "work": work}
	_queued[id] = entry
	_queue.append(entry)

func _physics_process(_delta: float) -> void:
	var served: int = 0
	while served < requests_per_frame and not _queue.is_empty():
		var entry: Dictionary = _queue.pop_front()
		_queued.erase(entry["id"])
		served += 1
		var requester: Node = entry["requester"]
		if not is_instance_valid(requester):
			continue
		var work: Callable = entry["work"]
		work.call()

Register it as an autoload named PathBudget, and change the agent's timer handler to hand over the query instead of running it.

The missing class_name is deliberate. An autoload cannot share a name with a global script class — Godot refuses to add it and tells you the name collides — and the autoload name is already what makes PathBudget.request(...) resolve from any script. Adding class_name PathBudget to this file buys nothing and blocks the registration.

func _on_repath_timer_timeout() -> void:
	if _target == null:
		return
	var target_position: Vector2 = _target.global_position
	if target_position.distance_squared_to(_last_queried_target) < repath_move_threshold * repath_move_threshold:
		return
	_last_queried_target = target_position
	PathBudget.request(self, _run_query.bind(target_position))

func _run_query(target_position: Vector2) -> void:
	if NavigationServer2D.map_get_iteration_id(_agent.get_navigation_map()) == 0:
		return
	_agent.target_position = target_position

The _queued dictionary is the part that makes this work at scale. An agent that requests twice before being served does not get two slots in the queue — its second request overwrites the first. Without that, a busy frame produces a queue longer than the number of agents, and the queue never drains.

The freed-node check matters too. Enemies die between requesting and being served, and calling into a freed object is a crash that only reproduces under load.

Now set requests_per_frame from the number you measured at the top of this part. If a query costs 180 µs and you are willing to spend 1 ms per frame on pathfinding, that is five. Forty agents asking at once get served over ten frames — 160 milliseconds of latency for the last one, on a system where you already accepted 300 milliseconds of staleness.

That is the whole idea, and it is worth more than any single optimisation in this shelf: a slower algorithm with a frame budget beats a faster one without. Players do not experience your average frame time. They experience the worst one. A queue turns a spike into latency, and latency is the thing you have already decided you can live with.

Checkpoint — definition of done#

  • You have printed a real per-query microsecond number from your own worst-case route, and you can say what fraction of a 16.6 ms frame your current enemy count spends on pathfinding.
  • With the every-frame version, you have reproduced the shiver: stood at a near-tie point behind a pillar and watched the enemy fail to pick a side.
  • With the timer version, that shiver is gone and the enemy commits to one side of the pillar.
  • Twenty enemies spawned in the same frame do not all repath on the same frame — the frame time graph shows a raised floor, not a repeating spike.
  • Standing perfectly still produces zero repath queries. Add a print inside the query to confirm, then delete it.
  • You have set repath_interval high enough to see the failure: an enemy running confidently to where you were, arriving, and only then noticing.
  • A grid path drawn on screen visibly loses waypoints after string-pulling, and the agent walking the pulled path does not clip any wall corner.
  • Steering smoothing rounds the agent's turns without changing the drawn path at all.
  • Dropping a crate in a doorway makes agents route through a different door — within one repath interval, not instantly.
  • Removing the crate restores the original route, proving your unblock call passed false.
  • Sealing the player behind a wall produces your chosen no-path behaviour, and the enemy is visibly doing something.
  • With the budget queue in place, forty agents requesting in one frame produce no visible hitch.
  • You can explain: why does recomputing the path every frame make the chase look worse than the naive steering from part 01, even before frame time becomes a problem?

Stretch (no instructions)#

  • Make repath_interval scale with distance from the player, so distant enemies repath rarely and the one in your face repaths often. Watch what it does to your total query count.
  • Give one enemy type an extra navigation layer that includes ledges the others cannot use, so "no route" for a grunt is a route for a climber.
  • Draw the queue depth of PathBudget on screen as a bar and play until you can make it climb.
  • Cache the last N smoothed paths against their start and end cells, and measure whether the lookup is cheaper than the pull.
  • Make the enemy that loses its route say so — a sound, a shrug, a question mark. The behaviour was already correct; find out how much of "it looks smart" was legibility rather than routing.

If you get stuck#

  • Enemy repaths at a strange interval, faster or slower than you set → you phased it with _repath_timer.start(offset). That argument overwrites wait_time, so the offset became the interval. Offset with a separate one-shot timer before calling start().
  • All enemies still spike on the same frame → the await in _ready was skipped by an early return, or the timer's start() runs before the await. The offset only exists if the wait happens before the first start.
  • Enemy never repaths at all_last_queried_target is being written somewhere before the threshold test, so the distance is always zero. It should only be assigned on the line right before you set target_position.
  • First repath never fires_last_queried_target was initialised to Vector2.ZERO instead of Vector2.INF, and the player happens to be within the threshold of the world origin.
  • Enemy freezes for exactly one interval after the world changes → expected. navigation_changed invalidates the cached target; the next tick queries. If the freeze is permanent instead, the agent is holding a path to a point that is now inside the crate — reset target_position in the handler rather than only clearing _last_queried_target.
  • Smoothed path walks the agent into a wall corner_line_is_clear is testing a ray or a shape smaller than the agent. Match the probe radius to the agent's actual collision shape, and check wall_mask matches the layer your walls are on.
  • String-pulling drops every waypoint and returns a straight line through a buildingcollision_mask is 0, or your walls are on a layer the mask excludes. Every sweep reports clear, so every waypoint looks redundant.
  • cast_motion always reports blocked and nothing gets pulledwall_mask includes a layer that is not a wall, so the floor or a trigger volume stops every sweep; or the probe's agent_radius is wider than your corridors, so no legitimate line between two waypoints can ever complete; or a waypoint itself sits inside geometry, which blocks every sweep starting there. Populating _probe_params.exclude is reasonable hygiene, but it is not the cause — a sweep already ignores any shape it starts out overlapping, including the agent's own.
  • Agent orbits corners after adding steering smoothing → the wider turning arc now overshoots waypoints. Raise path_desired_distance or turn_sharpness.
  • Crate blocks nothing on the gridset_point_solid was called with an out-of-region cell. Look in the output panel for Point (x, y) out of bounds — the message names the cell it refused. Check is_in_boundsv is returning true, and that you passed to_local(global_position) to local_to_map rather than the global position.
  • Removing the crate leaves the cell blockedset_point_solid(cell) with no second argument. The parameter defaults to true. Pass false.
  • All your walls vanish after a runtime change → you called update(). It rebuilds the point set and clears every solid flag. It is only for region, cell_size or offset changes, and re-marking every wall afterward is mandatory.
  • Crate blocks nothing on the mesh → either you queried before bake_finished fired, or the crate's collider is not on a layer that parsed_collision_mask includes, or you expected a NavigationObstacle2D to block pathfinding. It does not, until affect_navigation_mesh and carve_navigation_mesh are both set and the region is rebaked.
  • Frame time collapses whenever the barrel is moving → the barrel's obstacle has vertices set and its position changes every frame, rebuilding the avoidance map each time. Clear the vertices, give it a radius, and feed velocity.
  • Enemy stands still and nothing in the console mentions it → the no-path case, arriving unhandled. Print inside the empty-path branch to confirm, then give it a behaviour.
  • Crash on path[0] after a level change → an unreachable target returned an empty array. Guard is_empty() even when you passed allow_partial_path.
  • Budget queue grows forever and agents stop updating → the dedupe dictionary is not being erased when an entry is served, or agents are requesting every frame instead of on their timer.
  • Random crash under heavy combat with the budget queue → a queued requester was freed before being served. That is the is_instance_valid check earning its place.

Next: /farming_game/ — the curriculum this shelf was written alongside. Its villagers cross a tile world on a grid the gameplay code already thinks in, with fences and buildings the player relocates at runtime, so parts 02 and 05 stop being exercises and become the thing you maintain. Read it when you want the whole apparatus running inside a game that has other problems too.