Dead Rounds keeps up to 100 animated zombies on screen in a plain browser tab by drawing each body part of every zombie in a single instanced draw call, baking animations into lookup tables at load time, and moving only 12 real lights around a map that has dozens of lamps. This post explains each of those choices for anyone building a game with Three.js, and why we made them.
The problem
A wave survival game gets harder by sending more enemies. In late rounds we allow 100 zombies alive at once. The naive approach in Three.js is one skinned mesh per zombie, each with its own animation mixer. That costs one draw call per material per zombie, plus skinning work on the CPU or GPU for every one of them, every frame. At 100 zombies on a laptop's integrated graphics, that is not playable.
1. Jointed models instead of skinned ones
Our zombie model is a jointed mannequin: one rigid mesh per bone, no skin weights. The head is one piece, each forearm is one piece, and so on. It suits the look we want and it makes the next step possible.
2. One instanced mesh per body part
Because every part is rigid, every "left forearm" of every zombie on the map can be drawn with one
InstancedMesh. The whole horde costs one draw call per body part, the same whether there are 5 zombies or 100. Each frame we only write one matrix per part per zombie into the instance buffers.
Variety comes cheaply: each zombie gets a ±5% size jitter and a colour tint from its id, and each animation exists in three variants (three different walks, three attacks, and so on), so a crowd does not move in lockstep.
3. Animations baked at load time
At load we sample every animation clip into per-part transforms. At runtime, posing a zombie is a table lookup and an interpolation, with no animation mixer and no bone hierarchy to walk. Clip choice comes from the zombie's state in the network snapshot (walking, swinging, climbing through a window, tearing off a plank, dying) and walk speed follows how fast it is actually moving on screen, so feet do not slide.
4. A pool of 12 lights
Our maps have many lamps, up to 99 on the largest. Three.js forward rendering shades every pixel against every light, so 99 point lights cost about four times the GPU time of 12. We keep only 12 real point lights and, every frame, move them to the 10 lamps nearest the camera (weighted by each lamp's range), fading them in and out so you never see a light pop. Fog and a hemisphere light fill in the rest. Baked lighting is the long-term plan.
5. Small, frequent snapshots
In co-op the server runs the game at 30 ticks per second and sends each player a snapshot of the world. A zombie in a snapshot is just a few numbers: id, position, heading, state. A full snapshot is about 1.6 KB with 5 zombies and about 4.5 KB with 100. The client draws other players and zombies 100 ms in the past, blending between two snapshots, so movement stays smooth even when packets arrive unevenly.
6. One code path for solo and co-op
Solo mode runs the exact same server code inside the browser tab. That doubled as a performance test: if solo runs well with the simulation and the renderer sharing one tab, co-op (where the simulation is on the server) runs better.
Results
The cost that grows with zombie count is now mostly the per-instance matrix updates, which are cheap, and the snapshot size, which is small. The server's whole tick for a room with about 100 zombies takes well under a millisecond on a modern CPU.
Takeaways for Three.js developers
- If your characters can be rigid-jointed, instancing per part turns N characters into a fixed number of draw calls.
- Bake animations you play many copies of; skip
AnimationMixerfor crowds. - Do not add a real light for every lamp. Pool a fixed number and move them.
- Measure in the browser you ship to: we keep a debug panel (F3) that shows frame time, draw calls and network traffic live.
You can see the horde for yourself: play Dead Rounds in your browser.