<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Vav-Labs]]></title><description><![CDATA[Vav-Labs]]></description><link>https://vav-labs.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Vav-Labs</title><link>https://vav-labs.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 10 Sep 2026 23:02:23 GMT</lastBuildDate><atom:link href="https://vav-labs.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[3D Pathfinding in Godot 4: NavigationAgent3D, the Three Radii, and When You Actually Need AStar3D]]></title><description><![CDATA[Originally published on vav-labs.com. Everything below is Godot 4.7.1 stable, and there's a runnable MIT-licensed project with a verification receipt at the end.

You add a NavigationRegion3D, bake a ]]></description><link>https://vav-labs.hashnode.dev/3d-pathfinding-in-godot-4-navigationagent3d-the-three-radii-and-when-you-actually-need-astar3d</link><guid isPermaLink="true">https://vav-labs.hashnode.dev/3d-pathfinding-in-godot-4-navigationagent3d-the-three-radii-and-when-you-actually-need-astar3d</guid><category><![CDATA[pathfinding]]></category><category><![CDATA[NavigationRegion3D]]></category><category><![CDATA[Godot]]></category><dc:creator><![CDATA[Vav Labs]]></dc:creator><pubDate>Fri, 07 Aug 2026 08:21:33 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a3c21de61a47ab305219bba/4dfadcd8-a79a-42d3-ba85-2bee0356569a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>Originally published on <a href="https://vav-labs.com/blog/3d-pathfinding-in-godot/">vav-labs.com</a>. Everything below is Godot 4.7.1 stable, and there's a runnable MIT-licensed project with a verification receipt at the end.</p>
</blockquote>
<p>You add a <code>NavigationRegion3D</code>, bake a mesh, drop a <code>NavigationAgent3D</code> on your character, set <code>target_position</code>, hit Play, and the character stands there doing nothing at all.</p>
<p>That's the normal first run. All of it is documented, and people still lose an evening to it (me included). The reason is that 3D navigation in Godot is really four separate systems that have to agree with each other: the baked mesh, the physics body, the agent, and your own movement code. Any one of them can be wrong on its own terms while looking fine in the inspector.</p>
<p>So here's the whole contract in one place, in the order you'd actually build it.</p>
<h2>Navmesh or AStar3D? Decide by movement space</h2>
<p>Before any setup, pick the representation. This is the choice people get wrong most often, and it's not really about 2D versus 3D.</p>
<table>
<thead>
<tr>
<th>Your movement space</th>
<th>Use</th>
</tr>
</thead>
<tbody><tr>
<td>Grounded movement over authored 3D geometry</td>
<td><code>NavigationMesh</code> + <code>NavigationAgent3D</code></td>
</tr>
<tr>
<td>Voxels, stacked tiles, waypoints, other predefined positions</td>
<td>An explicit <code>AStar3D</code> point graph</td>
</tr>
<tr>
<td>Fully volumetric flight or swimming</td>
<td>An explicit 3D graph or something purpose-built</td>
</tr>
</tbody></table>
<p>A navmesh describes continuous walkable space. One polygon can cover a whole floor, and the bake follows your authored geometry instead of chopping the level into cubes. That's the ordinary answer for a character walking through a scene.</p>
<p><code>AStar3D</code> describes points and connections, and it's the better fit when the positions themselves are the rules: a voxel that can be occupied, a flying unit hopping between lattice nodes, a turn-based actor paying for one exact step. The catch is that <code>AStar3D</code> won't inspect a <code>GridMap</code> and discover the graph for you. You add every point, connect every legal pair, and keep the thing in sync when the world changes.</p>
<p>This post builds the first case.</p>
<h2>The scene, and the order to build it</h2>
<p>Nothing exotic. Everything here is authored before Play.</p>
<pre><code class="language-text">Main (Node3D)
├── NavigationRegion3D
│   └── LevelGeometry (floor, ramp, platform, walls)
├── Player (CharacterBody3D)
│   ├── CollisionShape3D
│   ├── MeshInstance3D
│   └── NavigationAgent3D
├── MovingObstacle (AnimatableBody3D)
│   └── NavigationObstacle3D
├── TargetMarker
├── PathLine
└── Camera3D
</code></pre>
<p>Build it in this order and you get a working result at every step, which makes it obvious where things broke:</p>
<ol>
<li><p><code>NavigationRegion3D</code> with the level geometry under it. The floor, ramp, platform and walls are visible in the editor.</p>
</li>
<li><p>A <code>NavigationMesh</code> resource on the region, baked and saved. You now have a walkable surface.</p>
</li>
<li><p><code>CharacterBody3D</code> with a <code>NavigationAgent3D</code> child. A grounded body can follow one target across the ramp.</p>
</li>
<li><p>Physics-step mouse raycast. Left click produces a world-space target.</p>
</li>
<li><p>Closest-point clamping and a path line. The target snaps to the navmesh and the route is visible.</p>
</li>
<li><p>Optional avoidance obstacle. Local steering reacts to motion without touching the global path.</p>
</li>
</ol>
<h2>Baking the NavigationMesh</h2>
<p>Put the walkable geometry under <code>NavigationRegion3D</code>, create a <code>NavigationMesh</code> on the region, bake. Turn on the navigation debug view while you're adjusting things — the colored overlay should connect the lower floor to the upper platform and keep visible clearance around dividers.</p>
<table>
<thead>
<tr>
<th>NavigationMesh property</th>
<th>Starter value</th>
<th>What it changes</th>
</tr>
</thead>
<tbody><tr>
<td><code>agent_radius</code></td>
<td><code>0.50</code></td>
<td>Erodes clearance around walls and ledges</td>
</tr>
<tr>
<td><code>agent_height</code></td>
<td><code>2.0</code></td>
<td>Minimum vertical space the body needs</td>
</tr>
<tr>
<td><code>agent_max_climb</code></td>
<td><code>0.50</code></td>
<td>Joins surfaces across small height changes</td>
</tr>
<tr>
<td><code>agent_max_slope</code></td>
<td><code>35.0</code></td>
<td>Keeps the 20-degree ramp walkable</td>
</tr>
<tr>
<td><code>cell_size</code></td>
<td><code>0.25</code></td>
<td>Horizontal bake resolution</td>
</tr>
<tr>
<td><code>cell_height</code></td>
<td><code>0.25</code></td>
<td>Vertical bake resolution</td>
</tr>
</tbody></table>
<p>Two things that bite here. <code>cell_size</code> and <code>cell_height</code> have to match the navigation map, and Godot rounds the bake radius <strong>up</strong> to a multiple of <code>cell_size</code>. So a coarse cell size can quietly erode more space than the radius field suggests, and your doorway disappears for reasons the inspector never shows you.</p>
<h2>Keep the three radii separate</h2>
<p>This is the one I'd put on a sticky note. Three different settings all read like "the agent radius" during a quick inspector pass, and they don't do the same job.</p>
<table>
<thead>
<tr>
<th>Setting</th>
<th>Starter value</th>
<th>Owner</th>
</tr>
</thead>
<tbody><tr>
<td><code>CapsuleShape3D.radius</code></td>
<td><code>0.48</code></td>
<td>Physics collision</td>
</tr>
<tr>
<td><code>NavigationMesh.agent_radius</code></td>
<td><code>0.50</code></td>
<td>Bake-time wall and ledge clearance</td>
</tr>
<tr>
<td><code>NavigationAgent3D.radius</code></td>
<td><code>0.55</code></td>
<td>Local avoidance only</td>
</tr>
</tbody></table>
<p>The bake radius should cover the physical body plus a small margin. The avoidance radius can run a little wider, because it controls preferred spacing between agents, not whether a doorway is legal.</p>
<p>Get the relationship backwards and you get two distinct bugs. Bake for a body smaller than the collision capsule and the path will happily route around a corner that physics won't let you take. Bake too large and narrow doors vanish from the mesh entirely. When something looks wrong at a doorway, debug the baked surface and the collision shape together, not one at a time.</p>
<h2>Wait until the navigation map is usable</h2>
<p>The empty-first-path case is documented: the map hasn't synchronized the region yet. For a small scene, deferring setup and waiting one physics frame is the normal baseline.</p>
<p>The starter is stricter than that, because it assigns an automatic target the moment the scene is ready. It waits for a map iteration, a registered region, and a closest point near the authored spawn:</p>
<pre><code class="language-gdscript">func _ready() -&gt; void:
	_spawn_transform = global_transform
	floor_snap_length = 0.45
	floor_max_angle = deg_to_rad(45.0)
	agent.velocity_computed.connect(_on_velocity_computed)
	_finish_navigation_setup.call_deferred()


func _finish_navigation_setup() -&gt; void:
	while true:
		var map_rid := agent.get_navigation_map()
		var map_has_data := NavigationServer3D.map_get_iteration_id(map_rid) &gt; 0 \
			and not NavigationServer3D.map_get_regions(map_rid).is_empty()
		var closest_point := NavigationServer3D.map_get_closest_point(
			map_rid, global_position) if map_has_data else Vector3.ZERO
		if map_has_data and closest_point.distance_to(global_position) &lt; 2.0:
			break
		await get_tree().physics_frame
	_navigation_is_ready = true
	navigation_ready.emit()
</code></pre>
<p>Don't copy the two-metre check into a reusable library. It belongs to this authored spawn point. In your scene, pick a readiness assertion that matches where your character is actually supposed to start.</p>
<h2>Raycast the click during the physics step</h2>
<p>Mouse input arrives outside the physics callback, but <code>direct_space_state</code> wants to be queried during physics processing. Store the screen position, consume it in <code>_physics_process()</code>:</p>
<pre><code class="language-gdscript">func _unhandled_input(event: InputEvent) -&gt; void:
	if event is InputEventMouseButton \
			and event.button_index == MOUSE_BUTTON_LEFT \
			and event.pressed:
		_pending_click = event.position
		get_viewport().set_input_as_handled()


func _physics_process(_delta: float) -&gt; void:
	if _pending_click == null:
		return
	var click_position: Vector2 = _pending_click
	_pending_click = null
	_raycast_target(click_position)


func _raycast_target(screen_position: Vector2) -&gt; void:
	var ray_origin := camera.project_ray_origin(screen_position)
	var ray_end := ray_origin + camera.project_ray_normal(screen_position) * 200.0
	var query := PhysicsRayQueryParameters3D.create(ray_origin, ray_end, 1)
	query.exclude = [player.get_rid()]
	var hit := get_world_3d().direct_space_state.intersect_ray(query)
	if hit.is_empty():
		_set_status("No level surface under that click.")
		return
	_set_target(hit.position)
</code></pre>
<h2>A physics hit is not a navigation target</h2>
<p>A click can land on the side of a wall, on geometry outside the baked region, or next to a disconnected surface. Normalize it explicitly:</p>
<pre><code class="language-gdscript">func set_navigation_target(requested_position: Vector3) -&gt; Vector3:
	if not _navigation_is_ready:
		return global_position

	var reachable_target := NavigationServer3D.map_get_closest_point(
		agent.get_navigation_map(), requested_position)
	agent.target_position = reachable_target
	_has_target = true
	_last_path = PackedVector3Array()
	return reachable_target
</code></pre>
<p>Keep both values around if your UI ever needs to explain a correction — the starter displays the requested world position next to the clamped navmesh target. When a character stops short of where you clicked, <code>get_final_position()</code> gives you the reachable end of the current path and <code>is_target_reachable()</code> tells you what the agent thinks of the request.</p>
<h2>NavigationAgent3D does not move your character</h2>
<p>It computes path information. That's it. Your controller asks for one next point per physics frame, flattens the steering onto the ground plane, applies acceleration, and calls <code>move_and_slide()</code>:</p>
<pre><code class="language-gdscript">func _physics_process(delta: float) -&gt; void:
	if not is_on_floor():
		velocity.y -= _gravity * delta
	elif velocity.y &lt; 0.0:
		velocity.y = -0.1

	var desired_velocity := Vector3.ZERO
	if _navigation_is_ready and _has_target:
		if agent.is_navigation_finished():
			_has_target = false
		else:
			var next_path_position := agent.get_next_path_position()
			var direction := global_position.direction_to(next_path_position)
			direction.y = 0.0
			if direction.length_squared() &gt; 0.0001:
				direction = direction.normalized()
				desired_velocity = direction * move_speed
			_emit_path_if_changed()

	var current_horizontal := Vector3(velocity.x, 0.0, velocity.z)
	desired_velocity = current_horizontal.move_toward(
		desired_velocity, acceleration * delta)

	if agent.avoidance_enabled and _navigation_is_ready:
		agent.velocity = desired_velocity
	else:
		_on_velocity_computed(desired_velocity)
</code></pre>
<p>Keep <code>get_next_path_position()</code> in the physics loop. Calling it from signals like <code>waypoint_reached</code> can retrigger path updates and recurse on you.</p>
<h3>The ramp that ate an evening</h3>
<p>The first version of this starter had a route that was completely valid and completely unwalkable. The navmesh connected the floor to the upper platform, the path line drew a clean cyan arc up the ramp, and the capsule walked to the bottom of the ramp and stopped.</p>
<p>Nothing was wrong with the pathfinding. The baked route approached the <em>vertical side face</em> of the ramp collider, and no combination of <code>floor_snap_length</code>, gravity, or <code>floor_max_angle</code> was going to get a <code>CharacterBody3D</code> up a wall. Making both ramp transitions physically flush is what fixed it.</p>
<p>A valid path is not a promise that the body can execute it. When movement stalls and the path looks correct, stop reading navigation code and go inspect the collider.</p>
<p>The other tuning result worth stealing: <code>path_desired_distance = 0.65</code>. Smaller values made this particular accelerated body overshoot each waypoint and then curve back toward it, forever. Derive that number from your controller's speed and stopping behaviour rather than copying mine.</p>
<h2>Avoidance changes velocity, not the path</h2>
<p>The moving obstacle in the starter is a <code>NavigationObstacle3D</code> that publishes its velocity every physics frame so the avoidance server can predict it:</p>
<pre><code class="language-gdscript">func _physics_process(delta: float) -&gt; void:
	var previous_position := global_position
	_phase = fmod(_phase + TAU * cycles_per_second * delta, TAU)
	var next_position := _spawn_position + travel_axis.normalized() \
		* sin(_phase) * travel_distance
	last_reported_velocity = (next_position - previous_position) / maxf(delta, 0.0001)
	navigation_obstacle.velocity = last_reported_velocity
	global_position = next_position
</code></pre>
<p>The player sends its desired horizontal velocity through <code>agent.velocity</code>, and <code>velocity_computed</code> hands back a locally safer one:</p>
<pre><code class="language-gdscript">func _on_velocity_computed(safe_velocity: Vector3) -&gt; void:
	velocity.x = safe_velocity.x
	velocity.z = safe_velocity.z
	move_and_slide()
</code></pre>
<p>While all of that happens, the cyan path line doesn't move. That's correct behaviour, and it surprises people. Avoidance doesn't rebake the mesh, doesn't pick a new route, and doesn't know your physics collider exists. It nudges velocity and nothing else.</p>
<p>Which also means avoidance can pin an agent against a wall if you park a moving obstacle in a narrow corridor. If an object is supposed to make a route <em>illegal</em>, you need an actual navigation change, not steering.</p>
<h2>Symptom table</h2>
<table>
<thead>
<tr>
<th>Symptom</th>
<th>Check first</th>
</tr>
</thead>
<tbody><tr>
<td>First path comes back empty</td>
<td>Wait for map and region data to synchronize</td>
</tr>
<tr>
<td>Path computes, character doesn't move</td>
<td><code>NavigationAgent3D</code> doesn't move its parent; call your controller</td>
</tr>
<tr>
<td>Body clips or sticks at a doorway</td>
<td>Compare <code>CollisionShape3D</code> against the baked <code>agent_radius</code></td>
</tr>
<tr>
<td>A moving object gets ignored</td>
<td>Enable avoidance and supply the obstacle's velocity</td>
</tr>
<tr>
<td>Character stops short of the click</td>
<td>Compare the request with <code>get_final_position()</code> and <code>is_target_reachable()</code></td>
</tr>
<tr>
<td>Path crosses the ramp, body stops at its foot</td>
<td>Inspect the collider for a vertical lip or side-face approach</td>
</tr>
<tr>
<td>Body circles a waypoint</td>
<td>Raise <code>path_desired_distance</code>, or retune speed and acceleration</td>
</tr>
<tr>
<td>Path line changes when you only expected steering</td>
<td>Something is assigning a new target. Avoidance alone doesn't reroute</td>
</tr>
</tbody></table>
<h2>When AStar3D is the right answer</h2>
<p>Reach for it when the legal positions and edges are explicit game data:</p>
<ul>
<li><p>A voxel world where each block position can be occupied or disabled</p>
</li>
<li><p>A dungeon built from stacked tile layers</p>
</li>
<li><p>Fixed 3D waypoints with one-way or authored connections</p>
</li>
<li><p>A flying or swimming lattice with discrete neighbours</p>
</li>
<li><p>Turn-based 3D movement with exact per-node costs</p>
</li>
</ul>
<p>You own that graph completely. Add each point, connect the legal pairs, disable and reconnect points when the world changes, then move the actor along the returned point path. Nothing discovers it for you.</p>
<h2>Run the verified starter</h2>
<p>The download is the exact authored scene and scripts from this article, MIT-licensed: <a href="https://vav-labs.com/downloads/pathfinding/3d-pathfinding-starter-godot-source.zip">Godot 4.7.1 source project</a>, 13,833 bytes, SHA-256 <code>bcfb590993cfd025b810f745bf0303d39061cdf6c177f0c27bf66be637104d1c</code>.</p>
<p>Beside it is a <a href="https://vav-labs.com/downloads/pathfinding/3d-pathfinding-starter-godot-verification.json">machine-readable verification receipt</a>: 21 named checks, 21 passed, 0 failed, on Godot 4.7.1-stable (official, engine hash <code>a13da4fe</code>). It records the ZIP hash, per-file hashes, a clean import of the extracted project, and a scene smoke test that reports 27 navmesh polygons and 26 vertices from the saved bake.</p>
<p>Honest boundary, straight from the receipt: this is deterministic tutorial correctness. It makes no claim about FPS, throughput, crowd capacity, path optimality, production-readiness, or dynamic blockers. If you want a number for "how many agents can I run," you'll have to profile your own project.</p>
<hr />
<p>The full version is on <a href="https://vav-labs.com/blog/3d-pathfinding-in-godot/">vav-labs.com</a> and it embeds the scene as a playable web export so you can click around before downloading anything. It also carries the FAQ and the links out to the related guides.</p>
<p>If any of this doesn't match what you're hitting in your own scene, I'd genuinely like to hear about it. The failure modes here are more varied than one post can cover.</p>
]]></content:encoded></item><item><title><![CDATA[Why NavigationAgent2D Lags With Hundreds of Units]]></title><description><![CDATA[Originally published on vav-labs.com. Everything here is against Godot 4.7 stable, and there's a runnable source project with a verification receipt at the end.

If your game runs fine with a dozen un]]></description><link>https://vav-labs.hashnode.dev/why-navigationagent2d-lags-with-hundreds-of-units</link><guid isPermaLink="true">https://vav-labs.hashnode.dev/why-navigationagent2d-lags-with-hundreds-of-units</guid><category><![CDATA[Godot]]></category><category><![CDATA[GameDev]]></category><category><![CDATA[pathfinding]]></category><category><![CDATA[performance]]></category><dc:creator><![CDATA[Vav Labs]]></dc:creator><pubDate>Wed, 15 Jul 2026 21:21:24 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a3c21de61a47ab305219bba/e7f2f5b3-abbb-434b-b16b-6c90fcf83bec.gif" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>Originally published on <a href="https://vav-labs.com/blog/why-navigationagent2d-lags-with-hundreds-of-units/">vav-labs.com</a>. Everything here is against Godot 4.7 stable, and there's a runnable source project with a verification receipt at the end.</p>
</blockquote>
<p>If your game runs fine with a dozen units and then hitches the moment you box-select a hundred and click "move," the instinct is to blame the pathfinding algorithm. Usually it isn't the algorithm.</p>
<p>It's how many path requests you started in one physics tick.</p>
<p>This one's in the docs, and people still hit it constantly (me included, the first time I built an RTS selection). Setting <code>NavigationAgent2D.target_position</code> requests a new path. Do that for 100 selected units in a single frame and you've just queued 100 path queries in that frame. One query is cheap. A hundred of them landing on the same tick is a scheduling problem wearing an algorithm costume.</p>
<p>So before you change anything, count.</p>
<h2>First, confirm navigation actually owns the hitch</h2>
<p>Profile before you theorize. If the profiler says the frame time is going to physics or rendering or a gameplay script, none of the fixes below apply. Nail the attribution first.</p>
<p>Once you know it's navigation, the next question is whether it's the <em>request</em> side (too many queries starting at once) or the <em>search</em> side (one query that's genuinely expensive). They have different fixes, so don't guess.</p>
<table>
<thead>
<tr>
<th>What you see</th>
<th>Likely cause</th>
<th>First action</th>
</tr>
</thead>
<tbody><tr>
<td>One hitch when a group gets a move order</td>
<td>Same-tick request burst</td>
<td>Update groups or a per-tick request budget</td>
</tr>
<tr>
<td>Steady stutter while units chase a moving target</td>
<td>Per-frame <code>target_position</code> resets</td>
<td>Distance + time repath thresholds</td>
</tr>
<tr>
<td>Lag only where the crowd bunches up</td>
<td><code>path_max_distance</code> recalculations</td>
<td>Measure deviation, tune tolerance, freeze arrivals</td>
</tr>
<tr>
<td>Every query is slow, even for one unit</td>
<td>Search-side polygon/edge count</td>
<td>Optimize the nav mesh — scheduling isn't the fix</td>
</tr>
<tr>
<td>A squad is smooth but hundreds share one goal badly</td>
<td>Per-agent paths hit their ceiling</td>
<td>Move to a shared field</td>
</tr>
</tbody></table>
<p>One thing worth internalizing: path-search cost tracks navigation-mesh polygons and edges, not the physical size of your world. And an unreachable target can force a much longer search. If a single unit is already slow, that's a mesh problem, and no amount of request scheduling will save you.</p>
<h2>Count the requests before you tune them</h2>
<p>Don't tune budgets and thresholds blind. Add a few custom monitors and watch real numbers in the Debugger's Monitor tab:</p>
<pre><code class="language-gdscript"># Battle-scene wiring. The receipt covers the governor, not this glue.
func _ready() -&gt; void:
    Performance.add_custom_monitor("repath/requests_per_tick",
        func(): return _requests_this_tick)
    Performance.add_custom_monitor("repath/queue_depth",
        func(): return _governor.queue_depth())
    Performance.add_custom_monitor("repath/oldest_request_ticks",
        func(): return _governor.oldest_request_age_ticks(
            int(Engine.get_physics_frames())))
</code></pre>
<p>What each one tells you:</p>
<ul>
<li><p><strong>Requests per physics tick</strong> — a one-frame group-order burst shows up here.</p>
</li>
<li><p><strong>Requests per unit per second</strong> — sustained every-tick target resets show up here.</p>
</li>
<li><p><strong>Queue depth and oldest-request age</strong> — tells you your budget is too tight for acceptable response latency.</p>
</li>
</ul>
<p>One caveat: <code>path_changed</code> fires with no reason payload, so you can't ask the engine <em>why</em> it repathed. Track your own reasons where you queue the request (<code>GROUP_ORDER</code>, <code>TARGET_DRIFT</code>, <code>PERIODIC_REFRESH</code>), and only infer "probably knocked off-path" when the target and map revision both stayed fixed but the agent drifted past its tolerance.</p>
<h2>Cause A: one click, a hundred requests</h2>
<p>This is the common one. The fix is the explicit version of Godot's own "split agents into update groups" advice: route every target change through a single governor that dispatches a budgeted number of live requests per tick.</p>
<p>The nice thing is you can predict the latency before you even run the game. With <code>N</code> pending requests and a budget of <code>B</code> per tick, draining takes <code>ceil(N / B)</code> ticks. At 60 Hz, 500 requests with <code>B = 8</code> means the last unit gets its path about a second after the order. If that's too slow, raise the budget for small groups, prioritize the visible/player units, or stop issuing one path per unit entirely.</p>
<p>The dispatch loop is bounded two ways — a query ceiling and a <em>separate</em> scan ceiling, so that cleaning up cancelled or superseded entries can't itself blow the tick:</p>
<pre><code class="language-gdscript">func drain(
    run_query: Callable,
    is_alive: Callable,
    current_map_revision: int,
    now_tick: int
) -&gt; Array[Dictionary]:
    var dispatched: Array[Dictionary] = []
    var scanned := 0
    var query_limit: int = maxi(0, queries_per_tick)
    var scan_limit: int = maxi(0, max_entries_scanned_per_tick)

    while dispatched.size() &lt; query_limit and scanned &lt; scan_limit:
        var entry := _take_next_raw_entry()
        if entry.is_empty():
            break
        scanned += 1
        # ...skip stale/dead entries, then run the query and record it...
</code></pre>
<p>A few properties that matter once you're at scale:</p>
<ul>
<li><p><strong>Latest target wins.</strong> A repeated request for a unit still in the queue updates its target in place, instead of piling on more work.</p>
</li>
<li><p><strong>Dead and cancelled units stop before the callback.</strong> Their queued entries never reach the agent.</p>
</li>
<li><p><strong>Request and scan budgets are separate.</strong> Stale cleanup can't quietly consume an unbounded tick.</p>
</li>
</ul>
<p>Two small gotchas: keep the governor on the controller that owns the unit registry (one queue per agent just recreates the burst with more objects), and don't reach for <code>Array.pop_front()</code> on a big queue — it shifts every remaining index. Head cursors are cheaper.</p>
<h2>Cause B: the target moved one pixel</h2>
<p>A chasing unit tends to reset <code>target_position</code> every time its target moves, and every reset is a new path request. The docs even attribute the classic "unit dances between two spots" bug to path updates that are too frequent.</p>
<p>Gate it. Ignore drift smaller than a distance threshold, and refresh a slowly-moving target on a time threshold instead of every frame. Long intervals are fine at range; tighten them near the destination. And treat arrival as a hard stop — an arrived unit requests nothing and stops calling <code>get_next_path_position()</code>.</p>
<pre><code class="language-gdscript">static func repath_reason(
    planned_target: Vector2,
    live_target: Vector2,
    repath_distance: float,
    now_tick: int,
    next_repath_tick: int,
    arrived: bool
) -&gt; StringName:
    if arrived:
        return REASON_NONE
    var drift := planned_target.distance_to(live_target)
    var threshold := maxf(0.0, repath_distance)
    if drift &gt; 0.0 and (threshold == 0.0 or drift &gt;= threshold):
        return REASON_TARGET_DRIFT
    if now_tick &gt;= next_repath_tick:
        return REASON_PERIODIC_REFRESH
    return REASON_NONE
</code></pre>
<h2>Cause C: avoidance pushes, path_max_distance pulls</h2>
<p><code>path_max_distance</code> is how far an agent is allowed to stray from its ideal path. Godot documents that crossing it triggers a recalculation — including when collision avoidance is what shoved the agent out there.</p>
<p>At crowd scale that chains: avoidance nudges one agent past its tolerance, its new route shifts the local pressure, a neighbor crosses <em>its</em> tolerance, and so on. Before you blame this, correlate the <code>path_changed</code> signal with an unchanged target and map revision plus measured deviation. Otherwise you're guessing.</p>
<p>Three things help, in order:</p>
<ol>
<li><p><strong>Tune from measured deviation.</strong> Raise <code>path_max_distance</code> just enough that normal steering stays inside it, then recheck that a genuinely lost agent still recovers.</p>
</li>
<li><p><strong>Freeze arrivals.</strong> Units sitting inside their destination tolerance stop consuming path updates, so avoidance can't shove them into another recalculation.</p>
</li>
<li><p><strong>Keep steering separate from planning.</strong> A momentary avoidance offset doesn't need a brand-new target assignment.</p>
</li>
</ol>
<p>Avoidance and pathfinding are separate systems, and avoidance has its own cost knobs (<code>neighbor_distance</code>, <code>max_neighbors</code>). That's a deeper rabbit hole than this post.</p>
<h2>Run the proof yourself</h2>
<p>There's a standalone Godot 4.7 project with the full assembled governor, a runnable scene, a verifier, README, and license: <a href="https://vav-labs.com/downloads/pathforge/why-navigationagent2d-lags-with-hundreds-of-units-source.zip">source ZIP here</a>. Its <a href="https://vav-labs.com/downloads/pathforge/why-navigationagent2d-lags-with-hundreds-of-units-verification.json">machine-readable receipt</a> reports 20/20 named checks, zero failures.</p>
<p>The checks cover both ceilings, FIFO within a priority, high-priority preemption without starving the normal queue, coalescing and promotion, dead/cancelled entries, the threshold policies, arrival freeze, and current-revision dispatch. The scene smoke stands up a real <code>NavigationServer2D</code> region, gets both direct and <code>NavigationAgent2D</code> paths, and drains ten requests as <code>3, 3, 3, 1</code> under a three-query budget. The ZIP is 11,641 bytes, SHA-256 <code>5c58b4a53df42cf9b8aa81a0a75758bc64656435344fc0d44db36e92e6f94bef</code>.</p>
<p>Honest boundary: this is scheduling-correctness and scene-integration evidence. It is <strong>not</strong> an FPS, throughput, memory, or supported-unit-count benchmark. If you want a number for "how many units can I run," you'll have to profile your own project.</p>
<h2>Debug order, short version</h2>
<ol>
<li><p>Profile — confirm the hitch is navigation, not physics/rendering/scripts.</p>
</li>
<li><p>Record requests per tick, per-unit rate, your own reasons, queue depth, oldest age.</p>
</li>
<li><p><code>GROUP_ORDER</code> spikes → Cause A. Sustained <code>TARGET_DRIFT</code> → Cause B.</p>
</li>
<li><p>Unexplained <code>path_changed</code> with a stable target/map + measured deviation → Cause C.</p>
</li>
<li><p>Check whether arrived units are still consuming path updates.</p>
</li>
<li><p>Change one budget/threshold/tolerance, then re-measure the same counters.</p>
</li>
<li><p>Requests minimal but one query slow → it's the mesh, not scheduling.</p>
</li>
<li><p>Still need separate routes to one shared goal for a huge crowd → shared field.</p>
</li>
</ol>
<p>Fix the cause you measured, not the one you assumed. If any of this doesn't match what you're seeing in your own project, I'd genuinely like to know — the failure modes are more varied than one post can cover.</p>
<p>The full version, with the complete governor code and the internal links to the related failure-mode guides, is on <a href="https://vav-labs.com/blog/why-navigationagent2d-lags-with-hundreds-of-units/">vav-labs.com</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Open-sourced: a playable Godot tower-defense path-validation demo
]]></title><description><![CDATA[One of the most common tower-defense bugs in Godot: a player drops a tower in the wrong cell, walls off the path, and the whole wave softlocks.
So I built (and open-sourced) a small, playable Godot 4.]]></description><link>https://vav-labs.hashnode.dev/open-sourced-a-playable-godot-tower-defense-path-validation-demo</link><guid isPermaLink="true">https://vav-labs.hashnode.dev/open-sourced-a-playable-godot-tower-defense-path-validation-demo</guid><dc:creator><![CDATA[Vav Labs]]></dc:creator><pubDate>Wed, 24 Jun 2026 18:48:14 GMT</pubDate><content:encoded><![CDATA[<p>One of the most common tower-defense bugs in Godot: a player drops a tower in the wrong cell, walls off the path, and the whole wave softlocks.</p>
<p>So I built (and open-sourced) a small, playable Godot 4.7 demo for the fix. The rule is one line: a tower can lengthen the enemy route, but it must never seal it. You enforce it by validating the placement before you commit — drop the tower as a temporary blocker, ask the pathfinder whether a route still exists, then revert and only commit if it does.</p>
<p>The repo (MIT) has the placement-validation logic, a benchmark harness comparing full vs incremental validation, and the browser web export.</p>
<ul>
<li><p>Code: <a href="https://github.com/Vav-Labs/godot-tower-defense-path-validation">https://github.com/Vav-Labs/godot-tower-defense-path-validation</a></p>
</li>
<li><p>Play it in the browser: <a href="https://vav-labs.com/demos/pathforge/tower-defense-path-validation/">https://vav-labs.com/demos/pathforge/tower-defense-path-validation/</a></p>
</li>
<li><p>Full write-up: <a href="https://vav-labs.com/blog/tower-defense-path-validation-in-godot/">https://vav-labs.com/blog/tower-defense-path-validation-in-godot/</a></p>
</li>
</ul>
]]></content:encoded></item></channel></rss>