Room capacity
A room runs on one core, and steps its world at its tick rate, 30 times a second unless its game or its start names 60.
It holds its players while each tick, the step and the send that ends it, fits in one tick of the wall: 16.7 ms at 60 and 33.3 ms at 30.
spawnite play bench measures that: it fills each room a rooms file names with bots, measures it over a window, and appends a row to a results file. spawnite play room --cpu-profile and spawnite play bench --cpu-profile say where the room’s CPU went.
Unity, Unreal and Godot ship no capacity bench of their own: Unity calls it load testing, Unreal builds one per game on Gauntlet, and each profiles a server with its own profiler.
Write a rooms file
Section titled “Write a rooms file”A rooms file is JSON. It names each room to run, the players it takes and the settings it runs with. The bench fixture’s file, games/bench-field/bench/fifty.json, runs a room of 50 players spread over the field, with interest culling at 60 m, at 60 ticks and at 30. Its bench/hundred.json runs the same room at 100 players, the most a room holds:
{ "seconds": 60, "settleSeconds": 10, "rooms": [ { "name": "fifty-spread", "players": 50, "env": { "FIELD_LAYOUT": "spread", "FIELD_NPCS": "0", "ROOM_INTEREST_DRILL": "60" } }, { "name": "fifty-spread-30", "players": 50, "tickRate": 30, "env": { "FIELD_LAYOUT": "spread", "FIELD_NPCS": "0", "ROOM_INTEREST_DRILL": "60" } } ]}A rooms file takes the following settings:
rooms: each room’sname, which its rows carry, itsplayers, from 1 to 100, and itsenv, settings over the room’s own for the room and its bots alike. ItstickRate, 30 or 60, and itssendsPerSecond, which divides it, run the room at those rates over its game’s own, and the pair is checked as the room checks it. A room’sprojectruns another game, such as the same game in another checkout, to compare two builds.- A room’s
nodeFlags: Node’s flags for the room alone, after the onesroomHeapsets, so a later flag wins. Two rooms that differ only in their flags compare two runtime settings in one file, such as["--max-semi-space-size=8"]beside the hosted room’s 16 MB semi-spaces. The bench’s own--inspectis refused here;cpuProfileSecondsasks for a profile. seconds: the window each room is measured over, 60 by default.settleSeconds: the wait between the last join and the window, 10 by default.parallel: the rooms that run at once, each on a core of its own, 1 by default.rounds: the times each room runs, 1 by default. Each round runs the rooms in the order the round before reversed, so a drift in the machine’s load falls on every room alike.cpuProfileSeconds: a CPU profile of each room after its window, none by default.roomHeap: the room’s V8 old space in megabytes, the hosted room’s 256 by default, with its 16 MB semi-spaces, ornullfor no limit and Node’s own sizing.project: the game every room runs, from the file’s folder or by its name undergames. Left out, it is the game the file sits in.scene: the scene every room steps and its bots load, from the game’s folder, such as"./src/scenes/ForestEdge.tsx". A room’s ownsceneruns over the file’s. Left out, a room steps the scene the game’sspawnite.roomnames. With it, a game played alone benches a scene too.- A room’s
spread: the map whose ground its bots spread over, by its name, such as"forest-edge". Each bot draws a home from its own seed, on ground above the sea and under 50° within 90% of the map’s half size of its middle. It walks there along the navmesh and wanders within 15 m of it. The spread replaces the scene bot’sdestinationand keeps its weapons, skill andplay.
A setting the bench sets itself, such as PORT, ROOM_TICK_TIMES, TICK_RATE, BOT_COUNT or ROOM_GAME, is refused in env, as is a setting that is not a string.
The rooms and bots run the @spawnite/room the game installed. A game in this repository’s workspace that installs none, such as the example, runs the workspace’s packages/room, and the bench says so. Elsewhere the bench asks you to add @spawnite/room to the game’s devDependencies.
Last Light’s bench/forest-edge.json benches its forest edge, with 20 bots spread over its map and a profile after the window:
{ "seconds": 60, "settleSeconds": 90, "cpuProfileSeconds": 30, "scene": "./src/scenes/ForestEdge.tsx", "rooms": [ { "name": "forest-20", "players": 20, "spread": "forest-edge", "env": { "ROOM_INTEREST_DRILL": "60" } } ]}Run the rooms
Section titled “Run the rooms”From the game’s folder:
pnpm spawnite play bench bench/fifty.jsonFor each batch of rooms, the command does the following:
- Starts each room with
NODE_ENV=production, as a hosted room runs, a heartbeat a second, each tick’s times in its heartbeats, and no recording. - Pins each room to a core of its own from the second core up, both of its logical processors, and the bots to every other processor at below-normal priority. Linux pins with
tasksetand Windows with a process’s affinity; macOS cannot, and the command says so. - Starts the bots in processes of ten, each process naming its bots on from the last, until every room holds its players. A room still short after 120 s stops the pass.
- Waits
settleSeconds, measuresseconds, then takes each profile the file asks for. - Stops every bot and room it started, by its process id, and appends a row for each room to the results file.
--results <file> names the results file. Without it, the rows go to .spawnite/bench/<rooms file's name>.jsonl in the game’s folder, which git ignores. spawnite play bench report <file> prints every row a results file keeps, oldest first.
Read the table
Section titled “Read the table”Each room run prints a row:
when room round players ticks/sends tick p50 p95 p99 catch-up skipped room CPU MB B/s a player bot cores machine busy piled spread holds 2026-10-11 18:00 fifty-spread 1 50 60/60 4.2 6.1 8.0 2 0 35.5% 410 21,000 0.6 12.5% 0/50 191 m yesEach row has the following columns:
tick p50,p95andp99: a tick’s time at the median and the 95th and 99th percentiles, in milliseconds. A tick is the step and the send that ended it.catch-up: the steps the room ran straight after the one before, to catch up with the wall after a late tick.skipped: the steps’ worth of time the room dropped, each turn of its loop running at most a few catch-up steps. A room waiting for a CPU can read a fine p95 while it drops a third of its steps, so read this column beside the percentiles.room CPU: the room’s CPU over the window, as a share of one core.MB: the most resident memory the room held.B/s a player: the bytes the room sent each player a second.bot cores: the bots’ CPU over the window, in cores. A count near the processors left to them says the bots, not the room, set the limit.machine busy: every core’s share busy over the window, the room’s and the bots’ included. Past their share, other programs shared the machine.piled: the bots within 1 m of another bot at the window’s end, of those with a character. A pile at the spawn costs a room far more than the same bots spread out.spread: the bots’ median distance from their middle at the window’s end, across the ground. Each bot says where its character stands in its own world, so the read costs the room nothing.holds:yeswhile every requested player stays seated through the window, the tick’s p95 stays under one tick of the room’s own rate, 16.7 ms at 60 and 33.3 ms at 30, and the room skipped no step.ticks/sendsnames the rates each room ran at.
The results file keeps each row as JSON, with the commit the game’s checkout stood on, the room’s settings and flags, the machine’s CPU, processors and memory, whether the processes were pinned, and the step and send percentiles. It also keeps the room’s live set: the most heap the last full collection had left, read at each heartbeat of the window. Rows from two machines compare only beside their machine.
Profile a room’s CPU
Section titled “Profile a room’s CPU”--cpu-profile <seconds> on spawnite play room profiles each run’s room over Node’s inspector, from --after <seconds> into the run. On spawnite play bench, it profiles each room after its window, over the file’s cpuProfileSeconds. Node’s own --cpu-prof writes its profile only as a process exits cleanly, which Windows cannot ask a process to do, so the inspector takes it while the room runs.
The command prints where the room’s thread spent its busy time, each line a share of it. Busy leaves out the samples V8 names (program): the thread running no JavaScript V8 can name, which in a room is mostly its event loop waiting between ticks. The first line prints those seconds apart. Counted as busy, a room of 2 bots read 29% busy on 9.7% of a core (measured, 2026-10-19).
- By owner: each sample charged to the engine, room or game folder nearest its leaf, so a Rapier cast or a koota query counts toward the code that called it. A kept profile from another checkout names each folder from the path under its last
packagesorgamesfolder. - Self time by file and by function, each bundle’s frame named through its source map on disk, and each Rust function in the engine’s Rapier by its name without the
::hhash or a[1]after it, so its copies sum into one row. - Time by function with what each called, and the heaviest call path above each top function.
The profile is kept as a .cpuprofile, which Chrome’s DevTools Performance panel loads: beside --out or the results file, or in spawnite/room-profiles in the system’s temp folder, which keeps the newest 20 profiles. To print a kept profile again, run spawnite play room profile <file>, and add --calls <pattern> for the call paths under the frames a pattern names, such as readControlled. The profiler slows the room, so read a room’s tick times from a window without it.
Two options sum only part of the busy time, and each share is then of the part kept. They combine, so --under stepWorld --ticks-over 16.7 reads what the step called in the ticks over one 60 Hz tick:
--under <pattern>keeps the samples under a frame the pattern names, such asstepWorld. Each list starts at the outermost such frame, so it reads what that frame called rather than the timer and step wrappers above it.--ticks-over <ms>keeps the ticks that ran that many milliseconds or longer. A tick is a run of samples under Node’s timers, with the garbage collection between them, because the room steps on a timer. A tick that runs into the next one reads as one with it.
The bench fixture
Section titled “The bench fixture”games/bench-field is a room game kept for capacity runs: a flat 1 km field with 300 box buildings, target dummies in each group’s patch, and bots that wander their patch and fire a rifle at the dummies in reach. A room’s env sets the field:
| Setting | What it sets | Default |
|---|---|---|
FIELD_NPCS |
NPCs that wander the field along the navmesh. | 0 |
FIELD_NPC_UNSEEN |
What each NPC does while no player is near: simulate, throttle or suspend. |
suspend |
FIELD_LAYOUT |
spread places the players in groups of ten, a patch each; packed in one 60 m circle. |
spread |
FIELD_BUILDING |
grid has each bot build on a voxel grid in its patch, entities one entity a block. |
off |
FIELD_BUILD_SECONDS |
Seconds between one bot’s builds. | 4 |
FIELD_FIRE |
0 keeps the bots’ rifles quiet. |
1 |
The room’s own settings, such as ROOM_INTEREST_DRILL, go in env beside them, and its rates in tickRate and sendsPerSecond, as the room’s settings list them.
Beside fifty.json, the fixture keeps bench/npcs.json, 100 and 1,000 wandering NPCs under 50 players. It also keeps bench/building.json, 50 players each placing a block entity every 4 s, with and without 1,000 NPCs, which measures what a placed collider costs the navmesh.
Where the numbers come from
Section titled “Where the numbers come from”A room started with ROOM_TICK_TIMES=1 names in each heartbeat every tick’s step, send and whole time since the heartbeat before, and the steps it ran to catch up. The bench reads the heartbeats whose second has its middle inside the window, and takes the percentiles over their ticks. The room’s CPU, memory, bytes and dropped steps come from the same heartbeats; the bots’ CPU from their processes, and the machine’s from every core’s times.