<?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>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>
