How to navigate a horde of thousands in Unity DOTS
A guide to moving thousands of enemies in Unity DOTS: flow fields, pushing off neighbours and walls, crowd density cost, chokepoint jams and performance.

Steer a horde of thousands of enemies in Unity DOTS with one flow field for the whole map and a few cheap local rules: pushing off neighbours and walls, path cost that grows with crowd density, and a "waiting" state in jams. In the Unity Editor on a MacBook with an M3 Pro, that handles around 9,600 enemies at once at 90 FPS.
The problem shows up in tower defense, strategy and survival games where thousands of units head for a goal at the same time. Classic per-unit pathfinding is too expensive, and without local rules the crowd overlaps, hugs walls and gets stuck in chokepoints. Unity DOTS (Data-Oriented Technology Stack: Entities, ECS and the Job System) gives you the performance for thousands of entities, but it doesn't solve navigation for you. We use the approach below in our PC tower defense game (it's in our portfolio). The images come from a simplified 2D simulation running the same rules. They are not game footage.
Step 1: A flow field instead of a path per unit
A flow field is a grid over the whole map in which every cell knows which direction leads to the goal. An enemy doesn't search for a path, it just reads the direction at the spot where it stands. The cost is the same for 10 or 10,000 enemies.
Split the map into 1 m cells (our test level is 128 × 128) and, once per goal, compute the distance from every cell to the goal. A static field can be computed in the editor when the level is prepared and stored in an asset.
The simplest version is Dijkstra over 8 neighbours with one of 8 directions per cell. It works, but enemies walk diagonals and stick to walls in single file.


A smooth field gives a better result. Compute the distance by solving the eikonal equation (a wavefront equation whose solution is the shortest distance in continuous space, not just along grid edges) with the fast sweeping method, which passes over the grid repeatedly from all four corners. The direction is then not the direction of a cell but the distance gradient at the enemy's exact position. Add a surcharge to each cell's cost for being close to a wall, so the field itself keeps the crowd a little away from obstacles.
Step 2: A separate field for each enemy size
A large enemy doesn't fit everywhere a small one does. Introduce size classes: each has its own walkability (a cell must be far enough from walls) and its own field per goal. Keep the classes as level data, not in code. We use three.

Step 3: Pushing off neighbours
A flow field gives a direction but knows nothing about neighbours. The basis is a simplified boids model (the classic flocking rules): each enemy is repelled by neighbours within 0.9 m. Find neighbours in a spatial hash, a grid where every cell holds a list of the units inside it. Rebuild the hash every tick and sort it so the order stays deterministic.
Repulsion alone isn't enough. At 3,000 enemies, 83.7% of them had their nearest neighbour closer than 0.8 m, and the smallest distance was 4 cm, meaning two enemies were almost inside each other.
A higher separation weight doesn't fix it. At 6 instead of 1.5 the share of tightly packed enemies dropped to 54.6%, at 12 to 44.3%, at 24 there was no further gain (48.2%). On top of that the crowd starts to jitter, because unbounded pushes from many neighbours add up at once.
Positional correction helps. After moving, push every overlapping pair apart, each by half the overlap, and cap the shift at 0.15 m per tick. Without the cap the correction jitters just like the high weight. With it, 60.8% stay tightly packed, but the smallest distance rises to 28 cm and the crowd stays calm.


Step 4: Pushing off walls
Next to a wall, correction between enemies fails. Moving into walls is forbidden (the move is split per axis and the part going into the wall is dropped), so a pinned enemy has nowhere to go and the push from its neighbour is lost. Out of 938 overlapping enemies, only 35 had any push from a wall.
So add a push away from the nearest blocked cells, in 4 directions and diagonally. Two mistakes you can avoid:
- Inner corner. Pushes from both edges and from the corner add up and fling the enemy away. Only add the corner push at an outer corner.
- Real walls. Push off the real walls, not off the walkability of the size class. For a large enemy the "wall" starts a metre before the real one. At a corridor entrance, the corner push cancelled a large enemy's forward motion and the whole group behind it got stuck.
Step 5: Density cost and slowdown
Even with a smooth field the whole crowd takes the shortest path tight around the corner, no matter how many enemies are already there. Continuum Crowds (Treuille et al., 2006) offers a solution: cell cost grows with crowd density. Every 10 ticks, count the enemies per cell, smooth the result and recompute the field of one goal. At a crowded corner the field then sends enemies around, through less crowded cells.


The other half is slowing down. An enemy in a dense cell moves slower, down to 15% of its speed. Measured on 2,250 enemies: in sparse cells they moved at 94% of their speed on average, in medium density at 71%, in a jam at 20%. Let the slowdown start early: with a free-density threshold of 0.6 enemies per cell instead of 1, the share of tightly packed enemies in the chokepoint fell to 36.7%.
A practical tip: allocate arrays created at runtime (such as the density map) before a job uses them for the first time. A job scheduled with an array that doesn't exist yet crashes the Unity Editor in native code, without an exception.
Step 6: Jams at a chokepoint: wait, don't push
When there's no room to spread, the chokepoint remains. The simplest idea is to drop the velocity component towards a neighbour the enemy is touching. The ones at the back then stop instead of stepping aside and keep the jam alive.
Stepping aside, for example by 35° to the side with fewer neighbours, is better, but the crowd starts to flicker. Smoothing the heading and lowering the turn speed (180 instead of 360 °/s) only dampens the symptom. The cause is elsewhere: with balanced density the enemy picks a different side every tick. Hysteresis, a bias towards the previous decision, fixes it: the side from the previous tick gets a bonus of 3 neighbours in the comparison.
The final solution combines a contact constraint with three states: moving, sidestepping, waiting. The velocity component into a touching neighbour is dropped, the sideways one stays. Whoever has neither a free side nor room ahead waits and doesn't push.
for each enemy, every tick:
velocity = flow field direction × speed × density slowdown
velocity += push from neighbours
if touching a neighbour ahead:
if a side is free: sidestep (side with hysteresis)
else: wait
drop the velocity component into touching neighbours
move, drop the component into walls
then for everyone:
push overlapping pairs apart (half the overlap, at most 0.15 m)
push off walls

Watch out for three things:
- Patience. When two enemies meet head-on, let the one with the higher ID wait. A chain of neighbours can keep it blocked for thousands of ticks, though, so after a waiting limit it tries an emergency sidestep.
- Zero velocity. An enemy whose desired velocity is zero never leaves the "waiting" state and stays standing in the open after a wave. An enemy that doesn't want to move should always be "moving".
- Narrow cone. Look for a neighbour ahead within a narrow cone and with a short probe. At 45°, the settings sent 55 to 60% of the crowd into waiting or sidestepping even in the open; at 30° with a shorter probe, 41%.
Step 7: Overtaking large enemies
Enemy types of different sizes and speeds bring more cases. Our large, slow enemy has a crowd radius of 1.25 m and walks at 1.1 m/s. A fast enemy behind it couldn't overtake: the side probes diagonally ahead were always closer to the large one than the sum of radii, both sides came out blocked and the fast enemy waited. The fix: the probe only counts the neighbour being passed on that neighbour's own side. A neighbour straight ahead blocks neither side.
The large enemy in turn kicks small ones out of its way to the sides, within ±45° in front of it. Don't kick those moving in its direction faster than it wants to go, or it will also kick the ones currently overtaking it.
Step 8: Slots around the attack target
If enemies attack in melee, they need room around the target. Our tower has 24 slots around its 5 × 5 m footprint, each enemy takes one and the rest wait in a queue.

- Circle around. If you only offer a slot when a straight line leads to it, the rear slots stay empty, because from the front the line to them crosses the tower. Let the enemy circle the tower in an arc to a free slot.
- No eviction. Don't evict an enemy on its way to a slot in favour of another requester. It starts chains of evictions.
- No pushing at the slot. An enemy arriving next to an occupied slot gets pushed by its neighbour and ends up 13 cm from its target or completely off it. Enemies in a slot, and those within 0.5 m of it, don't push each other.
Simulation and rendering performance
A benchmark in the Unity Editor (M3 Pro, around 9,600 simple enemies, two chokepoints) gave 90 FPS at 1440p and 100 at 1080p. Resolution barely changes FPS, time acceleration does: at 10× it drops to 20 FPS, because each frame has to run ten simulation steps. At normal speed the simulation has headroom.
With a detailed enemy model instead of a capsule, the bottleneck moves to rendering:
| Variant | On screen | Triangles per model | Median FPS |
|---|---|---|---|
| Capsule | 2,612 | 844 | 197 |
| Detailed model, shadows on | 2,976 | 3,578 | 48 |
| Detailed model, shadows off | 2,976 | 3,578 | 85 |
| Simplified model, shadows on | 2,945 | 878 | 335 |
In the simulation, three things pay off:
- Two neighbour passes. The cheap one (contacts, blocking) always runs, the expensive one (side probes, side density) only for blocked enemies, a minority in free flow. Compare distances squared, without a square root.
- Simple sorting. Sorting the spatial hash synchronously took 0.37, 1.49, 3.16 and 8.46 ms at 3,000, 10,000, 20,000 and 50,000 entries, the job version 0.18, 0.93, 3.12 and 15.53 ms. The job isn't consistently faster, so simple sorting is enough.
- State hash. At ticks 600 and 1800, hash positions and health. A refactor must give the same hash, a behaviour change alters it and you can explain why. This works thanks to the deterministic order in the spatial hash.
Limits of the approach
- Two full crowds going in opposite directions through one chokepoint get stuck for good. That's a limit of a model without physics.
- The numbers come from the Unity Editor on an M3 Pro. We don't have measurements on a Steam Deck or in a real build yet, only an estimate from public CPU benchmarks.
If you want to try your own crowd, the Unity Entities documentation is a good start. A similar path from the simplest solution to a working one is described in our post on the sudoku generator. How to throw enemies in such a crowd around with an explosion without an expensive ragdoll for each one is covered in how to fake ragdolls in Unity DOTS. If you're working on a similar simulation in Unity and want help, get in touch.


