Animation
Every person in the city, the player, the passers-by, the guards, the police, the chasers and the riders, is animated: a simplistic, low-poly motion for every action the player does and every state a person can be in (Game design §4 "Animation" and "Falls"). This page is how: the figure and its clips, how the state machine picks one, the cosmetic ragdoll, how to author or change a clip in Blender, the gallery to check them by eye, and what keeps it all inside the frame.
The figure and its clips
The character (assets/blender/models/character.py, models/character.glb) is ten rigid parts under an empty at the feet, each with its origin at its joint: the torso from the hips, the head from the neck, the short sleeves from the shoulders, the bare forearms from the elbows, the thighs from the hips and the shins from the knees. No armature, no skinning: a pose is a turn per part, and the empty's place and turn (a crouch drops it, a roll turns it). About 230 triangles; the shirt is still three batches a person (the torso and the sleeves), the forearms are bare.
A clip is a few keyframes of those turns, made in Blender by assets/blender/models/animations.py and exported to apps/client/public/models/animations.json, which the client samples itself (clips.ts): no Babylon animation groups, because the people are thin instances posed by a matrix a part (models.ts). Between two keys a turn is interpolated component by component and normalized, as Blender's linear curves do, so what Blender shows is what the game draws. A clip has:
| Field | Meaning |
|---|---|
duration | Its length in seconds. |
loop | It loops (a walk, an idle, lying asleep), its last key its first: seamless. Or it plays once and hands back (a punch, a landing, a door): a one-shot. |
layer | full keys the whole figure and its root; upper keys only some upper parts (the arms, the head; a punch and a throw the torso too) and is drawn over a full clip, which keeps the rest. |
stride | For a locomotion clip, the metres one cycle covers: it plays as fast as the figure moves, so the feet keep pace with the ground at any speed. |
keys | Per part (and root), flat arrays of the time and the turn x y z w (the root's place x y z before it), in the glTF frame the parts are posed in. |
A change of clip crossfades from the pose shown over 0.15 s. A one-shot plays once over the clips below it and hands back: an upper one (a recoil, a punch, the phone) leaves the legs walking, a full one (a pickup, a landing, getting up) takes the figure for its length. Walking, the legs swing toward the way the figure moves, not the way it faces (strafing, backing off), as before.
States and their clips
The state machine (anim-state.ts, pure) picks a base clip and, over it, an upper clip from what the client knows of a person. The first that holds wins:
- Dead (
dead), then lying: asleep (asleep, breathing), knocked out (knocked-out, limp), stunned (stunned, a dazed wobble), down and hurt (down, clutching). Going down playsknockdown, getting upget-up; while it lies, a ragdoll draws the body (below). - Seated, by the seat's pose:
seat-driver,seat-passenger,seat-rider,seat-pillion; a bicycle's riderpedals as fast as it goes; someone killed in a car stays in its seat, slumped (seat-dead: forward and to one side, the head on the chest, the arms hanging). Everyone seated is drawn, on a two-wheeler or behind a car's see-through glass (riders.ts). - A carjack's beat:
carjack, the pull, facing the driver's door. - At a car's door (
boarding.ts, theenterandexitacts): getting in,enter, facing the door, for the half second the clip lasts: the simulation seats nobody before it has played to its end, so the car never moves with its driver still at the door, and the figure is in its seat from then on; just out,exit, facing out, a third of a second; the doorway swings open and shut meanwhile (see Driving model). On its way to the door the person walks or runs as its speed says. A bail-out from a moving car is a knockdown:knockdownand the ragdoll, flung along with the car's speed. - Cover: crouched behind low cover (
crouch-idle, movingcrouch-walk), flat against tall cover with its back to the wall (cover-high, movingcover-high-step), popped up to aim at low cover (cover-low-aim) or peeking past a tall wall's end (cover-high-peek), the weapon held over it. - In the air, after a moment (a kerb is no jump): rising (
jump), coming down (airborne), falling fast (fall, arms flailing). Back on the ground the figure plays the landing by how hard it hit and how fast it ran:land,land-hard, orland-rollat a run. - Busy on the ground: searching a body (
search, kneeling), rummaging in an open container (rummage), picking a lock (pick-lock). - On its feet, standing or moving by its speed and its role:
| Who | Standing | Moving |
|---|---|---|
| A player | idle (a slight sway) | walk, run (Shift) |
| A civilian | idle | stroll; flee (running, arms up) when running from danger |
| A commuter | idle | walk-brisk; flee |
| A jogger | idle | jog |
| A shopkeeper | idle-shop (arms crossed) | stroll; flee |
| A guard | post (looking around), suspicious (turned to the noise, the "?") | walk with patrol over it (pistol low), search-look searching, alert (both hands up) on alert |
| An officer | idle, gesturing arrest near a wanted player | walk, run |
| A chaser | idle | walk, run |
Over the legs of anyone on foot, the upper clip: what the hands do (the phone out phone, on a call call, talking face to face talk, typing at a terminal or a keypad type), a grenade wound up (throw-windup), else the weapon held by those who show one (players, guards, officers, chasers): hold-pistol (also the tranquilizer), hold-rifle (the rifle and the shotgun), hold-rocket on the shoulder, hold-grenade, the fists up (hold-fists) only for a moment after a punch. The weapon itself is its model (models/weapons.glb, assets/blender/models/weapons.py) put in the right fist as the figure is posed: along the forearm for the pistol, the tranquilizer and a grenade, from the right fist toward the left for the long guns and the launcher (weaponFrame in models.ts); in a seat, the pistol is held out of the window for a moment after a shot.
One-shots: a shot's recoil by the weapon (fire-pistol, fire-rifle, fire-shotgun, fire-rocket), punch, knockout (the chop from behind), throw, pickup, drop, use-item (the first-aid kit), open (a container, a trunk), door, shake (a guard waking a sleeper), a burst of talk or type, the six emotes of the room's chat played over their player's character (emote-wave, emote-laugh, emote-shrug, emote-facepalm, emote-clap, emote-thumbsUp).
STATE_CLIPS lists every state and its clip; a test holds the library to it (every state has its clip, every clip stands for a state). Climbing a ladder is climb (the hands and feet over the rungs, in step, the figure off the ground), from the climb act. Not animated, because the game has no such thing: reloading, swimming.
What the client knows, and the one byte added
The player's own figure reads its local world (its speed, footing, cover, weapon, the grenade's pin), its phone as the page holds it, and plays its own shots' recoil the moment the trigger draws a tracer. Everyone else's reads the snapshots: the pose (speed, footing, which way it moves), the status, the cover, the weapon, the grenade, a carjack's beat, the role (a pedestrian's cls, a contact's role, police, hostile), a guard's alert; getting in and out of a car is read off the act (enter, exit) and inside.
What the snapshots did not say was what a person does with its hands. One act byte at the end of each entity record says it (protocol 11; the simulation's acts.ts): the act's index in ACTS in the low five bits, a counter of the acts in the high three, so the same act twice reads as two. A one-off act (a shot, a punch, a knockout, a throw, a pickup, a drop, an item used, a container or a door opened, a word with a contact, keys typed, a sleeper shaken) shows for a fifth of a second, four snapshots; what lasts (a lock picked, a body searched, a container open) and the phone show as long as they last. The phone is the player's own word: two bits of its input (the spare top bits of the two flag bytes: out, on a call, in a conversation or at a screen of the world). Both are cosmetic: the server acts on neither. The act and its clock are components, hashed with the rest.
The cosmetic ragdoll
When a person goes down (run over, rammed, thrown by a blast, punched, shot, put to sleep, killed) the drawn body goes limp (ragdoll.ts): flung along the hit, it tumbles, rolls and slides on the ground and its slopes, bumps into walls and cars, and comes to rest in a sprawl. It is the client's alone, so each client may see a slightly different fall; the server's capsule, its knockdown and the determinism gates are untouched.
- The solver is Verlet points at the joints (the pelvis, the neck, the head, the shoulders, elbows, hands, hips, knees and feet). Distance constraints hold the torso rigid and the limbs at their lengths; limits keep a limb from folding flat, an elbow bending only forward and a knee only backward, and the limbs from crossing. Gravity and a little drag move the points; the level's ground under the pelvis (its surface and its decks, the tyres'
ground.ts) stops them with a small bounce and friction; a ray from where a moving point was to where it is (castScenery: the local world's level, cars and loose things, never a person; a query, nothing changed) stops it at a wall or a car. Chosen over a second Rapier world because it is a few hundred lines with nothing to keep in step: no bodies to create, sync or free, no second physics engine to pay for. - The seed is the server's: the capsule's velocity when it went down (the hit shoved it: a car's way, a blast's, a punch's, the shot's) and its spin.
- It follows the server softly. The whole body drifts toward the capsule over the ground, gently while it is flung, firmly as it slows, so a survivor gets up where the server stands it without a jump: the figure turns upright from the body as it lay while its
get-upplays. A body too far from its capsule (a respawn) ends. - It settles. Still for a moment, a body is settled: frozen, it costs nothing; a dead body stays where it settled until it respawns. A knocked-out or sleeping guard simply crumples to the ground and holds the pose.
- It is bounded. At most twelve simulate at once, the nearest the camera: one more lays the farthest flat at once, settled. One outside the view does not simulate.
The batches draw a ragdoll from its parts' world matrices in place of a pose (models.ts' worlds). window.__poc().animation tells each person's ragdoll (none, active, settled) and where its body lies, and the player's own.
The dogs
A dog is no figure: the simulation runs it as an NPC of its own (@foi/sim's dogs.ts, see AI) and the client draws the dog model of companions.glb where the server has it (dog-view.ts, pure maths; remotes.ts). Its legs, head and tail are pieces of their own in the batches, each turned about its joint by a draw (models.ts' LIMBS, DrawOptions.limbs), so the dogs stay thin-instanced like everything else and have no clips: a procedural gait by the distance covered (the legs by diagonal pairs at a trot, a longer and wider gallop at a run, the head nodding, the tail wagging) and a pose by what the snapshot says the dog does, blended over a quarter of a second: stand, trot, run (bolting, or faster than a trot), sniff (the head down, the tail level), sit (the body pitched back about its hips, the hind legs folded forward), air (a hop aside from a car) and lying on its side, dead or knocked down (no ragdoll). Its heading turns no faster than a dog turns. The lead is a line from its walker's right fist to its collar while the snapshot says it is on it: straight at the lead's 2.2 m, sagging the nearer the two are, down to the ground and no further; the walker plays walk-dog only then. A bark (the bark act) is a synthesized yelp, fainter with the distance. window.__poc().dogs lists every dog the page has with its walker, its lead, what it does and its pose, and leads the leads drawn.
Authoring a clip
The clips are data written in Python: animations.py describes each pose as a handful of angles in degrees (Pose: the root dropped, moved, tipped; the torso leaning, twisted, bent; the head nodding, turning, tilting; each arm swung forward, spread out, twisted, its elbow bent; each leg swung, spread, its knee bent), turns them into the parts' quaternions and, for a pose that stands, drops the figure until its lowest point (a sole, a knee, a hand, the head) touches the ground. A clip is a list of (time, pose) keys, its loop closed by the script, its layer from the parts it keys, its stride for a locomotion clip. build() keys every clip into the figure on Blender's timeline, one after the other, a marker naming each; export_clips() reads the keys back off Blender's curves and writes the file.
In Blender, through its MCP server (see Blender from Claude Code), with a scratch scene:
import importlib.util, sys
spec = importlib.util.spec_from_file_location("foi_animations", r"<repo>/assets/blender/models/animations.py")
mod = importlib.util.module_from_spec(spec); sys.modules["foi_animations"] = mod; spec.loader.exec_module(mod)
mod.build() # the figure, every clip on the timeline
mod.sheet(["walk", "run", "punch"], at=0.3) # a row of still figures in those clips, for a screenshot
mod.export_clips(r"<repo>/apps/client/public/models/animations.json")sheet() stands a figure per clip, posed part of the way through it, in a Sheet collection to remove afterwards; a viewport screenshot of it is how a motion is judged. Headless, to regenerate both the model and the clips from the scripts:
blender -b --factory-startup --python assets/blender/models/character.py -- --out apps/client/public/models
blender -b --factory-startup --python assets/blender/models/animations.py -- --out apps/client/public/modelsThe script is the source of truth: change a pose there and run it again, never edit the JSON. A new part means character.py's JOINTS and BUILDERS, the client's FIGURE_PARTS (in the same parent-first order), and the clips that key it; clips.test.ts fails while the file, the model and the client disagree. A new state means its clip in the script, its line in anim-state.ts and in STATE_CLIPS, and its test.
The gallery
/?anim-gallery (the web client, npm run dev:web, then http://localhost:5173/?anim-gallery; no server needed) shows every clip of the library on a labelled mannequin in a grid, each playing it over and over (a one-shot with a pause before it starts again, an upper clip over the idle stance), in the order of the states. ?anim-gallery=walk,seat,emote shows only the clips whose names start so, from closer. W A S D pan the camera, the wheel zooms and the mouse orbits it. In front of the grid a row of four mannequins stands by a wall: R (or the button) knocks them down, each flung its own way, one into the wall; they lie a few seconds, then get up where they lie. window.__gallery() tells the tests what is drawn (every clip with its state, its time and where its mannequin is on the page, the ragdolls' states).
Performance
Animating a crowd must not cost the frame (see Performance):
- Batches, not animation groups. A person is eleven thin instances in a few shared meshes, a matrix a part; nothing per person in the scene graph, nothing compiled while the game runs.
- No garbage per person per frame. The figures sample into kept poses, the state machine writes into a kept choice, the draw options and a ragdoll's matrices are kept objects.
- Only what is seen. People outside the camera's view are placed but not posed; those farther than 30 m from the player are posed every other frame; ragdolls outside the view pause, settled ones freeze, at most twelve simulate.
Measured with npm run perf on the reference machine (production build, 1920 × 1080, on the GPU), before (develop at 0.24.0) and after the animations and the ragdolls, one run after the other on 6 October 2026:
| Scenario | Version | fps | Frame p50 / p90 / p99 / max | Frames > 25 ms | Work mean / max | Draw calls | Meshes active / in the scene | Server step mean / p99 / max |
|---|---|---|---|---|---|---|---|---|
| city | before | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 2.51 / 5.4 | 147 | 68 / 1,731 | 2.05 / 2.81 / 5.60 |
| city | after | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 2.57 / 5.4 | 150 | 71 / 1,739 | 2.09 / 2.82 / 5.90 |
| chaos | before | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 4.12 / 9.4 | 190 | 83 / 1,734 | 4.72 / 6.54 / 8.07 |
| chaos | after | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 4.67 / 13.8 | 198 | 93 / 1,748 | 4.54 / 6.73 / 10.57 |
chaos throws grenades among a crowd of 60 for the whole window: many knockdowns at once, the ragdolls among them.
Tests
clips.test.ts: the clips file poses the parts the model has, every loop is seamless, a one-shot holds its last key and hands back a standing figure, the sampling passes through the keys and mixes between them, the crossfade, an upper clip leaves the parts it does not key.anim-state.test.ts: every state maps to its clip and every clip to a state; the priorities (dead over everything, lying over a seat, a seat over walking, cover over the air); the roles, the guards' alert, the weapons, the one-shots.figure.test.ts: the walk by the distance, the legs turned while strafing, the crossfade, a one-shot over the legs and one in place of them, the landings, getting up.ragdoll.test.ts: the limbs hold, it settles on the ground without sinking into it, the hinges, a wall stops it, it drifts toward the capsule and ends far from it, the cap and the pause out of view.acts.test.ts(the simulation) andcodec.test.ts(the protocol): the act byte and the phone bits.- End to end, on the mini level
test-on-foot(Levels): E2E-260 to E2E-279 in the test plan.