50 players on one Cloudflare Durable Object: the netcode numbers
DimeTown's whole city runs inside one Cloudflare Durable Object: up to 50 players, the NPCs, and every car, traffic included, simulated by one single-threaded JavaScript object with SQLite beside it. It ticks at 20 Hz and moves everything in 60 Hz fixed steps. Your browser predicts your own movement and every vehicle near you, so you hit cars where the server will say you hit them. In the lab on 4 Oct 2026, a 50-player tick (25 driving, 25 walking, 83 traffic cars) averaged 6.1 ms of its 50 ms budget on my laptop. A driver ramming through traffic over a simulated Wi-Fi link saw about 10 corrections bigger than 4 px per 30 seconds. Every number below comes from that lab, a simulation on one machine. None of them were measured on the live game.
The game is a top-down, GTA2-style roleplay city at /play/. The overview of how DimeTown works covers the dice and the NPC judgments; this page covers the networking.
Why one Durable Object per city
A Durable Object is a named, single-threaded instance with its own storage. Cloudflare creates it near where it's first requested, and it can accept WebSockets. The Worker sends every /ws connection to the same one:
const stub = env.CITY.get(env.CITY.idFromName("city-1"));
That object is the authority for everything. One thread means no locks: a carjacking, a bribe and a car crash can't race, because only one runs at a time. The SQLite storage API is synchronous, so saving a character never yields mid-tick. Characters, the NPC line bank, laws and the event log all live in that database; there's no separate game server, cache or database.
The limit is the obvious one: one thread in one place. The city caps itself at 50 players (MAX_PLAYERS), and a player far from wherever the object landed pays the full round trip, which is why the client predicts so much.
About hibernation: sockets are accepted through the Hibernation API (ctx.acceptWebSocket), each with an attachment holding the player id. But Cloudflare's docs say setInterval keeps an object awake, and a 20 Hz tick is exactly that, so while anyone is playing the object never sleeps. When the last socket closes, the interval stops and every player and NPC is saved. If the object is ever rebuilt with sockets still attached, the constructor re-attaches each player from those attachments. A deploy disconnects every socket regardless, and the clients reconnect.
The loop: 20 Hz ticks, 60 Hz steps
The constants are in shared/constants.ts: TICK_HZ = 20, STEP_HZ = 60, INPUT_SEND_MS = 66, INTERP_DELAY_MS = 110, VIEW_RADIUS = 1100 px.
Each 50 ms tick works out how many 60 Hz world steps are due (usually three) and runs them in lockstep. Each step applies one step of every player's input, then moves the vehicles, resolving contacts in id order so every client that predicts the same contact gets the same answer. Then every player gets a snapshot of what's within 1,100 px, stamped with the step clock, not the wall clock.
What the client predicts, and how the server reconciles
This is the Valve/Gambetta model. Walking (shared/sim.ts) and driving (shared/vehicle.ts) are the same deterministic fixed-step code on both sides; the client runs it the moment you press a key. Identical steps in a row are run-length encoded into one command, [seq, mx, my, run, steps], and about every 66 ms (roughly four steps) the unsent commands go up the socket.
Applying those batches the moment they arrived is what broke first. Batches land bunched up or held back, so a player moved 0, 4 or 8 steps in a tick, and everyone watching saw them stutter. The fix (server/playout.ts) treats each player's queue as a jitter buffer. Each world step takes one step of input:
// One a step; any extra goes in with the first, and a dry buffer stalls the last ones.
for (let i = 0; i < Math.min(take, n); i++) out[i] = 1;
if (take > n && n > 0) out[0] += take - n;
A dry queue stalls the player a step, letting the buffer build by one; a buffer at least two steps deeper than needed for a whole second gives a step back. Past 8 steps of slack (after a lag spike) it works off one extra step per tick. Past 30 (a tab that froze) it plays everything over 30 straight away. A budget of n × 1.002 steps per tick stops a client speeding itself up by sending more input.
Acks are exact to the step. Each snapshot carries ack, the last command applied in full, and ap, how many steps of the next one were applied. The client drops what's acked, takes the server's position (or car state: position, heading, speed, slip, yaw rate, trailer angle and cargo), replays the rest, and compares. The physics takes the correction at once; the screen eases it out at a rate that grows with its size (6 + min(14, error / 3) per second). Over 96 px on foot or 160 px in a car counts as a teleport and snaps.
Other players' cars: predicted to now, not interpolated
People (other players and NPCs) are drawn 110 ms in the past, interpolated between snapshots. The clock they're drawn on drifts toward the server-time estimate by at most 5% instead of jumping to it. Before that change, every late snapshot moved the estimate, and named NPCs visibly hitched backward and forward on the live game.
Cars can't work like that: if your car lives in "now" and the one you're about to hit is drawn 150 ms ago, you collide with something you can't see. So client/src/fleet.ts predicts every vehicle around you forward to the moment the server will apply the input you're pressing:
- Another player's car runs on the shared physics with that driver's input, which the server averages over about 0.1 s and packs into a single number in the snapshot. A held key comes through exactly. Someone tapping the wheel comes through as the line they're actually taking.
- Traffic carries on at its last speed and spin. Its spin is averaged over three snapshots, because a traffic car's heading can hold for one tick halfway through a turn, and extrapolating that tick alone made it alternate between full spin and none.
- Anything nobody's steering (a parked car you shoved, traffic knocked out of its lane) rolls on the same physics the server uses, into your car if you're in the way.
- Trailers swing on their tractor's fifth wheel.
Each snapshot rebases them all on the server's state, your unacked steps are replayed with them, and whatever that moved them by is eased out the same way as your own corrections.
A vehicle in a snapshot is [id, x, y, dir, bits, heading in mrad], plus [vx, vy, spin, driver input, tractor] while it's moving or driven. Snapshots are deltas: the server remembers what each client last received and sends only the fields that changed, so a parked car or an idle NPC isn't sent at all. A keyframe every 100 ticks (5 s), staggered by player, heals any drift. Workers negotiate permessage-deflate by default (the web_socket_compression flag), with one window per connection, so a cruising car's change, the same few digits every tick, costs almost nothing on the wire.
The lab
tests/lab/ is how all of this got tuned. netsim.ts builds a real World (the class the Durable Object runs, on Node's in-memory SQLite) on a fake clock. Simulated clients run the browser's real ClientState and Fleet code over ordered links with latency, jitter and spikes, and the server timer wobbles by ±4 ms. Math.random is seeded, so a seed always replays identically.
The links in the driving bench are one-way delays:
| Link | Base | Jitter | Spikes |
|---|---|---|---|
| clean | 25 ms | 0–5 ms | none |
| Wi-Fi | 45 ms | 0–30 ms | 2% of messages, up to +120 ms |
| far | 110 ms | 0–25 ms | 2% of messages, up to +150 ms |
The bench puts an autopilot driver in a sedan, lapping a block of the city for 30 s and ramming whatever traffic gets in the way, with a second player watching. It runs 6 seeds per link and measures the last 28 s.
- A correction is how far the driver's own predicted car moved when a snapshot came in.
- A hitch is a frame where a drawn path bends more than 1 px away from the straight line between the frames either side of it. At arcade accelerations a car bends well under 0.5 px a frame at 60 fps.
The numbers (lab, 4 Oct 2026)
All of these come from one run of npx vitest run --project lab on 4 Oct 2026. It ran on Node 24.20 on an Apple M5 Max laptop that was busy with other work at the time (load average around 5), not on Cloudflare's runtime. Treat the timings as relative, not as what the live object spends.
Rubber-banding while driving (per 30 s, mean over 6 seeds, median in brackets). For scale, a sedan is about 101 px long.
| Link | Crashes | Corrections > 4 px | > 16 px | Biggest (worst seed) | Own-car hitches |
|---|---|---|---|---|---|
| clean | 11.8 | 7.0 (4.0) | 1.0 (0.0) | 20.6 px | 8.5 |
| Wi-Fi | 10.0 | 9.7 (7.5) | 1.5 (1.0) | 33.5 px | 14.5 |
| far | 13.2 | 21.2 (20.0) | 5.3 (5.0) | 69.2 px | 23.2 |
Per crash, the bench counted 0.59 corrections over 4 px on the clean link, 0.97 on Wi-Fi and 1.61 on the far one.
How another player's car looks to the watcher, out of about 1,680 frames: 18.2 hitching frames on the clean link, 7.7 on Wi-Fi and 10.5 on the far one. The 99th-percentile bend was 0.7–1.0 px, and the worst single frame bent 4.0–4.3 px. The clean link coming out worst surprised me, and I can't explain it yet.
What predicting costs the client: 2.3, 2.8 and 3.9 ms of CPU per second of driving on the three links, with about 36 vehicles in the predicted fleet.
Server tick at 50 players (the load test: 25 players driving laps, 25 walking about, all on a 30 ms + 0–20 ms link, 20 s timed on the real clock):
| Average | p50 | p99 | Max | Traffic sim | |
|---|---|---|---|---|---|
| 50 players | 6.12 ms | 6.03 ms | 9.19 ms | 10.51 ms | 1.62 ms |
| + 2 police chases | 5.56 ms | 5.48 ms | 7.64 ms | 8.33 ms | 1.37 ms |
The city had 83–84 traffic cars and 186–189 vehicles in all; the two cruisers took 0.1 ms of a tick. The chase run coming out faster is noise from the busy machine: read these as "about an eighth of the budget", not exact figures.
Bytes per tick per player (the first five players in both load runs, averaged over the last 100 ticks, so one keyframe each): 683–1,456 bytes of JSON, and 164–368 bytes after permessage-deflate, before frame headers.
Handling (the feel test, a sedan on an empty lot): 0 to 244 px/s in 1 s and top speed (380 px/s) by 2 s. Braking from top speed takes 0.47 s over 85 px. The steady turning circle at full speed is about 336 px across, with 5.9° of slip. Handbrake plus throttle swings a 180 in 1.07 s from 250 px/s. Handbrake alone, without throttle, only got 131–157° round before the car stopped.
Police (12 seeds): a cruiser busted a wanted driver parked out of its sight in 45 of 48 runs (median 12.6 s), parked anywhere in 69 of 72 (median 10.2 s), and fleeing round a block in 24 of 24.
The burst test replays 30 s of snapshot arrival timing recorded from the live server (only the variation between arrivals; absolute latency wasn't recorded). Over 6 seeds, the recorded schedule gave 9 driver corrections over 4 px and none over 16 px, against 8 and none on an evenly spaced schedule. The watcher saw 45 hitching frames of 10,080 against 20.
What broke and what fixed it
From the git history of shared/vehicle.ts, server/playout.ts, client/src/fleet.ts and client/src/state.ts. The before-and-after figures are the lab numbers written in each commit message on the day. I haven't re-run the old code.
- 25 Sept, NPCs hitching on the live game. Remotes were drawn on the raw server-clock estimate, which jumps whenever a late snapshot lands. Fix: a render clock that slews toward it by at most 5%.
- 27 Sept, your own run buzzing. Steps are 60 Hz, but most Macs refresh at 120 Hz, so you moved 3 px, 0, 3 px, 0 against a smoothly easing camera. Fix: draw between the last two steps.
- 28 Sept, driving. Batches applied on arrival made cars stutter, and cars drawn in the past made crashes land in the wrong place. Fix: the playout buffer, lockstep vehicles, step-exact acks, shared rigid-body physics, and every vehicle predicted to now. The commit's lab numbers: corrections over 4 px per 30 s went from 18 / 37 / 286 to 13 / 7 / 21 (clean / Wi-Fi / far), and another player's car went from 510+ hitching frames to 10–20, drawn about 20–40 ms behind the server instead of 150–250 ms. The server tick at 50 players went from 3.2 ms to about 5.
- 28 Sept, bandwidth. Delta snapshots: in the load lab, 2.8 KB of JSON a tick became 1.3 KB, and 521 bytes on the wire became 334.
- 28 Sept, police. Cruisers steered straight at you, so a building between you pinned them. Fix: route along the road network, with a car-sized A* off it. Busts went from 13 to 133 out of 144 lab runs.
- 3 Oct, traffic spinning. Traffic heading through a junction alternated between full spin and none on everyone's screen. Fix: average its spin over three snapshots.
What this doesn't tell you
The lab is a model. Its links are made up, apart from the one recorded schedule; it runs on Node, not Cloudflare's runtime; its drivers are autopilots, not people panicking at a junction. It says nothing about phones on mobile data or how many players the city actually gets. When there are enough real sessions, the frame-rate reports the game already sends will be the better source, and they'll go here with their sample and dates.
How the NPCs decide what to say is in NPC dialogue that writes itself.
Sources
- Durable Objects: what are Durable Objects, checked 4 Oct 2026
- Durable Objects: use WebSockets (Hibernation API), checked 4 Oct 2026
- Durable Objects: SQLite storage API, checked 4 Oct 2026
- Workers compatibility flags: web_socket_compression, checked 4 Oct 2026