Performance
The game holds 60 frames a second with no dips however crowded the city gets: in the worst room the benchmark makes (twice the traffic, a crowd, chasers on foot and in cars, the police at three stars, and the player firing a rifle and throwing grenades among them), every frame of twenty seconds is drawn on time. This page is how that is measured and what keeps it so.
The benchmark
npm run perf # every scenario on this machine's GPU (empty, city, chaos, buildings, city-life, downtown, compound, storm-night, bayside, riverbend)
npm run perf -- --renderer swiftshader # Chromium's software rasterizer, as CI draws
npm run perf -- --scenario chaos --names --server-profile # read the profiles by name
npm run perf -- --scenario old-town --viewport 2560x1440 # a bigger screen; --camera 1.5 for any scenariobuild/perf.mjs builds the web client for production (vite build into reports/perf/web, served by a static server of its own), starts a game server per scenario (on main-hillside; test-buildings for buildings, test-city-life for city-life, main-downtown for downtown, main-compound for compound, main-bayside for bayside, main-riverbend for riverbend) and opens the page in headless Chromium (on the GPU: ANGLE on Direct3D 11 with the GPU forced on). After a warm-up of 10 s it times every frame for 20 s, then profiles the page for 8 s (apart, so the sampling never slows the timed frames). It records:
- the frame rate and the frame times (the gaps between animation frames: p50, p90, p99, the longest, how many over 25 ms) and the long tasks (over 50 ms);
- the game's own report of every second (
window.__perf(),frame-stats.ts): the mean and the worst time the frame's work took (the ticks, the snapshots, the drawing), the most ticks a frame ran, the draw calls, the meshes the scene evaluated as active and the meshes it holds; - the server's step over the window from
/health(its mean, and the median, the 99th percentile and the worst of its last 1,200 steps,loop.ts), the AI's share and the bytes a second the snapshots take; - the CPU profile of the page (and with
--server-profileof the server, through Node's inspector), its self time summed by file and by function;--namesbuilds the client unminified so the functions keep their names.
The report goes to reports/perf/ (git-ignored) as JSON and Markdown, with the profiles beside it; the console prints the table. It is not part of validate: it needs a GPU and a few minutes, and its numbers depend on the machine and on what else runs on it.
| Scenario | The room |
|---|---|
empty | No traffic, no pedestrians, no police: the level alone. |
city | The level's own traffic (32 cars) and crowd (40), the police on: the player in its car at the spawn. |
chaos | 64 cars of traffic, 60 pedestrians, 10 chasers on foot and 8 in cars with crews of 2, the police at three stars; the player on foot, invincible, firing a rifle and throwing grenades among them the whole time. |
buildings | test-buildings (every building of the kits, six districts) with 24 cars of traffic and a crowd of 40: the player put on its main street by the old town, its densest district, and driving it to and fro at a town's pace. |
city-life | The city's mini level (City): 16 cars out of its traffic spawn points, a crowd of 40 by the hour, its role NPCs and the police; the room clock at an hour a second, so the crowd thins at night and fills again by day over and over (commuters to work and home, shopkeepers in and out) while the player sits in its car at the spawn. |
downtown | "Downtown job's" level (main-downtown: a 4 × 4 grid town of five districts, the bank one walks into) with 24 cars of traffic, a crowd of 40, the police and its game running: the player put on its east-west avenue at the central cross, and driving it to and fro past the bank, the towers and the depot at a town's pace. |
compound | "The Compound"'s level (main-compound): its game's 16 cars and a crowd of 40, the compound's five guards and clerk with their cones and its four cameras; the player put on the avenue at the campus's main gate and driving it to and fro, the room clock at ten game minutes a second so the guards go on and off their breaks and change shifts in the window. |
storm-night | The level that ships (main-hillside, its traffic and a crowd) at eleven at night in a storm, the clock stopped: the rain round the player, the lightning, the wet roads, the street lamps' pools, the windows and the headlights, the player driving along the town's avenue (Time and weather). |
bayside | "Rush Hour"'s level (main-bayside: a coast region, a town of 5 × 3 blocks on a bay) with its game's 24 cars, a crowd of 40, the police and its game's morning fog: the player put on the town's first east-west avenue by the hospital and driving it to and fro. |
old-town | The flagship city with its game's 40 cars, a crowd of 40 and the police, seen from the highest camera the settings allow (1.5 times the game's): the player cruising the Old Town's ring road (three roads round the market square) at 47 km/h, steered by a pure pursuit of its centre line, lap after lap (put back on the ring when it gets stuck): what the user called "not fluid" on 2026-10-15. |
riverbend | The flagship city (main-riverbend, the densest level: 8,000 pieces, 940,000 triangles) with its game's 40 cars, a crowd of 40 and the police: the player put on the boulevard through downtown (road-broadway) and driving it to and fro between the towers and their roofs. |
The target, on the reference machine (an AMD Radeon RX 570, 24 logical processors, a 1920 × 1080 page): city, chaos, buildings, city-life, downtown, compound, storm-night, bayside and riverbend at 59.5 fps or more with a p99 frame of 16.7 ms and the longest frame under 25 ms, and the server keeping its 60 Hz step with room to spare.
What a frame costs, and what keeps it bounded
A frame's cost must not grow with the crowd. What it grew with, and what bounds it now:
| What grew with the crowd | What bounds it |
|---|---|
| The local world simulated every twin in full (every car's wheels, every person's walk), a tick and again for every input each reconciliation replays. | Proxies (Netcode): only the controlled entity and what may touch it before the next snapshot are simulated; every other twin is a kinematic body resting where its snapshot put it, solid and found by rays, costing the step nothing. A replay costs what the player can touch. |
| A slow frame caught up to fifteen ticks at once, which made the next frame slower still. | Three ticks a frame at most (frameTicks, clock.ts): a backlog of three more is caught up over the next frames, a stall beyond it dropped; the input-queue nudge brings the client back in step. |
| Every car was a clone of its model's hierarchy (20 to 32 meshes), every person eleven: over a thousand meshes for the scene to cull, transform and bound one by one, and a clone, materials and a shader to make whenever one appeared, changed owner or was wrecked. | Batches (models.ts): each model is merged at load into its paint (a mesh per tint, so a player's colour, a livery, a respray or a wreck's scorched paint are the same materials as before) and the rest (a sub-mesh per flat material, the light bars' lamps among them; a wreck's burnt, made the first time a model is drawn wrecked), the person part by part so its figure still poses every limb. An actor is a matrix or five in a few thin-instanced meshes; the scene holds a few dozen however many there are. |
| Every actor received was posed and drawn, though the camera sees a few dozen metres; the old hillside's 1,236 placed pieces were weighed by the scene every frame. | Culling by hand (view-rect.ts, level-culling.ts): the camera's ground rectangle, met below and above the player and widened so nothing pops in at the edge; actors outside it are placed (the minimap still shows them) but neither posed nor drawn, nor handed to the tyres' effects; shots and blasts outside it are not drawn; the level is handed to the scene by the 32 m cells the camera reaches, and what never moves has its world matrix frozen. |
| Every shot made a mesh of lines with its own material, every blast and hit a sphere: meshes made, shaders compiled and thrown away by the hundred in a firefight. | Pools made once (effects.ts): the tracers are one mesh of streaks turned to the camera, the swings one of lines, both fading by their colours; the blasts a pool of spheres reused oldest first; the muzzle flashes, the puffs, a wreck's plume, the sparks and the blood's mist and drops thin instances; the blood's stains a ring of quads like the tyre marks; the boxes of projectiles share their materials; every pool has a cap. Nothing is created or compiled while the game runs. |
| The minimap's markers were rebuilt every frame for the overlay that reads them ten times a second. | Built when the overlay is published (10 Hz). |
Both the server and the client paid for one more thing: Rapier's World.step (this version, with its soft bodies) sweeps the whole world after every step, a call from the engine into JavaScript for every body and every collider and a list of them all, to wrap the soft bodies the engine may have made on its own. The game makes none, and main-hillside has over a thousand colliders: stepSim runs the physics pipeline's step without the sweep (physicsStep, step.ts), the same physics. In the page that halved the garbage made a second (430 MB in ten seconds of city to 200), and a sampled remote, the culled level's cells and a paint's batch now cost none.
The server pays for the same crowd: a character's test against the cars it might be stuck in listed and sorted every car and built a pose per car, for every character every tick (now a cheap bound per car first, the list once a step), listCharacters and listVehicles sorted afresh at every call (now kept until one is spawned or removed), the police listed every car's crew every tick to find theirs (now read off the officers), the poses of bodies that cannot have moved were read back from Rapier every step (now skipped), and the traffic's projection onto its route made arrays in its inner loop.
Two limits a crowded room ran into on the way, and that no longer stop it: the simulation's entity budget (512 ids, of which the hillside's light pieces then took 336; the region's take 815) is now 4,096 (main-riverbend, the densest city, plants about 2,000), and a room asked for more chasers than its level has spawn points starts them all.
main-riverbend, the densest level (840,000 triangles in 6,900 pieces, 2,000 of them light; 940,000 in 8,000 since its roofs and its harbour were dressed in 0.57.0), measured before that on 13 October 2026 on the reference machine (an RX 570) in a headless Chromium at 1600 × 900 with 48 cars and 80 pedestrians asked for: 5.5 to 9.7 ms a frame at eighteen places round the city, 20 ms the longest; the server up in about 20 s, 11 s of it the navigation (it was 23 s before the triangles' boxes were kept, packages/world/src/geometry.ts).
Measured with the riverbend scenario on 13 October 2026 (0.57.0, its roofs and its harbour dressed), on the GPU, twice:
| Scenario | fps | Frame p50 / p90 / p99 / max | Frames > 25 ms | Work mean / max | Draw calls | Meshes active / in the scene | Server step mean / p99 / max |
|---|---|---|---|---|---|---|---|
| riverbend | 59.8 | 16.7 / 16.7 / 16.8 / 33.3 | 4 | 6.28 / 15.1 | 204 | 88 / 8,969 | 7.15 / 10.47 / 11.65 |
| riverbend | 59.9 | 16.7 / 16.7 / 16.8 / 33.4 | 3 | 6.45 / 14.3 | 233 | 94 / 8,962 | 6.90 / 10.43 / 11.40 |
No long task. The page has room (6.4 ms of work in a frame of 16.7), but three or four frames of the window's 1,200 took two refreshes, and a car driven into the street furniture until it blew up froze the picture for 150 ms. A trace of those frames (Chromium's, with the profiler's samples inside them) named one cause for both: Babylon marks a material's sub-meshes dirty when one of its settings changes, and finds them by asking every sub-mesh of every mesh of the scene for its material, about 10 ms in a city of 9,000 pieces, for a material no mesh uses yet. A paint not seen before cost one such look (the number of lights it takes), a wreck's dozen burnt materials a dozen and one more for each pushed onto its multi-material, a dog's lead one (a line makes a material of its own). They are made without the look now (apps/client/src/renderer/materials.ts: a copy of a material made once carries the setting over without setting it, a multi-material is given its materials at once; the leads share one material), and so are the level's own multi-materials while it is built. Measured again, three times:
| Scenario | fps | Frame p50 / p90 / p99 / max | Frames > 25 ms | Work mean / max | Draw calls | Meshes active / in the scene | Server step mean / p99 / max |
|---|---|---|---|---|---|---|---|
| riverbend | 59.7 | 16.7 / 16.7 / 16.8 / 33.4 | 7 | 6.91 / 22.0 | 204 | 88 / 8,962 | 6.86 / 11.81 / 22.52 |
| riverbend | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 6.97 / 14.0 | 237 | 102 / 8,962 | 7.79 / 10.97 / 13.23 |
| riverbend | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 7.41 / 15.1 | 274 | 119 / 8,963 | 7.42 / 10.63 / 12.79 |
Two runs without a frame over 16.8 ms; the first, straight after the client's build with the machine still busy (the server's longest step was 22 ms in it too), dropped seven, so the target is met but not by much: the frames that remain long in a trace are the graphics process's (8 to 14 ms to carry out a frame's draws on this card where the towers stand closest). The server is the tighter side: its step takes 7 ms of the tick's 16.7 with forty cars, eighty people and one player (Downtown's takes 4), 10.5 ms at its p99, and six tenths of that is Rapier (the profile of --server-profile: 26 % of the samples in its WebAssembly against 43 % awake), the physics of a city of 8,000 pieces with 2,000 light bodies planted in it. It keeps 60 Hz with a third of the tick to spare.
A full room there, measured the same day with the load harness (npm run test:load -- --bots 32 --level main-riverbend, 30 s, the bots on the server's own desktop), beside the hillside on the same machine:
| Level | Step p50 / p99 / max | Snapshots' round | Per client (decoded) | Entities per client | Server CPU | Memory |
|---|---|---|---|---|---|---|
main-riverbend | 12.6 / 27.5 / 30.5 ms | 9.0 ms | 28.6 kB/s | 280 | 1.18 | 645 MB |
main-hillside | 9.7 / 21.8 / 25.7 ms | 6.6 ms | 32.2 kB/s | 204 | 0.95 | 483 MB |
Thirty-two players are a third more work on Riverbend than on the hillside, and on both the ticks that send the snapshots run over the 16.7 ms they have (the step's p99); the median holds. A room of 32 is past what one game thread carries comfortably on either level: where the time goes (the physics of everybody's cars at once, the snapshots' round) is the next thing to look at on the server.
The Old Town from a raised camera (0.61.0)
On 15 October 2026 the user found the game "not fluid, especially when cruising in the old town and with a more zoomed out camera", at 1920 × 1080 and above. The old-town scenario reproduces it: the Old Town's ring road from the highest camera (1.5), the car at 47 km/h. Two things were wrong, and the benchmark now measures both: how long a frame takes, and how evenly the picture moves from one frame to the next (the second difference of the camera's ground point, of the car's place on the screen and of every other car's place, window.__drive() and window.__cars() read once a frame; at an even pace they are a few millimetres, a frame that ran no tick or two a few centimetres, a car frozen for a snapshot tens of centimetres).
The frames: the GPU process drowned in draw calls. The scenario ran at 32 fps (frames of 33 and 50 ms) at 1920 × 1080 and 26 fps at 2560 × 1440. Switched off part by part, the frame rate came back with the level gone (60 fps) and barely moved with a quarter of the pixels (35 fps): not the pixels. A trace of Chromium's threads showed its GPU process's main thread busy 98 % of the time carrying out the page's WebGL commands, the page's own thread idle 57 %. The level was an instance per placement, of a mesh per piece kind, in a material per colour: about 300 draw calls for the level alone in view (68 piece kinds in 290 colour groups round the market square), each copying every visible instance's matrix to the GPU every frame, and ANGLE on Direct3D 11 pays for each. Static batching (level-batches.ts) bakes every standing piece at load into a few meshes per 64 m cell and surface, its colours carried by its vertices: the same view is a few dozen draws of buffers that never change, and the scene holds 3,800 meshes instead of 9,000. A surface is what the weather and the night do to a colour (paved and loose ground wet and snowed, glass glowing in its own colour, lamp-like faces shining), one material each, its shader taking the vertex's colour as its diffuse colour (a material plugin, so a face is lit exactly as with a material of its own: 0.01 % of the pixels differ by more than 8/255 by day, 0.02 % at night, in a still frame of both builds). A light piece is baked too and keeps a hidden instance: knocked loose, its triangles in the batch fold to a point and its instance draws it; planted again, the other way (E2E-69). Street lamps, lava-painted pieces and fading roofs keep their own instances. The drawing buffer is no longer preserved between frames (a copy of the multisampled picture every frame, for nobody).
The motion: far cars froze and leapt. With the frames on time, the other cars still jolted: a car 80 m and more away stood still for two or three frames and covered the distance in the next three, over and over (the snapshots that leave it out carried its last pose, recorded as new; see Netcode), and every remote moved a tick forward or back when a snapshot came early or late. Both are fixed in @foi/net-client (remote-buffer.ts, clock.ts).
Measured on the reference machine (an RX 570, the production build, with three other agents' work on the machine), the Old Town from the highest camera:
| Build | Page | fps | Frame p50 / p90 / p99 / max | Frames > 25 ms | Work mean / max | Draw calls | Meshes in the scene | Other cars' jolt rms / p99 |
|---|---|---|---|---|---|---|---|---|
| 0.60 (an instance each) | 1920 × 1080 | 32.1 | 33.3 / 33.4 / 50.0 / 50.1 | 521 | 11.18 / 19.1 | 350 | 8,952 | 122 / 571 mm |
| batched | 1920 × 1080 | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 5.45 / 14.2 | 196 | 3,754 | 121 / 572 mm |
| batched, far cars, clock | 1920 × 1080 | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 5.34 / 14.4 | 170 | 3,784 | 16 / 75 mm |
| 0.60 (an instance each) | 2560 × 1440 | 26.3 | 33.4 / 50.0 / 50.1 / 50.1 | 517 | 12.43 / 29.6 | 317 | 8,945 | – |
| batched, far cars, clock | 2560 × 1440 | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 5.75 / 14.4 | 186 | 3,772 | 15 / 72 mm |
Then the camera: it looked 0.45 s ahead along the car's raw velocity, which swings with every flick of the steering keys, every kerb and every correction of the prediction, and the look-ahead magnified each into a jolt of the whole picture (18 mm rms over the Old Town's laps). It now looks ahead along a filtered velocity (LookAhead, camera.ts: a quarter of a second behind, frame-rate independent). With it, the zoom out at speed on (the settings' default) and everything above, the Old Town at 1920 × 1080: 60.0 fps, p99 and longest frame 16.8 ms, the camera's jolt 4.1 mm rms (15 mm p99), the car's on the screen 3.9 mm, the other cars' 6.9 mm (20 mm p99).
riverbend, the boulevard through downtown, in the same run: 60.0 fps, p99 and longest frame 16.8 ms, other cars' jolt 16 / 72 mm (94 / 448 mm before the fixes of the far cars and the clock). What was left of the other cars' jolts was the guess carried past the newest snapshot of a car sent every fourth one (now blended into the next, see Netcode) and the clock's 5 % drift.
In the ablation runs (6 s each, the same page) the instances' build drew 29 to 45 fps as the machine's load moved, 60.0 with the level hidden and 35 with a quarter of the pixels; the batched build 60.0 in every run, p99 16.8 ms.
Results
Every result below is dated. Until 0.41.0 the empty, city and chaos rooms ran on the hillside that is now test-curves (1,236 placements on 440 m), not on the region that ships today (1,823 placements on 896 m); none of them has been measured on the region yet.
Measured with npm run perf on the reference machine, the production build at 1920 × 1080, one run after the other on 5 October 2026 (the machine shared with other work, at 60 to 80 % load); before is develop at 0.22.0 with the benchmark's instrumentation and the two limits above lifted (without them chaos does not start). Frame times in ms; work is the frame's own JavaScript (the ticks, the snapshots, the drawing), the GPU's time apart.
On the GPU:
| Scenario | Version | fps | Frame p50 / p90 / p99 / max | Frames > 25 ms | Long tasks | Work mean / max | Draw calls | Meshes active / in the scene | Server step mean / p99 / max |
|---|---|---|---|---|---|---|---|---|---|
| empty | before | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 0 | 4.3 / 8.2 | 110 | 53 / 1,672 | 0.78 / 1.28 / 1.56 |
| empty | after | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 0 | 2.7 / 4.8 | 103 | 46 / 1,714 | 0.47 / 1.09 / 2.31 |
| city | before | 54.7 | 16.7 / 16.8 / 33.4 / 50.0 | 105 | 0 | 17.2 / 45.2 | 114 | 57 / 3,550 | 3.26 / 5.06 / 7.11 |
| city | after | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 0 | 3.1 / 7.2 | 149 | 68 / 1,731 | 2.71 / 3.71 / 7.27 |
| chaos | before | 1.0 | 1,167 / 1,417 / 1,633 / 1,633 | 21 | 19 | 1,090 / 1,606 | 428 | 386 / 6,011 | 8.61 / 12.38 / 18.84 |
| chaos | after | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 0 | 6.2 / 13.9 | 207 | 86 / 1,735 | 6.19 / 9.55 / 12.93 |
chaos had 64 cars of traffic, 26 chasers (on foot and crews), 4 police cars and 8 officers out, the crowd of 60 and about 160 people on screen; the server sent about 370 kB a second to the one player. Before, the frame never caught up: the prediction world stepped every twin in full, up to fifteen times a frame, and every frame was slower than the last.
Measured again once the locks, keys and garages (0.23.0) and the social layer (friends, parties, chat, safe zones, the respawn ghost) were in, on a quieter machine: city 60.0 fps, p99 and longest frame 16.8 ms, work 2.8 / 7.6 ms; chaos 60.0 fps, p99 and longest frame 16.8 ms, work 4.8 / 9.3 ms, the server's step 5.09 / 7.63 / 12.40 ms. The tags over the cars, the garages' floor paint and a respray's paint (one batch per palette colour) cost nothing to see; a ghost is a see-through clone of its own for its few seconds.
Measured again once the people were animated by their clips and went limp in cosmetic ragdolls (0.25, Animation): city 60.0 fps, p99 and longest frame 16.8 ms, work 2.57 / 5.4 ms; chaos (its grenades knocking the crowd down) 60.0 fps, p99 and longest frame 16.8 ms, work 4.67 / 13.8 ms. A person is eleven matrices now (a head, forearms and shins), a figure far from the player is posed every other frame, at most twelve ragdolls simulate and a settled one costs nothing.
Measured again once the civic kit, the districts and test-buildings were in, one run after the other on 6 October 2026, before on develop at 0.25.0 and after on the branch (the machine shared with other work):
| 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.60 / 5.5 | 150 | 71 / 1,739 | 2.10 / 2.76 / 5.69 |
| city | after | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 2.60 / 6.0 | 150 | 71 / 1,739 | 2.06 / 2.93 / 5.75 |
| chaos | before | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 4.68 / 14.2 | 215 | 95 / 1,750 | 4.50 / 7.61 / 10.50 |
| chaos | after | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 4.53 / 10.1 | 182 | 90 / 1,744 | 4.65 / 6.44 / 7.16 |
| buildings | after | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 2.68 / 8.1 | 185 | 81 / 1,247 | 1.93 / 2.85 / 3.99 |
The new buildings cost what the old ones did: a placement is an instance of its piece's mesh, a few dozen distinct pieces are in view at once, and the level's cells hand the scene only what the camera reaches. The minimap's districts are drawn once, into its static layer.
Measured again with the city's blocks (the room clock, the crowd by the hour, roles, schedules, restricted zones, gates, shops and Cash; City) on 6 October 2026, before being develop at 0.25.0, on the GPU:
| 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.7 / 5.9 | 151 | 71 / 1,739 | 2.50 / 3.51 / 5.82 |
| city | after | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 2.6 / 5.8 | 147 | 68 / 1,736 | 2.13 / 3.14 / 6.21 |
| chaos | before | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 4.8 / 12.5 | 196 | 96 / 1,747 | 4.54 / 7.69 / 9.49 |
| chaos | after | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 4.7 / 9.8 | 219 | 95 / 1,742 | 4.64 / 7.10 / 9.17 |
| city-life | after | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 2.2 / 8.5 | 121 | 68 / 215 | 1.20 / 1.83 / 2.47 |
And once more with both together (the buildings' shops at their counters, the hospital's and the police's spawn points, the crowd drawn from its districts' roles), the same day:
| Scenario | 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 | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 2.6 / 5.7 | 147 | 68 / 1,736 | 2.23 / 2.91 / 5.93 |
| chaos | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 4.6 / 9.9 | 215 | 94 / 1,743 | 4.80 / 6.33 / 8.57 |
| buildings | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 2.8 / 8.0 | 182 | 78 / 1,248 | 1.98 / 3.10 / 4.24 |
| city-life | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 2.2 / 8.4 | 122 | 69 / 215 | 1.23 / 1.84 / 2.18 |
Measured again once cars and people met as solid bodies (Collisions: contacts 10 cm ahead, soft CCD, a tick cut into up to four physics steps where a car closes fast on another body, light pieces knocked loose by kinetic energy), two runs each, alternated, on 7 October 2026, before being develop at 0.26.0 (the machine shared with other work):
| 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 | 3.10 / 6.4 | 148 | 68 / 1,736 | 2.77 / 3.78 / 7.13 |
| city | before | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 3.14 / 7.6 | 147 | 68 / 1,736 | 2.76 / 3.69 / 7.76 |
| city | after | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 3.25 / 10.3 | 148 | 68 / 1,736 | 2.84 / 3.89 / 8.82 |
| city | after | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 3.35 / 8.1 | 148 | 68 / 1,736 | 3.00 / 3.97 / 10.26 |
| chaos | before | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 5.88 / 12.7 | 202 | 92 / 1,742 | 5.95 / 8.69 / 10.47 |
| chaos | before | 59.9 | 16.7 / 16.7 / 16.8 / 49.9 | 2 | 6.20 / 21.0 | 204 | 92 / 1,745 | 6.01 / 9.64 / 14.72 |
| chaos | after | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 6.37 / 14.9 | 201 | 93 / 1,743 | 6.25 / 9.37 / 10.99 |
| chaos | after | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 6.19 / 20.1 | 199 | 93 / 1,745 | 6.62 / 9.78 / 13.99 |
No long task in any run. The first cut cost more: four physics steps every tick put the server's chaos step at 8.25 ms, and a cut that counted every pair moving fast in any direction (a person running past a car, two cars scraping along each other) still took two steps or more in 62 % of chaos ticks (7.96 ms, the longest 26 ms); with the light pieces' hull cast run through the whole level every tick by every bus at 21 km/h, the server's step timed system by system in one process was 8.6 ms against 6.4. Counting only how fast a car closes on another body, and looking the light pieces up on a grid, it is 7.0 to 7.4 against 6.7 to 6.9 (the same process, the same hour); a client's prediction, its step and the replays of every snapshot, 0.70 ms a tick in city and 4.1 in chaos, against 0.65 and 4.0.
Measured with "The Compound" (main-compound, its guards on schedules) on 7 October 2026, on the GPU:
| Scenario | fps | Frame p50 / p90 / p99 / max | Frames > 25 ms | Work mean / max | Draw calls | Meshes active / in the scene | Server step mean / p99 / max |
|---|---|---|---|---|---|---|---|
| compound | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 2.92 / 4.9 | 119 | 55 / 1,263 | 1.68 / 3.06 / 4.37 |
No long task. The schedules cost nothing to see: the hour in force is a lookup on the clock, and a guard on its break draws the same cone, shorter.
Measured with "Rush Hour" (main-bayside, in its game's morning fog) on 12 October 2026, on the GPU:
| Scenario | fps | Frame p50 / p90 / p99 / max | Frames > 25 ms | Work mean / max | Draw calls | Meshes active / in the scene | Server step mean / p99 / max |
|---|---|---|---|---|---|---|---|
| bayside | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 3.73 / 11.1 | 179 | 96 / 2,466 | 2.84 / 3.61 / 4.47 |
No long task. The tunnel and the lighthouse are a few hundred triangles of the region's own kit; the fog is the scene's.
Measured with the night's lights (Time and weather: the lamps that go out, the windows lit by the hour with their light on the pavement, every car's headlights, the lamps' light on the cars and people) on 13 October 2026, on the GPU, the runs alternated; before is main at 0.55.1, "no actor lamps" the same build without the three point lights of actor-lamps.ts. The machine was shared with other work that day, which cost every run a missed frame or more (the longest frame is 33 ms in all of them, the one before the change too):
| 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 |
|---|---|---|---|---|---|---|---|---|
| storm-night | before | 59.1 | 16.7 / 16.7 / 33.3 / 33.4 | 19 | 4.54 / 13.5 | 183 | 90 / 3,158 | 2.79 / 4.55 / 5.65 |
| storm-night | before | 58.4 | 16.7 / 16.7 / 33.3 / 33.4 | 33 | 5.21 / 15.4 | 203 | 98 / 3,177 | 3.21 / 4.78 / 6.18 |
| storm-night | before | 59.9 | 16.7 / 16.7 / 16.8 / 33.4 | 3 | 4.30 / 15.6 | 181 | 89 / 3,159 | 3.15 / 4.46 / 5.71 |
| storm-night | before | 59.9 | 16.7 / 16.7 / 16.8 / 33.3 | 3 | 4.07 / 19.1 | 170 | 86 / 3,158 | 2.85 / 4.03 / 4.93 |
| storm-night | no actor lamps | 59.6 | 16.7 / 16.7 / 16.8 / 33.4 | 9 | 5.10 / 19.2 | 200 | 101 / 3,174 | 3.15 / 4.86 / 6.44 |
| storm-night | no actor lamps | 60.0 | 16.7 / 16.7 / 16.8 / 33.4 | 1 | 4.79 / 34.7 | 187 | 92 / 3,161 | 3.18 / 4.73 / 7.54 |
| storm-night | no actor lamps | 58.8 | 16.7 / 16.7 / 33.3 / 33.4 | 24 | 5.01 / 15.2 | 209 | 103 / 3,177 | 3.08 / 4.67 / 5.41 |
| storm-night | after | 59.4 | 16.7 / 16.7 / 33.2 / 33.5 | 12 | 4.63 / 14.3 | 182 | 93 / 3,161 | 2.98 / 4.22 / 6.18 |
| storm-night | after | 58.5 | 16.7 / 16.7 / 33.3 / 33.4 | 30 | 5.35 / 18.2 | 199 | 100 / 3,177 | 3.21 / 5.12 / 6.37 |
| storm-night | after | 59.7 | 16.7 / 16.7 / 16.8 / 33.4 | 6 | 4.36 / 15.2 | 197 | 94 / 3,160 | 3.03 / 4.02 / 4.76 |
| storm-night | after | 59.6 | 16.7 / 16.7 / 16.8 / 33.4 | 8 | 4.68 / 13.3 | 206 | 99 / 3,159 | 3.07 / 4.13 / 5.00 |
No long task in any run. The three versions are within each other's noise: 59.3 fps before and after on average, the frame's work 4.5 ms against 4.8. To see past the machine's load, the frame rate was let go (Chromium's --disable-gpu-vsync --disable-frame-rate-limit) and the lights switched off and on again every second and a half in one page, the hillside at nine in the evening: a frame takes 11.65 ms with all of them (199 pools and halos, 2,658 lit windows, 881 patches on the pavement, the beams, the spotlight) and 11.20 ms with none, under half a millisecond for the lot, and no single one of them stands out of the noise. They are four meshes of thin instances whose buffers are written when the lit windows or the lamps change, not every frame (the cars' are, a few matrices). Finding the windows in a level's glass costs its load 0.1 to 0.3 s once.
On Chromium's software rasterizer (SwiftShader, what CI draws with; information only, the frame there is the rasterizer's):
| Scenario | Version | fps | Frame p50 / p99 / max | Work mean / max | Server step mean / p99 / max |
|---|---|---|---|---|---|
| empty | before | 30.0 | 33.3 / 66.7 / 83.3 | 7.1 / 13.3 | 1.02 / 2.68 / 5.24 |
| empty | after | 33.2 | 33.3 / 66.8 / 83.4 | 2.4 / 4.3 | 0.49 / 0.83 / 0.96 |
| city | before | 19.4 | 50.0 / 66.8 / 83.3 | 48.8 / 68.5 | 4.98 / 11.17 / 18.11 |
| city | after | 27.0 | 33.3 / 83.4 / 100.1 | 4.4 / 9.6 | 3.17 / 8.00 / 13.33 |
| chaos | before | 0.5 | 1,933 / 2,350 / 2,350 | 1,940 / 2,325 | 8.63 / 44.58 / 84.69 |
| chaos | after | 23.9 | 33.4 / 100.0 / 133.3 | 9.4 / 19.4 | 7.29 / 15.23 / 19.39 |
Measured once main-downtown was in (its 156,000 triangles in 1,132 placements, the bank's upper floors the one roof that fades), on 7 October 2026, city beside it for the machine's state:
| Scenario | 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 | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 3.31 / 12.0 | 133 | 52 / 1,761 | 2.82 / 3.79 / 11.90 |
| downtown | 60.0 | 16.7 / 16.7 / 16.8 / 16.8 | 0 | 3.34 / 6.8 | 118 | 55 / 1,569 | 2.56 / 3.58 / 4.77 |
No long task. The downtown costs what the hillside does: its buildings are instances of the kits' pieces like the hillside's, the level's cells hand the scene only what the camera reaches, and the server's step stays within the hillside's.
The server under load
npm run test:load (tests/load/run.ts) starts a server of its own (or reads one at --url) and joins it with bots over real WebSockets: each drives in wide curves at 60 inputs a second and reads every snapshot with the client's own decoder. It reports what a client receives a second (as decoded and as it came over the wire), the entities it holds, the keyframes asked for, the server's step over the last 20 s, how long a round of snapshots takes, the process's CPU and its resident memory; --compare runs the whole snapshots, then the deltas, --json <file> keeps the report.
Measured on 12 October 2026 (16 bots for 20 to 30 s, a laptop's Ryzen):
| Level | Snapshots | Per client (decoded) | Over the wire | Entities held | Step p50 / p99 | Snapshots' round | CPU |
|---|---|---|---|---|---|---|---|
test-traffic-junction, 16 cars, 8 bots | whole | 125 kB/s | 125 kB/s | 66 | 1.48 / 2.67 ms | 0.62 ms | |
test-traffic-junction, 16 cars, 8 bots | deltas | 22.8 kB/s | 67 | 1.56 / 3.25 ms | 0.86 ms | ||
main-hillside, 32 cars, 40 people, 16 bots | whole | 263 kB/s | 263 kB/s | 161 | 4.95 / 8.65 ms | 2.01 ms | |
main-hillside, 32 cars, 40 people, 16 bots | deltas | 20.8 kB/s | 20.9 kB/s | 152 | 4.98 / 8.83 ms | 2.28 ms | 0.44 |
main-hillside, 32 cars, 40 people, 16 bots | deltas, deflate | 21.4 kB/s | 12.3 kB/s | 158 | 4.89 / 8.33 ms | 2.15 ms | 0.43 |
With the acked snapshots (protocol 31) and the data channel, measured on 13 October 2026 (32 bots for 30 s on main-hillside, its 32 cars and 40 people, the bots on the same desktop as the server, a Ryzen of 24 threads; npm run test:load -- --bots 32 --loss <p> or --rtc):
| Snapshots, path | Per client (decoded) | Over the wire | Step p50 / p99 | Snapshots' round | Server CPU | Memory |
|---|---|---|---|---|---|---|
| ordered deltas, WebSocket (deflate) | 31.3 kB/s | 17.7 kB/s | 10.4 / 21.8 ms | 7.3 ms | 0.96 | 461 MB |
| acked, WebSocket, no loss | 53.9 kB/s | 26.3 kB/s | 9.8 / 32.9 ms | 12.6 ms | 1.09 | 861 MB |
| acked, WebSocket, 10 % lost each way | 48.3 kB/s | 21.7 kB/s | 8.9 / 30.4 ms | 12.1 ms | 1.00 | 749 MB |
acked, data channel (--rtc) | 40.0 kB/s | (not deflated) | 9.3 / 27.4 ms | 11.0 ms | 1.17 | 692 MB |
The acked snapshots cost the server more than the ordered ones: each client keeps a state per frame sent since its acknowledgement (copied on write, a map of the entities it sees), and each frame is a diff against the acknowledged one. In one process, with every acknowledgement in at once (32 clients over memory links), it is 6.4 ms a tick against 5.4 for the ordered deltas, the bytes the same (27 against 29 kB/s); over real sockets the frames go onto a base two or three frames back and carry the events again until they are acknowledged, so 30 to 70 % more bytes, and the round grows by half. The channel's frames are not deflated (WebRTC has no permessage-deflate). The 99th percentile of the step is a third higher than the ordered deltas' on this machine, against the plan's aim of none: a server whose clients all play over the channel holds fewer of them; the next step is an advance that does not copy each client's state every frame.
What the deltas do (@foi/protocol's snapshot-v2.ts, protocol 27): a client is sent only what changed since the last snapshot it was sent (the connection is ordered and reliable, so no acknowledgements), every entity quantized once a tick (a position in 1/256 m, sent as 16-bit steps from the last when it moved less than 128 m; a rotation in 32 bits; a velocity and a spin in 1/64 m/s and 1/256 rad/s), the client's own character, car and trailer in full for its prediction. The far entities' poses come less often (AOI_TIERS: every snapshot within 80 m, every second within 160 m, every fourth to 250 m; a person sitting in a car every fourth), what they are and do at once; the events go to the players near them. permessage-deflate halves the bytes on the wire for no more CPU, so it is on (FOI_WS_DEFLATE=0 turns it off; a window of 4 kB and a small memory level keep a connection's state small). Browsers and the desktop app inflate any message; Node's own WebSocket drops the connection on a big city's welcome (the hillside's, 4.5 MB inflated), so a client written for Node uses the ws package (as the stack's smoke and the load harness do). A connection backed up past 256 kB is skipped (what it holds stays as it was) and closed after 10 s past 2 MB. FOI_DELTA=0 sends every snapshot whole, FOI_AOI_TIERS=0 every pose every time.
Over the WebRTC data channel (FOI_RTC=1, Netcode) the snapshots lose permessage-deflate: a data channel compresses nothing, so the hillside's client above takes its 21 kB/s on the wire as they are (and about 60 bytes a frame of DTLS, SCTP and UDP headers, some 1.2 kB/s at 20 snapshots a second) against 12 kB/s compressed over the WebSocket. What it buys is that a lost packet holds nothing up: over TCP every frame after it waits for its retransmission. The reliable frames (the city, the missions, the chat) stay on the compressed WebSocket.
A room's navigation (@foi/ai's buildAiStatic: 10 s on the hillside, 3.7 s on the downtown) is built in a worker thread, once per level in the process (apps/server/src/nav-cache.ts), and a room opens when it is ready (RoomHost.openWhenReady): opening the downtown's room while another plays costs the loop one step of 90 ms (the room's own world) instead of 3.7 s.
Runs
Every run of a mission is a room of its own (TECHNICAL_SPECS §9.5), and a room is a whole level's world: on Riverbend its physics, its traffic and crowd (a quarter of the city's in a run's room), its AI and its snapshots, beside the city's own room on the same loop. npm run test:load -- --runs <n> --mission hostile_takeover --seats 4 opens n run rooms of Riverbend's robbers and chasers as the platform opens them (POST /rooms with the room secret, bundled, the run; a bot in each seat with a join token, two teams of two driving once the countdown is over) beside the city's room and its --bots.
Measured on 19 October 2026 on a laptop's Ryzen, Riverbend, 30 s after the warm-up, the city's room with its 40 cars and its crowd and 8 bots, each run room with 4 bots (the server's own numbers, the loop's step over its last 20 s):
| Rooms | Bots | Step p50 / p95 / p99 / longest | Server CPU | Memory |
|---|---|---|---|---|
| the city alone | 8 | 6.96 / 11.2 / 13.4 / 16.5 ms | 0.60 | 596 MB |
| the city and one run | 8 + 4 | 9.21 / 12.3 / 14.2 / 16.6 ms | 0.72 | 868 MB |
| the city and two runs | 8 + 8 | 12.8 / 16.6 / 18.8 / 21.2 ms | 0.95 | 1.09 GB |
| the city and four runs | 8 + 16 | 16.8 / 21.6 / 25.2 / 32.4 ms | 1.11 | 1.23 GB |
A run's room with its players costs the loop 2.5 to 3 ms a tick and 150 to 270 MB. With the city and four runs the median step is a whole tick (16.7 ms): the loop no longer keeps 60 Hz, and every room's players feel it. With the city and one run the 95th percentile is 12.3 ms; with two, 16.6 ms, at the edge of the tick. So a server plays 2 runs at once at most by default (FOI_MAX_RUNS, apps/server/src/capacity.ts), counting the run rooms open and on their way (a run's place is held from the moment it is admitted, before its game is fetched and its navigation built, so runs opened at the same time never all pass). Ordinary rooms (the default room, the levels', the published games') keep the admission they had: no new room while draining, past the players (FOI_CAPACITY) or while the step's p95 is past 8 ms with players on it; a cap of rooms of any kind is there for a host short of memory (FOI_MAX_ROOMS, none unless set: the default room and the levels' rooms count then, and a level's room refused for it closes the connection with 4429). The step's guard does not cover a run: its room is opened before its players come, while it costs nothing, and they all arrive together, hence its own cap. The platform's fleet passes a server at its cap of runs by (its heartbeat says its maxRuns, its room listing its runs) and, with no server to take a run, refuses it with the city busy, to try again in a minute (apps/api/src/fleet.ts); an ordinary room is placed as before. A faster host takes more runs with FOI_MAX_RUNS; the next measure is on the fleet's own servers before runs are opened there (the plan's P1 note on the cost of instances).
The server's Prometheus metrics are at /metrics (apps/server/src/metrics.ts): the loop's step time as a histogram, the late steps and the stalls, every room's players, snapshot round, entities a client holds, AI step, bytes sent, snapshots skipped and keyframes asked for, and the process's CPU, memory and event loop lag.
Keeping it so
- Draw an actor through the batches (
ActorModels.draw), never a clone per actor; a look that differs per actor is a tint (a batch per colour) or a pose (a matrix), not a material per actor. - Make meshes, materials and textures at load, never in a frame: a pool with a cap for anything short-lived.
- Cull what you add against the view (
inView), and leave a margin so nothing pops in. - In the simulation, anything run per entity per step must not list, sort or read Rapier per other entity: keep a bound to pass over the far ones first, and a list per step.
- Make no garbage per actor per frame: write into kept objects (
RemoteBuffer.sample'sout), key by numbers or by the objects themselves, not by strings made each time. - Measure with
npm run perfbefore and after, and read the profile by name (--names,--server-profile).