DEV Community

Vav Labs
Vav Labs

Posted on

Why NavigationAgent2D Lags With Hundreds of Units

Originally published on vav-labs.com. Everything here is against Godot 4.7 stable, and there's a runnable source project with a verification receipt at the end.

If your game runs fine with a dozen 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.

It's how many path requests you started in one physics tick.

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

So before you change anything, count.

First, confirm navigation actually owns the hitch

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.

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

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

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.

Count the requests before you tune them

Don't tune budgets and thresholds blind. Add a few custom monitors and watch real numbers in the Debugger's Monitor tab:

# Battle-scene wiring. The receipt covers the governor, not this glue.
func _ready() -> void:
    Performance.add_custom_monitor("repath/requests_per_tick",
        func(): return _requests_this_tick)
    Performance.add_custom_monitor("repath/queue_depth",
        func(): return _governor.queue_depth())
    Performance.add_custom_monitor("repath/oldest_request_ticks",
        func(): return _governor.oldest_request_age_ticks(
            int(Engine.get_physics_frames())))
Enter fullscreen mode Exit fullscreen mode

What each one tells you:

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

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

Cause A: one click, a hundred requests

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.

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

The dispatch loop is bounded two ways — a query ceiling and a separate scan ceiling, so that cleaning up cancelled or superseded entries can't itself blow the tick:

func drain(
    run_query: Callable,
    is_alive: Callable,
    current_map_revision: int,
    now_tick: int
) -> Array[Dictionary]:
    var dispatched: Array[Dictionary] = []
    var scanned := 0
    var query_limit: int = maxi(0, queries_per_tick)
    var scan_limit: int = maxi(0, max_entries_scanned_per_tick)

    while dispatched.size() < query_limit and scanned < scan_limit:
        var entry := _take_next_raw_entry()
        if entry.is_empty():
            break
        scanned += 1
        # ...skip stale/dead entries, then run the query and record it...
Enter fullscreen mode Exit fullscreen mode

A few properties that matter once you're at scale:

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

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 Array.pop_front() on a big queue — it shifts every remaining index. Head cursors are cheaper.

Cause B: the target moved one pixel

A chasing unit tends to reset target_position 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.

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 get_next_path_position().

static func repath_reason(
    planned_target: Vector2,
    live_target: Vector2,
    repath_distance: float,
    now_tick: int,
    next_repath_tick: int,
    arrived: bool
) -> StringName:
    if arrived:
        return REASON_NONE
    var drift := planned_target.distance_to(live_target)
    var threshold := maxf(0.0, repath_distance)
    if drift > 0.0 and (threshold == 0.0 or drift >= threshold):
        return REASON_TARGET_DRIFT
    if now_tick >= next_repath_tick:
        return REASON_PERIODIC_REFRESH
    return REASON_NONE
Enter fullscreen mode Exit fullscreen mode

Cause C: avoidance pushes, path_max_distance pulls

path_max_distance 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.

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

Three things help, in order:

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

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

Run the proof yourself

There's a standalone Godot 4.7 project with the full assembled governor, a runnable scene, a verifier, README, and license: source ZIP here. Its machine-readable receipt reports 20/20 named checks, zero failures.

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

Honest boundary: this is scheduling-correctness and scene-integration evidence. It is not 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.

Debug order, short version

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

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.

The full version, with the complete governor code and the internal links to the related failure-mode guides, is on vav-labs.com.

Top comments (0)