Headcounts is a 3D crowd runner on Arena13. You steer a crowd of people through maths gates, past oncoming cars and spinning bars, into fights with rival crowds and a giant boss. Every person who gets knocked out of a crowd becomes a ragdoll and tumbles down the road.
The game draws with three.js 0.185.1 and runs its physics on Rapier, through the @dimforge/rapier3d-compat 0.20.0 package. Here is how a running, animated person turns into eleven jointed bodies, and how that stays cheap enough for a phone.
Loading Rapier
Rapier is written in Rust and compiled to WebAssembly. The compat build has the WebAssembly embedded in the JavaScript file as base64, so there is no separate .wasm file to serve and no MIME type to configure. The price is size: the vendored rapier.mjs is about 2.9 MB before compression. So it is loaded with a dynamic import after the page starts, in parallel with the character models:
function loadPhysics() {
import('./vendor/rapier.mjs').then((mod) => {
const R = mod.default || mod;
return R.init().then(() => { RAPIER = R; buildWorld(); });
}).catch((e) => { physFailed = true; console.warn('physics', e); });
}
Until world exists, and for players who ask for reduced motion, a knocked runner simply vanishes in a puff of little cubes. The game is fully playable without physics; Rapier only makes it look better.
The world itself is small: gravity of 18 metres per second squared (Earth's is 9.81, so almost double), a long fixed box for the road, and two fixed boxes for the kerbs so a tumbling runner rolls up against the pavement instead of off into the void.
A fixed step that follows slow motion
Rapier steps at a fixed 1/60 of a second. The game loop feeds it through an accumulator that is capped at three steps per frame, so a long hitch on a phone never turns into a burst of catch-up steps:
function physicsStep(dt) {
if (!world) return;
physAcc = Math.min(physAcc + dt, 3 / 60);
while (physAcc >= 1 / 60) {
physAcc -= 1 / 60;
for (const k of kinematics) {
k.rb.setNextKinematicTranslation({ x: k.x, y: k.y, z: k.z });
// ...rotation for the spinning bars
}
world.step();
}
}
The dt it gets is already scaled for the game's hit stop and slow motion. When the boss goes down and time slows to 35%, the ragdolls slow down with it, because they simply receive less time.
A crowd shares a few skeletons
The people are Quaternius's CC0 Universal Base Characters, with clips from his Universal Animation Library. Each person is one skinned mesh, with the body, eyes, brows and one hairstyle merged in the rest pose, and a shader paints the team kit on top.
Animating every runner separately would be far too slow, so there are only a few animated skeletons per pose and gender: six sprint rigs, three jog rigs and two of everything else, each started at a different point in its clip so a crowd never moves in step. Every runner's mesh borrows one of them:
m = new THREE.SkinnedMesh(PEOPLE.kinds[k].geo, mat);
m.bindMode = THREE.DetachedBindMode;
// ...
m.position.set(b.x, b.y, b.z);
m.rotation.y = b.yaw;
m.skeleton = rigFor(b.g, b.pose, b.pi).skeleton;
DetachedBindMode is the trick. The shared rigs all stand at the origin, and in detached mode three.js does not tie the skeleton to the mesh's own transform, so the mesh's position and rotation place the pose wherever that runner is. One skeleton can be drawn in many places at once.
Meshes are pooled too (takeMesh and freeMesh keep one free list per body type), and only so many runners are drawn: 40 on a phone, 70 on a desktop. The headcount itself keeps counting past that; each drawn runner stands for a share of it.
From runner to ragdoll
A ragdoll cannot use a shared skeleton, because its bones must move on their own. So when a runner is knocked out, knockOut takes a spare private rig, places it where the runner stands, and copies the exact pose of the shared rig at that moment, bone by bone:
const src = rigFor(b.g, b.pose, b.pi);
const rr = ragFree[b.g].pop() || bareRig(b.g);
rr.root.position.set(b.x, b.y, b.z); rr.root.rotation.set(0, b.yaw, 0); rr.root.scale.setScalar(1);
for (let i = 0; i < src.bones.length; i++) {
const a = src.bones[i], c = rr.bones[i];
c.position.copy(a.position); c.quaternion.copy(a.quaternion); c.scale.copy(a.scale);
}
rr.root.updateMatrixWorld(true);
const parts = ragBodies(rr, 1, _v.set(vx, vy, vz), spin || 8);
const m = b.mesh;
m.skeleton = rr.skeleton; m.position.set(0, 0, 0); m.rotation.set(0, 0, 0); m.frustumCulled = false;
The same mesh keeps being drawn; it just switches skeletons. Its position goes back to zero because the private rig's bones now carry the world position. Frustum culling is turned off because the mesh's bounding sphere still sits at the origin while the body flies down the road.
Eleven bodies, capsules from bone to bone
The ragdoll is a table of eleven bones: pelvis, spine, head, two upper arms, two forearms, two thighs and two calves. Each row names its parent body, the bone its capsule runs to, and a radius:
const RAG = [
['pelvis', null, 'spine_02', 0.12],
['spine_02', 'pelvis', 'neck_01', 0.13],
['Head', 'spine_02', null, 0.1],
['upperarm_l', 'spine_02', 'lowerarm_l', 0.05], ['lowerarm_l', 'upperarm_l', 'hand_l', 0.045],
// ...the right arm and both legs
];
ragBodies reads each bone's world matrix and creates a dynamic body there, with the same rotation and the runner's launch velocity. The pelvis and spine get continuous collision detection, so a fast hit cannot push the torso through the road:
const body = world.createRigidBody(RAPIER.RigidBodyDesc.dynamic()
.setTranslation(P.x, P.y, P.z).setRotation({ x: Q.x, y: Q.y, z: Q.z, w: Q.w })
.setLinvel(v.x, v.y, v.z)
// ...a random spin
.setLinearDamping(0.05).setAngularDamping(0.7).setCcdEnabled(name === 'pelvis' || name === 'spine_02'));
The collider is a capsule from the bone to its end bone, worked out in the body's own frame. Rapier's capsules stand along the y axis, so the capsule is rotated onto the bone and moved to the middle of it. The head has no end bone and gets a ball instead. The torso is denser than the limbs:
const len = e.length(), half = Math.max(0.01, len / 2 - rad);
const rot = new THREE.Quaternion().setFromUnitVectors(UP, e.clone().normalize());
const cd = (end ? RAPIER.ColliderDesc.capsule(half, rad) : RAPIER.ColliderDesc.ball(rad * 1.15))
.setTranslation(e.x / 2, e.y / 2, e.z / 2).setRotation({ x: rot.x, y: rot.y, z: rot.z, w: rot.w })
.setDensity(name === 'pelvis' || name === 'spine_02' ? 1.6 : 1).setFriction(0.8).setRestitution(0.1);
Each body is tied to its parent with a spherical joint, anchored where the child bone starts. Contacts between joined bodies are switched off, or the overlapping capsules at a shoulder or hip would fight each other:
const a1 = P.clone().sub(pp.p).applyQuaternion(pp.q.clone().invert());
const j = world.createImpulseJoint(RAPIER.JointData.spherical({ x: a1.x, y: a1.y, z: a1.z }, { x: 0, y: 0, z: 0 }), pp.body, body, true);
j.setContactsEnabled(false);
Spherical joints have no limits, so an elbow can bend the wrong way. I accepted that: a ragdoll is on screen for a few seconds, seen from behind a running crowd.
Bones follow the bodies
Every frame, ragSync turns each body's position and rotation into a world matrix, converts it into the bone's parent space, and writes it into the bone. The table lists parents before children, so each parent is already in place when its child is set. Bones without a body, like the hands and feet, simply ride along with their parents:
function ragSync(r) {
for (const [name] of RAG) {
const { body, bone } = r.parts[name];
const t = body.translation(), rq = body.rotation();
_m.compose(_v.set(t.x, t.y, t.z), _q.set(rq.x, rq.y, rq.z, rq.w), _s.setScalar(r.scale));
bone.parent.updateWorldMatrix(true, false);
_m2.copy(bone.parent.matrixWorld).invert().multiply(_m);
_m2.decompose(bone.position, bone.quaternion, _v2);
bone.updateMatrixWorld(true);
}
}
The r.scale in there matters for one ragdoll in particular.
What Rapier does not decide
Rapier does not decide who gets hit. That is game logic: a car checks which runners overlap its box, and each one it hits leaves the crowd with a velocity that comes from the car's speed and which side of the car it stood on:
hits += hitRunner(j, (b.x - c.x) * 2 + (Math.random() - 0.5) * 2, 5 + Math.random() * 3, -(c.speed + S.curSpeed) * 0.45, 9);
Doing it this way keeps the rules exact and fair: a car takes at most about 30% of the drawn crowd (at least 3), whatever the physics would have done. But the cars, buses and spinning sweeper bars are also kinematic bodies in the Rapier world. The game moves them, and physicsStep pushes their positions in with setNextKinematicTranslation before every step. Rapier works out their velocity from that, so a bus rolling on through runners who are already down shoves them aside properly. Barriers, the boss and the 13 steps at the end are fixed boxes, so ragdolls bounce off them and tumble down the steps.
The deal props reuse the same pieces. When the ram car smashes a barrier or snaps a sweeper bar, the mesh becomes a dynamic cuboid in wreck() and flies off; oncoming cars that hit a crowd of robots flip away the same way.
A ragdoll three times the size
The boss at the end of the road is the same kind of person scaled by BOSS_SCALE, 3.1. When it is beaten, it goes down through the same function. The capsule lengths come from the bone positions, which are already scaled, and the radii are multiplied by the scale:
const parts = ragBodies(rig, BOSS_SCALE, _v.set(0, 5, 7), 3);
// ...
rags.push({ rig, g: rig.g, k: -1, mesh: o.body, parts, t: 0, scale: BOSS_SCALE, boss: true });
A Rapier body has a position and a rotation but no scale, so ragSync puts the scale back when it builds each bone's matrix. Without that, the boss's limbs would shrink to normal size the moment it fell.
A budget for phones
At most 12 ragdolls exist at once on a phone and 24 on a desktop. When a new one is needed and the limit is reached, the oldest one that is not the boss is removed, and its rig and mesh go back to their pools:
if (rags.length >= RAGS) dropRag(rags.find((r) => !r.boss));
Ragdolls are also removed after 9 seconds, when they fall off the world, or when they are left behind the crowd. Phones get fewer glass shards when a gate breaks, and their people cast no shadows. If frames still run slow for a while, the game steps down by itself: first a lower pixel ratio, then no shadows, then fewer runners drawn.
How it was made
I designed Headcounts and built it with help from Claude, an AI coding assistant. The game code is MIT licensed; three.js (MIT) and Rapier (Apache-2.0) are included unmodified, and the people are Quaternius's CC0 models.
Try it: play Headcounts free. Lose a few runners to a bus and watch them go.