How to fake ragdolls for thousands of enemies in Unity DOTS

How to knock thousands of enemies flying in Unity DOTS without bone physics: the body as one point, deterministic launch, walkable landing and tumbling.

Characters thrown by an explosion fly along arcs and tumble in the air

When an explosion throws hundreds of enemies at once, a real ragdoll for each of them is too expensive. You build a fake ragdoll in Unity DOTS by simulating the whole enemy body as a single point with its own simple physics and only rotating the model around the body's center. Falling and getting up are ordinary animation clips. No bones are simulated at all.

We show the approach on a PC tower defense game we are building in Unity with ECS (more in our references). In that game, a bomb or a fireball knocks back dozens to hundreds of enemies at once. With 2,000 bodies in the air, the simulation costs 0.53 to 0.59 ms per tick (a tick is one fixed-length step of the game simulation).

Why not a real ragdoll

A real ragdoll means ten or more physics bodies connected by joints per character. With thousands of characters on screen, that is not realistic.

The second reason is determinism: the same state has to produce the same result. Our game relies on it to replay a lost wave from a checkpoint, and we check it with a hash of the simulation state after 1,800 ticks.

The body as a single point

Instead of a physics engine, use your own simple 2.5D physics: a position on the grid, height above the ground, ballistic flight, bouncing off walls and friction. In our game the bomb moves this way too, and an enemy hit by an explosion briefly becomes the same kind of body. The inspiration was the way soldiers fly in Diplomacy is Not an Option.

The simulation only knows position, height and rotation angle. Don't write the model's rotation into it. It belongs in the entity's additional transform matrix, which the Entities package (ECS in Unity) supports for non-uniform scale (documentation).

Phases of a knocked-down enemy

A knocked-down enemy goes through three phases and then returns to the crowd:

on hit:
    compute launch velocity (force, distance from the explosion, randomness from the seed)
    find a landing on a walkable cell (see below)
    enable the "knocked" state, release the spot it held at the attack target
flight:     the body flies along a parabola, the model rotates around the body center
landing:    pose from the rotation (back or belly), short settle
lying:      waits a fixed number of ticks
getting up: get-up clip from the back or from the belly, then back to the crowd

Make the "knocked" state a component that can be switched on and off without moving the entity between chunks (the memory blocks in which ECS keeps entities with the same set of components). Navigation, crowd separation and attack decisions then simply skip knocked-down enemies. How navigation and separation work for a large crowd is covered in the article on horde navigation in Unity DOTS.

Launch: higher, not farther

The simplest launch uses an angle of 45° ± 10°. With a bomb force of 18 m/s and gravity of 20 m/s², the apex comes out at around 4 m. The body flies low over the crowd and the effect gets lost in it.

More force doesn't help, because the range grows with it. Split the velocity into two components and scale each one separately: an angle of 60° ± 5°, horizontal component × 0.85, vertical × 1.3. The apex is then around 10 m, the range stays at around 15.5 m and the flight takes roughly 2 seconds.

Side view: a low dashed arc and a high arc over a house with silhouettes of a character rotating during the flight
Dashed: a 45° launch. Solid: a 60° launch with separate scales. The house is 5 m tall.

The direction deviates from the explosion by ± 15°, the force by ± 20%, and at the edge of the explosion the force drops to half. Don't take randomness from a global generator. Derive it from a hash of the level seed, the enemy's identifier and the explosion's sequence number. Process knockdown requests in sorted order, because jobs running on several threads produce them in arbitrary order. Two runs of the same wave then give the same arcs.

Knockdown is yes or no. Every knockable enemy at least falls when hit (the speed has a lower bound of 2 m/s), and resistance only changes the range. Give immunity its own flag; in our game the largest enemy type has it. If immunity came from resistance (at 0.95 the speed falls below 2 m/s) and weak knockdowns were cancelled, the enemy would only be immune by accident.

Landing on a walkable cell

An enemy must not land in a ravine, on a cliff or inside a tower's footprint. Walkability is defined per grid cell and separately for each enemy size class.

Bouncing off walls in the air is not enough

The obvious idea is to treat non-walkable cells as walls the flying body bounces off. Next to a cliff that looks natural, next to a ravine it doesn't: above a ravine there is only air, but the cell is non-walkable, so the body bounces off an invisible column above its edge. On top of that, nothing guarantees that it ends up in a good spot after the bounce.

Side view: a body flies over a ravine, bounces mid-flight off an invisible column above its edge and falls back
Bouncing off a non-walkable cell. The body bounces off the air above the edge of the ravine.

Trajectory prediction at launch

It is more reliable to find the landing right at launch. Simulate the flight ahead with the same step as the real physics and check the landing cell for the enemy's size class. If it isn't walkable, try other variants in a fixed order:

for angle in [0, +25, -25, +50, -50, ... , +175, -175]:
    for horizontal speed multiplier in [1, 0.7, 0.4, 0.1]:
        simulate the flight
        if the landing cell is walkable for the enemy's class: use this direction and stop
if nothing worked: horizontal speed = 0 (hop in place)
Top view of a grid with a ravine and a raised cliff, crossed-out rejected landings and the first walkable one highlighted
Explosion, enemy, rejected landings and the chosen one. The grey dots are all candidate landings.

The worst case is 60 flight simulations per enemy (15 directions × 4 multipliers). The vertical speed doesn't change, so the flight height stays the same and only the range gets shorter. If another explosion hits the enemy during the flight, run the prediction again.

A 2D illustration: two enemies would land in the ravine (crosses), the prediction shortens their flight.

To keep the body from sliding or bouncing after landing, give it a ground restitution of 0 and high friction. It stays where the prediction said it would.

Rotation planned from the flight height

The simplest option is to spin the model at a random speed, for example 180 to 540 °/s, and settle it onto the nearest lying pose within 0.2 s after landing. The settle is visible, though, when the body lands almost upright. Adjusting the rotation speed every tick so that the angle comes out exactly horizontal at landing only helps partly: even a low throw spins like a somersault, and the rotation has nothing to do with how high the body flies.

It works better to plan the rotation at launch from the flight height:

height = vz² / (2 g), flight time from vz and height
target = nearest horizontal pose in a random direction (quarter turn from standing)
target += half turn for every 2.5 m of height, at most 3
rotation speed = (target - current angle) / flight time
Two arcs: a low throw where the character only falls over, and a high one where it makes three and a quarter turns
A low throw (apex around 2 m) rotates by 90°, a bomb (around 10 m) by 90° plus three half turns.

The landing side follows from the angle: face to the ground means belly, otherwise back. It selects the get-up clip. An enemy that is lying or getting up keeps its current angle when hit again and doesn't snap back to upright.

Putting the model on the ground

The model rotates around the body's center, but the ground is under its lowest point, and that point changes with the rotation. Estimating the offset from two constants and the sine of the angle doesn't work for models with arms spread wide in the fall clip: in our game the skeleton model (a common enemy) sank up to 1.15 m into the ground and the necromancer model up to 1.73 m.

EstimateSix rotations of a character, the parts below the ground are red
MeasuredSix rotations of a character, each touches the ground with its lowest point
The same rotations with the offset from constants and from the measured table.

It is more reliable to measure the lowest point. When baking the animations, compute the body's lowest point for the fall clip at 72 rotations in 5° steps, and interpolate from the table at runtime. The deepest sinking then drops to 4 mm. A single minimum across all frames of the clip is not enough, though: the body would hover 0.15 to 0.28 m above the ground after landing until the get-up clip pulls it down. So the table has to be frames × rotations.

The slow planned rotation reveals one more effect. If you place the model with its lowest point at the flight height, the body's center drops by the difference between half the character's height and half its thickness as it goes from standing to horizontal. The body then rises only slowly at the start of the flight and speeds up afterwards. Lift the model by the difference between its half extent when standing and at the current angle, at most by the flight height. The body's center then follows the parabola.

Chart of the body center's height over time: a dashed ideal parabola, a red curve with a dip and a blue one that follows the parabola
Height of the body center during the flight: without the lift (red) and with it (blue).

Dead enemies: body, decal and dust

An enemy killed by an explosion should fly too. Create a flying body for it: a copy of the entity without gameplay components (health, reward, target). The body flies, lands, lies for a while and disappears. A corpse decal (a texture projected onto the ground) as in Warcraft 3 and a puff of dust stay in its place. In our game the limits are 1,000 flying bodies and 1,500 decals.

The decal is a top-down view of a frame from the fall clip, rotated the same way as the lying body, so the swap from body to decal isn't visible. The first frame of the get-up clip doesn't work, because the character is already sitting up from its back. Merge dust puffs within a 1.5 m cell. A bomb into a crowd then creates a few larger clouds instead of hundreds of small ones.

Traps in Unity Entities

  • The matrix from the baker disappears. If you add the additional matrix at bake time, the transform system removes it at uniform scale and knocked enemies don't move. Request non-uniform scale when baking. The prefab's whole scale then moves into that same matrix, so don't overwrite it with the rotation alone. Otherwise a knocked enemy scaled 2× ends up half its size, even after getting up. Store the scale separately and build the matrix from scale and rotation.
  • Root height when importing clips. With humanoid clips, the root's vertical motion is thrown away and characters fall through the floor when getting up. Bake the root height into the pose. Don't use the option to anchor the pose to the feet: it moves the whole body a meter down.
  • Matrix in closed form. Translation, rotation and reverse translation as three matrix multiplications are measurably slower in a job than a hand-written formula for the resulting matrix.
  • Burst running an old version. After you change the fields of a component read by a Burst job, the editor may crash or report nonsense. Restarting the editor fixes it.

Performance

Measured in the Unity Editor on an M3 Pro, 10,000 skeletons in a benchmark scene, launch force 18. The times are the sum of the Burst jobs' CPU time across all threads, not frame time. The chart and the table are without trajectory prediction and landing tables.

Chart of time per tick by the number of knocked enemies for the simulation and visual preparation, with a 0.5 ms target line
Simulation and visual preparation without trajectory prediction. Target 0.5 ms at 2,000 knocked.
knocked simulation (ms/tick) visual preparation (ms)
500 0.28 to 0.30 0.15
2,000 0.53 0.30 to 0.55
5,000 0.74 to 0.75 0.49 to 0.91
10,000 0.96 to 1.01 0.68 to 1.24
  • The simulation grows linearly, roughly 0.25 ms per thousand knocked. With trajectory prediction and landing tables it is 0.58 to 0.59 ms at 2,000 knocked, slightly above the 0.5 ms target. A sensible cap is therefore 3,000 enemies knocked at once, which is about 0.8 ms.
  • An explosion into a group of 359 enemies next to a wall costs 1.3 to 1.9 ms in the knockdown request system in a single frame. Most of that is the trajectory prediction.
  • Editor FPS can't be compared: two runs of the same configuration differed by up to 80 FPS.

Limits of the solution

Visual preparation went up to 1.3 ms in another test scene, probably because of reading the landing table for every body. It is the first candidate for optimization. FPS in a build and the load of killing 1,000 enemies at once (target: at most 1.5 ms extra per frame) are not measured yet.

If you are working on a similar crowd or effects simulation in Unity and need help, get in touch.

← More in Tower Defense