<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vav Labs</title>
    <description>The latest articles on DEV Community by Vav Labs (@vav_labs).</description>
    <link>https://dev.to/vav_labs</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3986932%2F34593eb7-f7c5-49ff-9381-e86c74adb4e9.jpg</url>
      <title>DEV Community: Vav Labs</title>
      <link>https://dev.to/vav_labs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vav_labs"/>
    <language>en</language>
    <item>
      <title>TileMapLayer Custom Data for Movement Costs in Godot</title>
      <dc:creator>Vav Labs</dc:creator>
      <pubDate>Sat, 22 Aug 2026 10:21:06 +0000</pubDate>
      <link>https://dev.to/vav_labs/tilemaplayer-custom-data-for-movement-costs-in-godot-4jlc</link>
      <guid>https://dev.to/vav_labs/tilemaplayer-custom-data-for-movement-costs-in-godot-4jlc</guid>
      <description>&lt;p&gt;Your units keep preferring a strip of tiles you never gave a movement cost.&lt;br&gt;
There's no error anywhere. The path is legal. The route is wrong.&lt;/p&gt;

&lt;p&gt;Here's the chain that produces it in Godot 4.7.2. A typed float custom-data&lt;br&gt;
layer defaults to &lt;code&gt;0.0&lt;/code&gt;. &lt;code&gt;TileData.has_custom_data()&lt;/code&gt; checks whether the named&lt;br&gt;
layer exists in the &lt;code&gt;TileSet&lt;/code&gt; — it doesn't know whether you actually painted a&lt;br&gt;
value on this tile. And &lt;code&gt;AStarGrid2D.set_point_weight_scale()&lt;/code&gt; rejects negative&lt;br&gt;
values but accepts &lt;code&gt;0.0&lt;/code&gt; without complaint. So one unpainted tile imports as&lt;br&gt;
free to enter, and every step of that is silent.&lt;/p&gt;

&lt;p&gt;To be fair, this is documented behavior. &lt;code&gt;has_custom_data()&lt;/code&gt; is honest about&lt;br&gt;
what it checks. People still hit it constantly, because the name reads like a&lt;br&gt;
per-tile check. This guard is the usual suspect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tile_data&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;has_custom_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"movement_cost"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cost&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tile_data&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_custom_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"movement_cost"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the layer exists, that branch succeeds for every tile that has one —&lt;br&gt;
including tiles still holding the typed default &lt;code&gt;0.0&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is an importer, not a call
&lt;/h2&gt;

&lt;p&gt;What worked for me is treating the TileSet like untrusted input:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Resolve the custom-data layer once, by name, and verify its Variant type.&lt;/li&gt;
&lt;li&gt;Read &lt;code&gt;TileData&lt;/code&gt; for each used cell. &lt;code&gt;get_cell_tile_data()&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt;
for scene-collection tiles, so decide what happens to those instead of
silently costing them like grass.&lt;/li&gt;
&lt;li&gt;Reject non-finite values and anything below your declared minimum. I use
&lt;code&gt;1.0&lt;/code&gt; — it makes an untouched default fail loudly instead of quietly
becoming a free tile.&lt;/li&gt;
&lt;li&gt;Validate the complete batch first, then write. Half an imported grid is
worse than a clean failure.&lt;/li&gt;
&lt;li&gt;Apply weights only after &lt;code&gt;AStarGrid2D.update()&lt;/code&gt;, because &lt;code&gt;update()&lt;/code&gt; clears
all point data — solidity and weight scale both. Replay from your normalized
data after any later structural update.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If different units read terrain differently (infantry vs vehicles), don't&lt;br&gt;
author a float at all. Store a &lt;code&gt;terrain_id&lt;/code&gt; string on the tile and resolve it&lt;br&gt;
through per-profile cost tables at runtime. One authored float can't represent&lt;br&gt;
two movement policies.&lt;/p&gt;

&lt;p&gt;Two more one-liners worth stealing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;jumping_enabled&lt;/code&gt; disables weight scaling entirely. It's in the class
reference, and the 4.7.2 source carries a TODO for weighted jumping. If
costs must affect route choice, keep jumping off.&lt;/li&gt;
&lt;li&gt;Weight is charged on entry: the engine multiplies the step cost by the
weight scale of the point being entered, so a path's terrain total skips the
starting cell.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's verified
&lt;/h2&gt;

&lt;p&gt;The write-up ships a standalone Godot 4.7.2 project (MIT) with a serialized&lt;br&gt;
TileSet fixture and a 14-check receipt, zero failures, including a clean rerun&lt;br&gt;
after extracting the package. My favorite check opens the &lt;code&gt;.tres&lt;/code&gt; as text and&lt;br&gt;
proves the untouched tile has no explicit &lt;code&gt;custom_data_0&lt;/code&gt; property &lt;em&gt;before&lt;/em&gt;&lt;br&gt;
loading it and observing the runtime &lt;code&gt;0.0&lt;/code&gt; — so the fixture can't cheat with&lt;br&gt;
an editor-serialized explicit zero.&lt;/p&gt;

&lt;p&gt;It's a correctness proof, not a benchmark. No timing, no throughput, no claims&lt;br&gt;
about large-map import time.&lt;/p&gt;

&lt;p&gt;The importer isn't clever, and that's the point. Its job is to make invalid&lt;br&gt;
authoring visible before it becomes a plausible but wrong route.&lt;/p&gt;

&lt;p&gt;Full version with the importer source, the fixture, the failure-mode table and&lt;br&gt;
the raw receipt JSON:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://vav-labs.com/blog/tilemaplayer-custom-data-for-movement-costs-in-godot/" rel="noopener noreferrer"&gt;TileMapLayer Custom Data for Movement Costs in Godot&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>godot</category>
      <category>gamedev</category>
      <category>pathfinding</category>
    </item>
    <item>
      <title>Smooth Grid Paths in Godot: Post-Smoothing vs Theta*</title>
      <dc:creator>Vav Labs</dc:creator>
      <pubDate>Fri, 21 Aug 2026 19:53:52 +0000</pubDate>
      <link>https://dev.to/vav_labs/smooth-grid-paths-in-godot-post-smoothing-vs-theta-1hcb</link>
      <guid>https://dev.to/vav_labs/smooth-grid-paths-in-godot-post-smoothing-vs-theta-1hcb</guid>
      <description>&lt;p&gt;AStarGrid2D` returns a perfectly legal route that still looks like it was drawn&lt;br&gt;
on graph paper. East, 45 degrees, east again, repeat, across a room that could&lt;br&gt;
have been crossed in one straight line.&lt;/p&gt;

&lt;p&gt;That's not an A* failure. The search was asked to move between grid neighbors,&lt;br&gt;
so it returned a path made of grid moves.&lt;/p&gt;

&lt;p&gt;There are two standard fixes and they get treated as interchangeable. They&lt;br&gt;
aren't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Smoothing runs after the search
&lt;/h2&gt;

&lt;p&gt;Keep the first cell, connect it to the farthest later cell it can see, keep that&lt;br&gt;
one, repeat until the goal.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`gdscript&lt;br&gt;
var output: Array[Vector2i] = [input[0]]&lt;br&gt;
var anchor_index := 0&lt;/p&gt;

&lt;p&gt;while anchor_index &amp;lt; input.size() - 1:&lt;br&gt;
    var candidate_index := input.size() - 1&lt;br&gt;
    while candidate_index &amp;gt; anchor_index:&lt;br&gt;
        calls += 1&lt;br&gt;
        if visibility.has_line_of_sight(&lt;br&gt;
            input[anchor_index], input[candidate_index], profile_id&lt;br&gt;
        ):&lt;br&gt;
            break&lt;br&gt;
        candidate_index -= 1&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if candidate_index == anchor_index:
    return _result(
        false, "MOVEMENT_VISIBILITY_MISMATCH", output,
        input.size(), calls, revision
    )

output.append(input[candidate_index])
anchor_index = candidate_index
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Search backward from the goal, not forward. Visibility isn't monotonic along the&lt;br&gt;
waypoint list, so one blocked candidate doesn't prove every later endpoint is&lt;br&gt;
blocked. The straightforward pass costs a quadratic number of line-of-sight&lt;br&gt;
calls in the number of waypoints, which is fine at waypoint counts and worth&lt;br&gt;
measuring before optimizing.&lt;/p&gt;

&lt;p&gt;Smooth over cell IDs from &lt;code&gt;get_id_path()&lt;/code&gt;, not world positions. That keeps the&lt;br&gt;
visibility test in grid space instead of rebuilding cell identity by rounding&lt;br&gt;
floats.&lt;/p&gt;

&lt;h2&gt;
  
  
  Theta* runs inside it
&lt;/h2&gt;

&lt;p&gt;Basic Theta* changes the relaxation step. When it reaches a neighbor it also&lt;br&gt;
asks whether the current node's &lt;em&gt;parent&lt;/em&gt; can see that neighbor, and reparents&lt;br&gt;
when the visible shortcut is cheaper.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`gdscript&lt;br&gt;
for direction in DIRECTIONS:&lt;br&gt;
    var neighbor := current + direction&lt;br&gt;
    if closed.has(neighbor) \&lt;br&gt;
            or not visibility.is_anchor_open(neighbor, profile_id):&lt;br&gt;
        continue&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;los_calls += 1
if not visibility.has_line_of_sight(current, neighbor, profile_id):
    continue

var best_parent := current
var best_cost := float(g_score[current]) + _distance(current, neighbor)
var ancestor := Vector2i(parent[current])
if ancestor != current:
    los_calls += 1
    if visibility.has_line_of_sight(ancestor, neighbor, profile_id):
        var ancestor_cost := float(g_score[ancestor]) \
            + _distance(ancestor, neighbor)
        if ancestor_cost &amp;lt; best_cost - EPSILON:
            best_cost = ancestor_cost
            best_parent = ancestor

if best_cost &amp;gt;= float(g_score.get(neighbor, INF_COST)) - EPSILON:
    continue
g_score[neighbor] = best_cost
parent[neighbor] = best_parent
frontier.push(neighbor, best_cost, _distance(neighbor, goal))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Note the first line-of-sight call. It tests the &lt;em&gt;ordinary&lt;/em&gt; local step, not the&lt;br&gt;
shortcut. Drop it and your diagonal moves start cutting blocked corners that the&lt;br&gt;
search itself forbids, which is the whole bug this contract exists to prevent.&lt;/p&gt;

&lt;p&gt;Use Euclidean distance for cost and heuristic. Octile prices a route along&lt;br&gt;
eight-neighbor grid edges, so it overestimates any segment whose slope is&lt;br&gt;
neither axial nor 45 degrees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where smoothing stops
&lt;/h2&gt;

&lt;p&gt;Smoothing can only delete waypoints from the route A* already committed to. It&lt;br&gt;
can't move the path to the other side of an obstacle, because the bend it would&lt;br&gt;
need was never in the list.&lt;/p&gt;

&lt;p&gt;One authored fixture in the verified project: the &lt;code&gt;AStarGrid2D&lt;/code&gt; route measures&lt;br&gt;
19.314 cell units. Smoothing gets it to 18.806 and stays on the upper side.&lt;br&gt;
Basic Theta* takes the lower side at 18.601.&lt;/p&gt;

&lt;p&gt;Operation counts tell the same story from the other direction. The open Theta*&lt;br&gt;
case expands 9 nodes and makes 120 line-of-sight calls. Expansion counts alone&lt;br&gt;
hide that work entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the line-of-sight contract has to say
&lt;/h2&gt;

&lt;p&gt;This is the part that actually decides whether your implementation is correct,&lt;br&gt;
and prose can't prove it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;where a node lives (cell corners in the paper, cell centers in most Godot
tile code)&lt;/li&gt;
&lt;li&gt;what happens at a blocked corner, and whether it matches the diagonal rule the
search already uses&lt;/li&gt;
&lt;li&gt;which cells a segment touches, boundary to boundary, with deterministic ties&lt;/li&gt;
&lt;li&gt;whether &lt;code&gt;line_of_sight(a, b) == line_of_sight(b, a)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;whether the agent footprint fits along the whole segment, not just the center
line&lt;/li&gt;
&lt;li&gt;which topology revision a cached visibility result belongs to&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Godot 4.7.2 has none of this built in. &lt;code&gt;AStarGrid2D&lt;/code&gt; has no any-angle mode, and&lt;br&gt;
&lt;code&gt;jumping_enabled&lt;/code&gt; is Jump Point Search-style pruning, not arbitrary-angle&lt;br&gt;
parents.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's verified and what isn't
&lt;/h2&gt;

&lt;p&gt;The standalone Godot 4.7.2 project is MIT licensed. Its receipt records 12&lt;br&gt;
checks and zero failures, including line-of-sight symmetry across 5,995&lt;br&gt;
unordered cell pairs, an 80-grid generated corpus where all 594 returned&lt;br&gt;
segments pass the same contract, deterministic ties across 21 runs, and a clean&lt;br&gt;
rerun after package extraction.&lt;/p&gt;

&lt;p&gt;It records no timing, memory, or throughput data at all. Basic Theta* is&lt;br&gt;
complete under its model and &lt;strong&gt;not&lt;/strong&gt; optimal. The &lt;code&gt;square_2x2&lt;/code&gt; profile is a&lt;br&gt;
translated occupied-cell offset set, not a disk or a rotating shape.&lt;/p&gt;

&lt;p&gt;Full version with the corner rules, the FAQ, the sources, the source zip and the&lt;br&gt;
raw receipt JSON:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://vav-labs.com/blog/smooth-grid-paths-in-godot/" rel="noopener noreferrer"&gt;Smooth Grid Paths in Godot: Post-Smoothing vs Theta*&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>3D Pathfinding in Godot 4: NavigationAgent3D, the Three Radii, and When You Actually Need AStar3D</title>
      <dc:creator>Vav Labs</dc:creator>
      <pubDate>Fri, 07 Aug 2026 08:26:42 +0000</pubDate>
      <link>https://dev.to/vav_labs/3d-pathfinding-in-godot-4-navigationagent3d-the-three-radii-and-when-you-actually-need-astar3d-4nn1</link>
      <guid>https://dev.to/vav_labs/3d-pathfinding-in-godot-4-navigationagent3d-the-three-radii-and-when-you-actually-need-astar3d-4nn1</guid>
      <description>&lt;p&gt;Originally published on &lt;a href="https://vav-labs.com/blog/3d-pathfinding-in-godot/" rel="noopener noreferrer"&gt;vav-labs.com&lt;/a&gt;. Everything below is Godot 4.7.1 stable, and there's a runnable MIT-licensed project with a verification receipt at the end.&lt;/p&gt;

&lt;p&gt;You add a &lt;code&gt;NavigationRegion3D&lt;/code&gt;, bake a mesh, drop a &lt;code&gt;NavigationAgent3D&lt;/code&gt; on your character, set &lt;code&gt;target_position&lt;/code&gt;, hit Play, and the character stands there doing nothing at all.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So here's the whole contract in one place, in the order you'd actually build it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Navmesh or AStar3D? Decide by movement space
&lt;/h2&gt;

&lt;p&gt;Before any setup, pick the representation. This is the choice people get wrong most often, and it's not really about 2D versus 3D.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your movement space&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Grounded movement over authored 3D geometry&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;NavigationMesh&lt;/code&gt; + &lt;code&gt;NavigationAgent3D&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Voxels, stacked tiles, waypoints, other predefined positions&lt;/td&gt;
&lt;td&gt;An explicit &lt;code&gt;AStar3D&lt;/code&gt; point graph&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fully volumetric flight or swimming&lt;/td&gt;
&lt;td&gt;An explicit 3D graph or something purpose-built&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AStar3D&lt;/code&gt; 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 &lt;code&gt;AStar3D&lt;/code&gt; won't inspect a &lt;code&gt;GridMap&lt;/code&gt; and discover the graph for you. You add every point, connect every legal pair, and keep the thing in sync when the world changes.&lt;/p&gt;

&lt;p&gt;This post builds the first case.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scene, and the order to build it
&lt;/h2&gt;

&lt;p&gt;Nothing exotic. Everything here is authored before Play.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Main (Node3D)
├── NavigationRegion3D
│   └── LevelGeometry (floor, ramp, platform, walls)
├── Player (CharacterBody3D)
│   ├── CollisionShape3D
│   ├── MeshInstance3D
│   └── NavigationAgent3D
├── MovingObstacle (AnimatableBody3D)
│   └── NavigationObstacle3D
├── TargetMarker
├── PathLine
└── Camera3D
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build it in this order and you get a working result at every step, which makes it obvious where things broke:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;NavigationRegion3D&lt;/code&gt; with the level geometry under it. The floor, ramp, platform and walls are visible in the editor.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;NavigationMesh&lt;/code&gt; resource on the region, baked and saved. You now have a walkable surface.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CharacterBody3D&lt;/code&gt; with a &lt;code&gt;NavigationAgent3D&lt;/code&gt; child. A grounded body can follow one target across the ramp.&lt;/li&gt;
&lt;li&gt;Physics-step mouse raycast. Left click produces a world-space target.&lt;/li&gt;
&lt;li&gt;Closest-point clamping and a path line. The target snaps to the navmesh and the route is visible.&lt;/li&gt;
&lt;li&gt;Optional avoidance obstacle. Local steering reacts to motion without touching the global path.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Baking the NavigationMesh
&lt;/h2&gt;

&lt;p&gt;Put the walkable geometry under &lt;code&gt;NavigationRegion3D&lt;/code&gt;, create a &lt;code&gt;NavigationMesh&lt;/code&gt; 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.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;NavigationMesh property&lt;/th&gt;
&lt;th&gt;Starter value&lt;/th&gt;
&lt;th&gt;What it changes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;agent_radius&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.50&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Erodes clearance around walls and ledges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;agent_height&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Minimum vertical space the body needs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;agent_max_climb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.50&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Joins surfaces across small height changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;agent_max_slope&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;35.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keeps the 20-degree ramp walkable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cell_size&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.25&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Horizontal bake resolution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cell_height&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.25&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Vertical bake resolution&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things that bite here. &lt;code&gt;cell_size&lt;/code&gt; and &lt;code&gt;cell_height&lt;/code&gt; have to match the navigation map, and Godot rounds the bake radius &lt;strong&gt;up&lt;/strong&gt; to a multiple of &lt;code&gt;cell_size&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the three radii separate
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Starter value&lt;/th&gt;
&lt;th&gt;Owner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CapsuleShape3D.radius&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.48&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Physics collision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NavigationMesh.agent_radius&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.50&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bake-time wall and ledge clearance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NavigationAgent3D.radius&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.55&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Local avoidance only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wait until the navigation map is usable
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_ready&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;_spawn_transform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;global_transform&lt;/span&gt;
    &lt;span class="n"&gt;floor_snap_length&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.45&lt;/span&gt;
    &lt;span class="n"&gt;floor_max_angle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;deg_to_rad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;45.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;velocity_computed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_on_velocity_computed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;_finish_navigation_setup&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call_deferred&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_finish_navigation_setup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;map_rid&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_navigation_map&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;map_has_data&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NavigationServer3D&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map_get_iteration_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;map_rid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; \
            &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;NavigationServer3D&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map_get_regions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;map_rid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_empty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;closest_point&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NavigationServer3D&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map_get_closest_point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;map_rid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;global_position&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;map_has_data&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="kt"&gt;Vector3&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ZERO&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;map_has_data&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;closest_point&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;distance_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;global_position&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;get_tree&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;physics_frame&lt;/span&gt;
    &lt;span class="n"&gt;_navigation_is_ready&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;true&lt;/span&gt;
    &lt;span class="n"&gt;navigation_ready&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Raycast the click during the physics step
&lt;/h2&gt;

&lt;p&gt;Mouse input arrives outside the physics callback, but &lt;code&gt;direct_space_state&lt;/code&gt; wants to be queried during physics processing. Store the screen position, consume it in &lt;code&gt;_physics_process()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_unhandled_input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;InputEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;InputEventMouseButton&lt;/span&gt; \
            &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;button_index&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;MOUSE_BUTTON_LEFT&lt;/span&gt; \
            &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pressed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;_pending_click&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt;
        &lt;span class="n"&gt;get_viewport&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set_input_as_handled&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_physics_process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_pending_click&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kt"&gt;null&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;click_position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Vector2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_pending_click&lt;/span&gt;
    &lt;span class="n"&gt;_pending_click&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;null&lt;/span&gt;
    &lt;span class="n"&gt;_raycast_target&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;click_position&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_raycast_target&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;screen_position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Vector2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ray_origin&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;camera&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;project_ray_origin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;screen_position&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ray_end&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ray_origin&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;camera&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;project_ray_normal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;screen_position&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;200.0&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PhysicsRayQueryParameters3D&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ray_origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ray_end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exclude&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;player&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_rid&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;get_world_3d&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;direct_space_state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;intersect_ray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_empty&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;_set_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"No level surface under that click."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;_set_target&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A physics hit is not a navigation target
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;set_navigation_target&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requested_position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Vector3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Vector3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;_navigation_is_ready&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;global_position&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;reachable_target&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NavigationServer3D&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map_get_closest_point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_navigation_map&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;requested_position&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;target_position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reachable_target&lt;/span&gt;
    &lt;span class="n"&gt;_has_target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;true&lt;/span&gt;
    &lt;span class="n"&gt;_last_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PackedVector3Array&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;reachable_target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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, &lt;code&gt;get_final_position()&lt;/code&gt; gives you the reachable end of the current path and &lt;code&gt;is_target_reachable()&lt;/code&gt; tells you what the agent thinks of the request.&lt;/p&gt;

&lt;h2&gt;
  
  
  NavigationAgent3D does not move your character
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;move_and_slide()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_physics_process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;is_on_floor&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;velocity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;_gravity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;velocity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;velocity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;desired_velocity&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Vector3&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ZERO&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_navigation_is_ready&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;_has_target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_navigation_finished&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="n"&gt;_has_target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;false&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;next_path_position&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_next_path_position&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;global_position&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;direction_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next_path_position&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length_squared&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.0001&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;direction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="n"&gt;desired_velocity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;move_speed&lt;/span&gt;
            &lt;span class="n"&gt;_emit_path_if_changed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;current_horizontal&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Vector3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;velocity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;velocity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;desired_velocity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_horizontal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;move_toward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;desired_velocity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;acceleration&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;avoidance_enabled&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;_navigation_is_ready&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;velocity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;desired_velocity&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;_on_velocity_computed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;desired_velocity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep &lt;code&gt;get_next_path_position()&lt;/code&gt; in the physics loop. Calling it from signals like &lt;code&gt;waypoint_reached&lt;/code&gt; can retrigger path updates and recurse on you.&lt;/p&gt;

&lt;h3&gt;
  
  
  The ramp that ate an evening
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Nothing was wrong with the pathfinding. The baked route approached the &lt;em&gt;vertical side face&lt;/em&gt; of the ramp collider, and no combination of &lt;code&gt;floor_snap_length&lt;/code&gt;, gravity, or &lt;code&gt;floor_max_angle&lt;/code&gt; was going to get a &lt;code&gt;CharacterBody3D&lt;/code&gt; up a wall. Making both ramp transitions physically flush is what fixed it.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The other tuning result worth stealing: &lt;code&gt;path_desired_distance = 0.65&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Avoidance changes velocity, not the path
&lt;/h2&gt;

&lt;p&gt;The moving obstacle in the starter is a &lt;code&gt;NavigationObstacle3D&lt;/code&gt; that publishes its velocity every physics frame so the avoidance server can predict it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_physics_process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;previous_position&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;global_position&lt;/span&gt;
    &lt;span class="n"&gt;_phase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;fmod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_phase&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="bp"&gt;TAU&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;cycles_per_second&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;TAU&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;next_position&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_spawn_position&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;travel_axis&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; \
        &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_phase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;travel_distance&lt;/span&gt;
    &lt;span class="n"&gt;last_reported_velocity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next_position&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;previous_position&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;maxf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0001&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;navigation_obstacle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;velocity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;last_reported_velocity&lt;/span&gt;
    &lt;span class="n"&gt;global_position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;next_position&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The player sends its desired horizontal velocity through &lt;code&gt;agent.velocity&lt;/code&gt;, and &lt;code&gt;velocity_computed&lt;/code&gt; hands back a locally safer one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_on_velocity_computed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;safe_velocity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Vector3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;velocity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;safe_velocity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;
    &lt;span class="n"&gt;velocity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;safe_velocity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;
    &lt;span class="n"&gt;move_and_slide&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;illegal&lt;/em&gt;, you need an actual navigation change, not steering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Check first&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First path comes back empty&lt;/td&gt;
&lt;td&gt;Wait for map and region data to synchronize&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Path computes, character doesn't move&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;NavigationAgent3D&lt;/code&gt; doesn't move its parent; call your controller&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Body clips or sticks at a doorway&lt;/td&gt;
&lt;td&gt;Compare &lt;code&gt;CollisionShape3D&lt;/code&gt; against the baked &lt;code&gt;agent_radius&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A moving object gets ignored&lt;/td&gt;
&lt;td&gt;Enable avoidance and supply the obstacle's velocity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Character stops short of the click&lt;/td&gt;
&lt;td&gt;Compare the request with &lt;code&gt;get_final_position()&lt;/code&gt; and &lt;code&gt;is_target_reachable()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Path crosses the ramp, body stops at its foot&lt;/td&gt;
&lt;td&gt;Inspect the collider for a vertical lip or side-face approach&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Body circles a waypoint&lt;/td&gt;
&lt;td&gt;Raise &lt;code&gt;path_desired_distance&lt;/code&gt;, or retune speed and acceleration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Path line changes when you only expected steering&lt;/td&gt;
&lt;td&gt;Something is assigning a new target. Avoidance alone doesn't reroute&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When AStar3D is the right answer
&lt;/h2&gt;

&lt;p&gt;Reach for it when the legal positions and edges are explicit game data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A voxel world where each block position can be occupied or disabled&lt;/li&gt;
&lt;li&gt;A dungeon built from stacked tile layers&lt;/li&gt;
&lt;li&gt;Fixed 3D waypoints with one-way or authored connections&lt;/li&gt;
&lt;li&gt;A flying or swimming lattice with discrete neighbours&lt;/li&gt;
&lt;li&gt;Turn-based 3D movement with exact per-node costs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the verified starter
&lt;/h2&gt;

&lt;p&gt;The download is the exact authored scene and scripts from this article, MIT-licensed: &lt;a href="https://vav-labs.com/downloads/pathfinding/3d-pathfinding-starter-godot-source.zip" rel="noopener noreferrer"&gt;Godot 4.7.1 source project&lt;/a&gt;, 13,833 bytes, SHA-256 &lt;code&gt;bcfb590993cfd025b810f745bf0303d39061cdf6c177f0c27bf66be637104d1c&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Beside it is a &lt;a href="https://vav-labs.com/downloads/pathfinding/3d-pathfinding-starter-godot-verification.json" rel="noopener noreferrer"&gt;machine-readable verification receipt&lt;/a&gt;: 21 named checks, 21 passed, 0 failed, on Godot 4.7.1-stable (official, engine hash &lt;code&gt;a13da4fe&lt;/code&gt;). 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;The full version is on &lt;a href="https://vav-labs.com/blog/3d-pathfinding-in-godot/" rel="noopener noreferrer"&gt;vav-labs.com&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>pathfinding</category>
      <category>godot</category>
      <category>navigationagent3d</category>
    </item>
    <item>
      <title>Why NavigationAgent2D Lags With Hundreds of Units</title>
      <dc:creator>Vav Labs</dc:creator>
      <pubDate>Wed, 15 Jul 2026 15:05:59 +0000</pubDate>
      <link>https://dev.to/vav_labs/why-navigationagent2d-lags-with-hundreds-of-units-1pp2</link>
      <guid>https://dev.to/vav_labs/why-navigationagent2d-lags-with-hundreds-of-units-1pp2</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F84334ychfgghlag83qo3.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F84334ychfgghlag83qo3.gif" alt=" " width="760" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Originally published on &lt;a href="https://vav-labs.com/blog/why-navigationagent2d-lags-with-hundreds-of-units/" rel="noopener noreferrer"&gt;vav-labs.com&lt;/a&gt;. Everything here is against Godot 4.7 stable, and there's a runnable source project with a verification receipt at the end.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;It's how many path requests you started in one physics tick.&lt;/p&gt;

&lt;p&gt;This one's in the docs, and people still hit it constantly (me included, the first time I built an RTS selection). Setting &lt;code&gt;NavigationAgent2D.target_position&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;So before you change anything, count.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, confirm navigation actually owns the hitch
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Once you know it's navigation, the next question is whether it's the &lt;em&gt;request&lt;/em&gt; side (too many queries starting at once) or the &lt;em&gt;search&lt;/em&gt; side (one query that's genuinely expensive). They have different fixes, so don't guess.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;First action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One hitch when a group gets a move order&lt;/td&gt;
&lt;td&gt;Same-tick request burst&lt;/td&gt;
&lt;td&gt;Update groups or a per-tick request budget&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Steady stutter while units chase a moving target&lt;/td&gt;
&lt;td&gt;Per-frame &lt;code&gt;target_position&lt;/code&gt; resets&lt;/td&gt;
&lt;td&gt;Distance + time repath thresholds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lag only where the crowd bunches up&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;path_max_distance&lt;/code&gt; recalculations&lt;/td&gt;
&lt;td&gt;Measure deviation, tune tolerance, freeze arrivals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every query is slow, even for one unit&lt;/td&gt;
&lt;td&gt;Search-side polygon/edge count&lt;/td&gt;
&lt;td&gt;Optimize the nav mesh — scheduling isn't the fix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A squad is smooth but hundreds share one goal badly&lt;/td&gt;
&lt;td&gt;Per-agent paths hit their ceiling&lt;/td&gt;
&lt;td&gt;Move to a shared field&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Count the requests before you tune them
&lt;/h2&gt;

&lt;p&gt;Don't tune budgets and thresholds blind. Add a few custom monitors and watch real numbers in the Debugger's Monitor tab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Battle-scene wiring. The receipt covers the governor, not this glue.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_ready&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;Performance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add_custom_monitor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"repath/requests_per_tick"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_requests_this_tick&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;Performance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add_custom_monitor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"repath/queue_depth"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_governor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queue_depth&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;Performance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add_custom_monitor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"repath/oldest_request_ticks"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_governor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oldest_request_age_ticks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Engine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_physics_frames&lt;/span&gt;&lt;span class="p"&gt;())))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each one tells you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Requests per physics tick&lt;/strong&gt; — a one-frame group-order burst shows up here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Requests per unit per second&lt;/strong&gt; — sustained every-tick target resets show up here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queue depth and oldest-request age&lt;/strong&gt; — tells you your budget is too tight for acceptable response latency.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One caveat: &lt;code&gt;path_changed&lt;/code&gt; fires with no reason payload, so you can't ask the engine &lt;em&gt;why&lt;/em&gt; it repathed. Track your own reasons where you queue the request (&lt;code&gt;GROUP_ORDER&lt;/code&gt;, &lt;code&gt;TARGET_DRIFT&lt;/code&gt;, &lt;code&gt;PERIODIC_REFRESH&lt;/code&gt;), and only infer "probably knocked off-path" when the target and map revision both stayed fixed but the agent drifted past its tolerance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause A: one click, a hundred requests
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The nice thing is you can predict the latency before you even run the game. With &lt;code&gt;N&lt;/code&gt; pending requests and a budget of &lt;code&gt;B&lt;/code&gt; per tick, draining takes &lt;code&gt;ceil(N / B)&lt;/code&gt; ticks. At 60 Hz, 500 requests with &lt;code&gt;B = 8&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The dispatch loop is bounded two ways — a query ceiling and a &lt;em&gt;separate&lt;/em&gt; scan ceiling, so that cleaning up cancelled or superseded entries can't itself blow the tick:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;drain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;run_query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Callable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;is_alive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Callable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;current_map_revision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;now_tick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;dispatched&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;scanned&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;query_limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;maxi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;queries_per_tick&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;scan_limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;maxi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_entries_scanned_per_tick&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;dispatched&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;query_limit&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;scanned&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;scan_limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_take_next_raw_entry&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_empty&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;scanned&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="c1"&gt;# ...skip stale/dead entries, then run the query and record it...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few properties that matter once you're at scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Latest target wins.&lt;/strong&gt; A repeated request for a unit still in the queue updates its target in place, instead of piling on more work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dead and cancelled units stop before the callback.&lt;/strong&gt; Their queued entries never reach the agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request and scan budgets are separate.&lt;/strong&gt; Stale cleanup can't quietly consume an unbounded tick.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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 &lt;code&gt;Array.pop_front()&lt;/code&gt; on a big queue — it shifts every remaining index. Head cursors are cheaper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause B: the target moved one pixel
&lt;/h2&gt;

&lt;p&gt;A chasing unit tends to reset &lt;code&gt;target_position&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;get_next_path_position()&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;repath_reason&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;planned_target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Vector2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;live_target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Vector2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;repath_distance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;now_tick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;next_repath_tick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;arrived&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;StringName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;arrived&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;REASON_NONE&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;drift&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;planned_target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;distance_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;live_target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;maxf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;repath_distance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;drift&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;threshold&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;drift&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;REASON_TARGET_DRIFT&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;now_tick&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;next_repath_tick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;REASON_PERIODIC_REFRESH&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;REASON_NONE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Cause C: avoidance pushes, path_max_distance pulls
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;path_max_distance&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;At crowd scale that chains: avoidance nudges one agent past its tolerance, its new route shifts the local pressure, a neighbor crosses &lt;em&gt;its&lt;/em&gt; tolerance, and so on. Before you blame this, correlate the &lt;code&gt;path_changed&lt;/code&gt; signal with an unchanged target and map revision plus measured deviation. Otherwise you're guessing.&lt;/p&gt;

&lt;p&gt;Three things help, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tune from measured deviation.&lt;/strong&gt; Raise &lt;code&gt;path_max_distance&lt;/code&gt; just enough that normal steering stays inside it, then recheck that a genuinely lost agent still recovers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freeze arrivals.&lt;/strong&gt; Units sitting inside their destination tolerance stop consuming path updates, so avoidance can't shove them into another recalculation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep steering separate from planning.&lt;/strong&gt; A momentary avoidance offset doesn't need a brand-new target assignment.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Avoidance and pathfinding are separate systems, and avoidance has its own cost knobs (&lt;code&gt;neighbor_distance&lt;/code&gt;, &lt;code&gt;max_neighbors&lt;/code&gt;). That's a deeper rabbit hole than this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the proof yourself
&lt;/h2&gt;

&lt;p&gt;There's a standalone Godot 4.7 project with the full assembled governor, a runnable scene, a verifier, README, and license: &lt;a href="https://vav-labs.com/downloads/pathforge/why-navigationagent2d-lags-with-hundreds-of-units-source.zip" rel="noopener noreferrer"&gt;source ZIP here&lt;/a&gt;. Its &lt;a href="https://vav-labs.com/downloads/pathforge/why-navigationagent2d-lags-with-hundreds-of-units-verification.json" rel="noopener noreferrer"&gt;machine-readable receipt&lt;/a&gt; reports 20/20 named checks, zero failures.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;NavigationServer2D&lt;/code&gt; region, gets both direct and &lt;code&gt;NavigationAgent2D&lt;/code&gt; paths, and drains ten requests as &lt;code&gt;3, 3, 3, 1&lt;/code&gt; under a three-query budget. The ZIP is 11,641 bytes, SHA-256 &lt;code&gt;5c58b4a53df42cf9b8aa81a0a75758bc64656435344fc0d44db36e92e6f94bef&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Honest boundary: this is scheduling-correctness and scene-integration evidence. It is &lt;strong&gt;not&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debug order, short version
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Profile — confirm the hitch is navigation, not physics/rendering/scripts.&lt;/li&gt;
&lt;li&gt;Record requests per tick, per-unit rate, your own reasons, queue depth, oldest age.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GROUP_ORDER&lt;/code&gt; spikes → Cause A. Sustained &lt;code&gt;TARGET_DRIFT&lt;/code&gt; → Cause B.&lt;/li&gt;
&lt;li&gt;Unexplained &lt;code&gt;path_changed&lt;/code&gt; with a stable target/map + measured deviation → Cause C.&lt;/li&gt;
&lt;li&gt;Check whether arrived units are still consuming path updates.&lt;/li&gt;
&lt;li&gt;Change one budget/threshold/tolerance, then re-measure the same counters.&lt;/li&gt;
&lt;li&gt;Requests minimal but one query slow → it's the mesh, not scheduling.&lt;/li&gt;
&lt;li&gt;Still need separate routes to one shared goal for a huge crowd → shared field.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The full version, with the complete governor code and the internal links to the related failure-mode guides, is on &lt;a href="https://vav-labs.com/blog/why-navigationagent2d-lags-with-hundreds-of-units/" rel="noopener noreferrer"&gt;vav-labs.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>godot</category>
      <category>gamedev</category>
      <category>pathfinding</category>
      <category>performance</category>
    </item>
    <item>
      <title>Tower Defense Path Validation in Godot</title>
      <dc:creator>Vav Labs</dc:creator>
      <pubDate>Wed, 24 Jun 2026 15:15:50 +0000</pubDate>
      <link>https://dev.to/vav_labs/tower-defense-path-validation-in-godot-12e2</link>
      <guid>https://dev.to/vav_labs/tower-defense-path-validation-in-godot-12e2</guid>
      <description>&lt;p&gt;In a tower defense, a player can drop one tower that walls off the path and softlocks the wave. The fix isn't to place it and hope pathfinding recovers — it's to validate first: treat the tower as a temporary blocker, ask if a route still exists, revert if it doesn't, and only then commit.&lt;/p&gt;

&lt;p&gt;I built a playable Godot 4.7 demo for it: place towers, watch the path reroute, try to seal it and get rejected. The guide also measures the cheap vs expensive way to run that check, with a runnable project.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://vav-labs.com/blog/tower-defense-path-validation-in-godot/" rel="noopener noreferrer"&gt;https://vav-labs.com/blog/tower-defense-path-validation-in-godot/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1llhjn6ghfoshlxo7xii.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1llhjn6ghfoshlxo7xii.gif" alt=" " width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>godot</category>
      <category>gamedev</category>
      <category>pathfinding</category>
    </item>
    <item>
      <title>Godot's NavigationObstacle2D has two modes (and other NavigationServer2D gotchas)</title>
      <dc:creator>Vav Labs</dc:creator>
      <pubDate>Tue, 23 Jun 2026 09:42:09 +0000</pubDate>
      <link>https://dev.to/vav_labs/godots-navigationobstacle2d-has-two-modes-and-other-navigationserver2d-gotchas-1l6</link>
      <guid>https://dev.to/vav_labs/godots-navigationobstacle2d-has-two-modes-and-other-navigationserver2d-gotchas-1l6</guid>
      <description>&lt;p&gt;Godot's NavigationServer2D is powerful and quietly confusing. Most of the pain isn't the API — it's a handful of distinctions that look similar and behave nothing alike. The ones that eat afternoons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NavigationObstacle2D has two modes, and people conflate them.&lt;/strong&gt; &lt;code&gt;avoidance_enabled&lt;/code&gt; makes your agents steer around an obstacle, but the path query still runs straight through it — so your tower-defense blocker doesn't block the route. &lt;code&gt;affect_navigation_mesh&lt;/code&gt; is the other one: it carves the obstacle into the navmesh at bake time, and &lt;em&gt;then&lt;/em&gt; the path reroutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The empty path.&lt;/strong&gt; A query returns nothing even though the scene looks walkable — usually because the navmesh was never baked. It's the NavigationServer2D version of forgetting &lt;code&gt;AStarGrid2D.update()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Map-sync timing.&lt;/strong&gt; NavigationServer changes apply at the end of the physics frame. Query in &lt;code&gt;_ready()&lt;/code&gt; or right after editing nav data and you get a stale or empty result — then blame the agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NavigationAgent2D isn't the map.&lt;/strong&gt; The agent is a node-level helper that consumes navigation data; it doesn't make the map valid by itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I wrote the full guide — regions, agents, direct path queries, both obstacle modes, links, and the map-sync trap — checked every claim against the docs and the C++ source, and built an interactive playground for the gotchas. (It's a browser illustration of how the system behaves, not the engine running, and there's no benchmark here — it's a guide, not a measurement.)&lt;/p&gt;

&lt;p&gt;Full guide + playground: &lt;strong&gt;&lt;a href="https://vav-labs.com/blog/navigationserver2d-godot-complete-guide/" rel="noopener noreferrer"&gt;https://vav-labs.com/blog/navigationserver2d-godot-complete-guide/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If any of it's wrong or out of date for your version, tell me — the navigation API has moved across Godot 4.x, and I'd rather fix it than leave you debugging my mistake.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0x6k3z4a31nv2736h8qk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0x6k3z4a31nv2736h8qk.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>AStarGrid2D in Godot 4: the gotchas, and an interactive sandbox</title>
      <dc:creator>Vav Labs</dc:creator>
      <pubDate>Sun, 21 Jun 2026 23:20:15 +0000</pubDate>
      <link>https://dev.to/vav_labs/astargrid2d-in-godot-4-the-gotchas-and-an-interactive-sandbox-3dl8</link>
      <guid>https://dev.to/vav_labs/astargrid2d-in-godot-4-the-gotchas-and-an-interactive-sandbox-3dl8</guid>
      <description>&lt;p&gt;Godot's &lt;code&gt;AStarGrid2D&lt;/code&gt; is a ten-minute read and a month of gotchas. The API page is small, you skim it, and then your path comes back empty, your units cut through wall corners, or your weighted swamp gets ignored. None of it is a bug. It's the gap between knowing the method names and knowing which handful of settings actually decide the behavior.&lt;/p&gt;

&lt;p&gt;The five that get almost everyone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You forget &lt;code&gt;update()&lt;/code&gt;.&lt;/strong&gt; Change the region or cell size, skip the rebuild, and you get an empty array with no error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;update()&lt;/code&gt; wipes your point data.&lt;/strong&gt; It clears every solid cell and weight, so you have to set those &lt;em&gt;after&lt;/em&gt; you call it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diagonal corner-cutting.&lt;/strong&gt; &lt;code&gt;DIAGONAL_MODE_ALWAYS&lt;/code&gt; lets units squeeze diagonally between two walls. You usually want &lt;code&gt;ONLY_IF_NO_OBSTACLES&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;jumping_enabled&lt;/code&gt; silently ignores weight scale.&lt;/strong&gt; Turn on JPS and your weighted terrain stops mattering. It's in the docs. People still lose an afternoon to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manhattan overestimates with diagonals.&lt;/strong&gt; On an 8-direction grid it counts a diagonal as two steps, so your "shortest" path isn't. Reach for Octile.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I wrote the reference I actually wanted (every property and method, the gotcha attached to each, version-honest across Godot 4.0 → 4.7, checked against the official docs and the C++ source), and built an interactive sandbox for the parts you have to &lt;em&gt;see&lt;/em&gt;: drag the goal, paint solid and weighted cells, flip diagonal modes, toggle jumping, and watch the path recompute live. (It's a browser illustration of how AStarGrid2D behaves, not the engine itself running in a tab.)&lt;/p&gt;

&lt;p&gt;Full reference and the sandbox: &lt;strong&gt;&lt;a href="https://vav-labs.com/blog/astargrid2d-complete-reference/" rel="noopener noreferrer"&gt;https://vav-labs.com/blog/astargrid2d-complete-reference/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If any of it's wrong or out of date for your version, tell me. The API moved across Godot 4.x, and I'd rather fix it than leave you debugging my mistake.&lt;/p&gt;

</description>
      <category>godot</category>
      <category>gamedev</category>
      <category>gdscript</category>
      <category>pathfinding</category>
    </item>
    <item>
      <title>Moving 10,000 agents in Godot without the frame spike</title>
      <dc:creator>Vav Labs</dc:creator>
      <pubDate>Tue, 16 Jun 2026 15:08:59 +0000</pubDate>
      <link>https://dev.to/vav_labs/moving-10000-agents-in-godot-without-the-frame-spike-1l9n</link>
      <guid>https://dev.to/vav_labs/moving-10000-agents-in-godot-without-the-frame-spike-1l9n</guid>
      <description>&lt;p&gt;I kept reading that Godot pathfinding falls apart with a lot of agents, so I actually measured it. 500 agents each calling AStarGrid2D.get_id_path() every frame gave me a 670 ms median frame on an 8-year-old desktop — a slideshow. Swap those 500 per-agent queries for one shared field the whole crowd reads, and the same agents drop to ~2 ms, nothing over the 16.6 ms budget. The fix was the shape of the work, not a faster solver — and the same approach holds 10,000 agents at 77 FPS in a browser tab, pure GDScript.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/4I9pNktBq1M"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Full write-up with the measured ladder out to 20,000 agents, a playable demo (drag the goal, flip naive vs scheduled, push the count yourself), and the benchmark JSON for every count: &lt;a href="https://vav-labs.com/blog/moving-10000-agents-in-godot/" rel="noopener noreferrer"&gt;https://vav-labs.com/blog/moving-10000-agents-in-godot/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>godot</category>
      <category>gamedev</category>
      <category>performance</category>
      <category>gdscript</category>
    </item>
  </channel>
</rss>
