NavMesh, A* and flow fields: 200 agents without the jam
Why pathfinding jams in a 200-agent siege scene, where the NavMesh quietly lies to you, and exactly where the line between A* and a flow field runs.
Two hundred agents stuck at one gate
Late last year I was working on a siege scene: one castle gate and 200 NavMeshAgent instances running at it. Navigation in game AI falls apart in exactly this kind of scene. The recording QA attached to the nightly build always froze at the same spot: fifteen metres short of the gate, around sixty agents were grinding against each other and shivering, some of them walking backwards. In the first ten seconds only about forty of them made it into the courtyard behind the gate.
Frame time was 11 ms while the agents were far away and climbed to 26 ms as they closed on the gate. My first guess was that A* had blown up. The profiler disagreed: NavMesh.CalculatePath summed to 3,2 ms while NavMeshAgent's own internal update sat at 9,8 ms. So the bottleneck was not in the search, it was in the work each agent does every frame.
Two separate problems were showing up at once and had been mistaken for one: the cost of computing the global path, and how the agents behave relative to each other. Any discussion that files those under a single heading stalls, because the fixes live in completely different places. Until you separate them you can never tell which knob moved which number. For us the split started with one habit: tracking the search line and the agent-update line separately in the profiler.
The cost of A* and hierarchical pathfinding
A* pathfinding cost scales with the number of nodes it expands. On a 41.000-polygon NavMesh, one request from one end of the map to the other took 0,35 ms. Two hundred agents asking in the same frame would be 70 ms, but you never see a hitch like that: Unity queues the requests, the path arrives three or four frames late, and the agents keep running the old heading meanwhile. The symptom is not a stall, it is a late turn.
Hierarchical pathfinding cuts that cost in half twice over. You split the map into regions and write the transitions between them into a portal graph; the search runs first on a coarse graph of 30-40 nodes (0,02 ms), then a detailed A* runs only as far as the next portal. Our average request dropped from 0,35 ms to 0,06 ms, and a full agent path was never computed in one go again. The coarse graph is built once at bake time and only updated at runtime when a dynamic obstacle closes a portal.
The alternative we tried and dropped was a shared path cache. We rounded destinations to 4 m cells and handed one path to every agent heading for the same cell. Hit rate was 70% against static targets but fell to 18% while chasing the player, and the invalidation logic gave back more than the cache saved. We deleted the code. Path sharing only earns its place where the destination holds still for tens of frames.
One flow field for the whole crowd
If 200 agents share a destination, running 200 separate searches makes no sense. A flow field runs a single cost propagation backwards from the goal, then writes into every cell a direction vector pointing at its cheapest neighbour. Ours was a 128x128 grid at 0,5 m per cell, and a full rebuild took 4,1 ms. The propagation fills every cell in one pass, so the cost is entirely independent of how many agents you have.
What matters is that the 4,1 ms is paid when the goal cell changes, not every frame. While the player was running that happened three or four times a second, so roughly 14 ms per second. Per-agent cost collapses to two bilinear samples: 0,004 ms, or 0,8 ms across 200 agents. The same crowd cost 12 ms through hierarchical A*.
The limit is sharp: every distinct destination means another field. Past three or four goals, memory and rebuild cost overtake A*. So we stayed hybrid, with named NPCs and boss agents on A* and the crowd simulation on the flow field. Both stand on the same NavMesh triangles; only the source of the heading differs. The split lives in a single bool in code, and a designer can flip it on the prefab.
Dynamic obstacles and the path request budget
Dynamic obstacles use NavMeshObstacle with carving, but carving re-bakes the tile on every move. With 12 rolling barrels in the scene we were seeing regular 5,8 ms spikes. Raising carvingMoveThreshold to 0,5 m and enabling carveOnlyStationary brought the same scene down to 0,9 ms. Never make a continuously moving thing an obstacle; those belong to local avoidance.
The budget part is simple: fix the number of full path requests allowed per frame. Ours is 12. Agents ask on a 0,45 second interval plus a random 0-0,15 second offset, because without that jitter the spawn wave lands every request in the same frame. When the budget is full the request is not cancelled, it slides to the next frame, and nobody notices because the agent keeps walking its existing path in the meantime. Measure that budget on your target hardware; the 12 we use on console sits comfortably at 30 on desktop.
In this scene total frame time went from 26 ms to 13,4 ms and pathfinding's share from 13 ms to 2,1 ms, and the cheapest win inside that was the one-line priority change. Order the work this way: verify the NavMesh geometry with your eyes first, then put a per-frame request budget in place, then move the crowd onto a flow field, and touch local avoidance settings last. Go in the reverse order and hours disappear into ORCA parameters while the 0,6 metre doorway stays exactly where it was. Do not change a setting you have not measured; every number here came out of the profiler, none of them out of a guess.