DimeTown is a top-down, GTA2-style roleplay city in a browser canvas. It runs its whole multiplayer city inside one Cloudflare Durable Object, so every player shares a single thread. Before I had 50 players, I had Claude point 50 bots at it. They used about 2 ms of every 50 ms tick, and Cloudflare’s price list puts a city that’s full and moving around the clock at about $15 a month, plus the $5 Workers plan.
Bots aren’t players and a projection isn’t a bill. Here’s the fine print, the bandwidth number I had wrong, and the netcode work that followed.
How the game runs on one Durable Object
The Worker does one job. It takes /ws and hands every connection to the same object:
// worker.ts, simplified
const stub = env.CITY.get(env.CITY.idFromName("city-1"));
return stub.fetch(req);
That object, City, is the whole game: the authoritative simulation (people, traffic, NPCs, events, the economy) on a 20 Hz setInterval, characters and world state in the object’s built-in SQLite, and a cap of 50 players. Sockets use the Hibernation API, so the object can sleep when the city is empty. While anyone is connected, the tick keeps it awake. That matters for the bill.
Each player sends input in a batch about every 66 ms (roughly 15 messages a second) plus a small ping every two seconds. The server sends each player a snapshot of what’s near them every tick.
How much CPU do 50 players use on one Durable Object?
I wanted a number before I had the players, so Claude wrote a throwaway benchmark: 50 fake connections, spawned in one spot so they started in view of each other, then wandering at random for five simulated minutes at 20 ticks a second, with a timer around the world’s tick(). It ran the real game code, with Node’s built-in SQLite standing in for the object’s and canned answers standing in for the AI calls.
- Tick time: 1.9 and 2.0 ms on average across two runs, p99 about 3 ms. That’s roughly 4% of one core against a 50 ms budget.
- Outbound: about 1,160 messages a second in total, and 27 KB/s per player of uncompressed JSON.
- Database writes: about 6 rows a second, mostly periodic player saves.
It ran in Node on a Mac with no real sockets, no workerd and no network, and the harness was deleted afterwards, so I can’t re-run it. JSON encoding and compression happened outside the timer too; a later lab, with bigger snapshots, put them at about 0.5 ms and 1.75 ms a tick (Node’s zlib). Even so, a few ms against 50 isn’t close.
How much do Durable Objects WebSockets cost?
Prices are from Cloudflare’s Durable Objects pricing and Workers pricing pages as of 7 October 2026. Three rules decide the bill:
- Incoming WebSocket messages bill as requests at 20 to 1 (100 messages is 5 requests). Outgoing messages and protocol pings are free, but my ping is an ordinary JSON message, so it counts.
- Duration is wall-clock time at a flat 128 MB while the object is awake and can’t hibernate.
- SQLite storage bills rows read and written.
For a full city, 24 hours a day, 30 days a month:
| Estimate | Included, then | Bill | |
|---|---|---|---|
| Incoming messages | about 775/s (50 players at about 15 a second, plus pings) is 2.0 billion a month, or 100M billed requests | 1M, then $0.15 per million | about $15 |
| Duration | one object, always awake: 2,592,000 s at 128 MB is about 332k GB-s | 400k GB-s, then $12.50 per million | $0 |
| SQLite rows written | about 6/s, or 16M a month | 50M, then $1.00 per million | $0 |
| Outgoing messages | about 1,160/s | free | $0 |
| Workers Paid plan | $5 minimum | $5 | |
| Total | about $20 a month |
Claude’s first answer was 65 to 100 million billed requests, which gave $10–15. I can only reproduce the 100 million end, so plan around $15 for a city that’s permanently full and moving. Real cities have people standing still and empty hours, when the object hibernates.
What the projection leaves out. As of October 2026 the game uses TypeSafe’s Jev model for judgment calls, such as which nearby NPC you’re talking to. NPC decisions are batched and the server’s Jev client keeps at most eight calls in flight, but I have no number I trust for that bill, and Claude’s estimate didn’t have one either. A small OpenAI model also writes lines into a bank when none fit, which Claude called negligible without pricing it. The $15 is Cloudflare only.
I use TypeSafe’s Jev in DimeTown; I’m not affiliated with TypeSafe.
What limits a single Durable Object?
One thread, shared by every player. Hundreds of players would mean splitting the city across several objects, which is a game design question as much as an engineering one: who can see whom, and what happens at the border.
Cloudflare’s limits page gives an object a soft limit of 1,000 requests per second, and my 775 incoming messages a second is in the same neighbourhood. The page doesn’t say whether WebSocket messages count towards it the way they do for billing, so I’d test that with real sockets before raising the cap.
Tick time grows with the game, too. With half the bots driving through 80-odd AI cars, a later load test read 3.2 ms; 4.6 after the rewrite below; 7.87 by 3 October with police chases. Still a fraction of 50 ms, but every feature spends it.
Why does my browser game rubber band?
After the cost question I asked for something harder: “I want driving to feel amazing, rubber banding to feel rare and the world to feel like it nicely coexists.” Before touching any netcode, Claude built a network lab: the real server world on a fake clock, with simulated clients running the real client loop over simulated links. Clean is 25 ms each way plus up to 5 ms of jitter. Wi-Fi is 45 ms plus up to 30 ms of jitter, with 2% of messages held up to 120 ms more. Far is 110 ms each way plus up to 25 ms of jitter and 2% spikes of up to 150 ms, about 250 ms round trip.
A bot driver laps the city ramming traffic while a second client watches, six seeds of 30 seconds per link, because single runs lie: crash counts swung between 16 and 40. The lab counts corrections, where a client’s prediction moved more than 4 px when the server’s answer arrived, and hitching frames, where another player’s car visibly stutters. Before any change: 18, 37 and 286 corrections per 30 seconds on clean, Wi-Fi and far.
Two causes. The server applied input in whatever batches the network delivered, so people and cars moved 0, 4 or 8 steps a tick and stuttered on everyone else’s screen. And only my own car was predicted; everything else was drawn 150–250 ms in the past, so I’d collide with cars that weren’t where my screen showed them. Three changes fixed most of it:
- Even input playout. The server queues each player’s commands and takes one step per 60 Hz world step. The queue is an adaptive jitter buffer: it grows when the link stalls and shrinks after a second of slack. On an empty road, that alone cut a watching player’s stutter from 518 hitching frames in a run to 116.
- Rigid-body cars on shared code. Momentum, grip limits and collision impulses, identical on client and server, so a prediction can be exact.
- Predict nearby vehicles to “now”. Clients run every vehicle forward to when the server will apply the input I’m pressing, and ease any disagreement out (most of it within about 0.2 s) without touching the physics. It’s the approach in Psyonix’s GDC 2018 Rocket League talk, and Gabriel Gambetta’s series covers the basics.
The right-hand figures are from the pull request: the stutter counts are without traffic, and the parked-car bumps are a separate scripted test. All of it is simulated: Claude tuned it with the lab and a scripted test drive. How it feels still needs a person behind the wheel.
What didn’t work
- Sending input every 33 ms instead of 66 ms. Over 10 seeds, corrections over 4 px per 30 seconds were 10.1 against 7.2 on Wi-Fi (worse) and 15.9 against 18.3 on the far link (slightly better). More jitter-buffer stalls ate the shorter horizon, so it was a wash.
- Sending AI traffic’s acceleration so clients could extrapolate its braking made corrections over 4 px go from 195 to 305 across twelve 30-second runs (Wi-Fi and far, six seeds each). Noisy accelerations overshoot. Reverted.
- A follow-up fix on that branch nearly doubled the worst correction beside swinging AI traffic on the slow link, from 47 to 83 px, until an independent review caught it.
Still rubber banding: bumping moving AI traffic on a 250 ms link, because the client has to guess where an AI car will be about 300 ms ahead. Fixing that properly needs server-side lag compensation, which I haven’t built.
How do you cut multiplayer bandwidth? Send what changed
The first sizing called bandwidth the weak spot: 27 KB/s of JSON per player, about 100 MB an hour if it had gone out uncompressed. The driving rewrite then grew snapshots from about 2 KB to 3.2 KB of JSON, so the next job was delta snapshots.
The server remembers what it last sent each client for every entity in view. An entity new in view goes whole, one the client already has goes as the change in each field, and a parked car or an idle NPC goes nowhere. The client rebuilds the full list before anything reads it, so prediction and interpolation see exactly the server’s values. A keyframe every 5 seconds resends everything in view, so any drift heals itself, at a cost of about 2%.
In the load lab (50 players, half driving through traffic) the JSON per player per tick fell from 2.8 KB to 1.3 KB, and what permessage-deflate puts on the wire fell from 521 to 334 bytes, 36% less.
The surprise was how little the obvious half did. Workers negotiate WebSocket compression by default (the web_socket_compression compatibility flag is on by default for dates from 2023-08-15), so repeats between snapshots already cost almost nothing, and leaving out unchanged entities alone saved only about 5% on the wire. Sending changes did the rest: a cruising car’s change is the same few digits every tick, which compresses far better than its position does.
That 27 KB/s was never what went over the wire: compression was already on. In the lab, before deltas, 2.8 KB of JSON a tick (about 56 KB/s) went out as 521 bytes, about 10.4 KB/s or 37 MB an hour. After, 334 bytes: about 6.7 KB/s, or 24 MB an hour per player, before frame headers.
Two bugs the bots never found
NPC jitter, only in production. Late snapshots jolted the browser’s estimate of the server clock; a render clock that slews at most 5% fixed it. The full story is receipt 5 in My AI’s tests passed. The code was broken..
Durable Object SQLite allows 100 bound parameters per query. A code review of my telemetry pull request caught a query with one parameter per open session (IN (?,?,…)). Past 100 sessions it throws on every pass, and a bot creating characters and disconnecting could get there. My unit tests run on Node’s built-in SQLite, which takes 150 parameters without complaint, so none of them could catch it until the fix added a test whose fake database throws past 100, the way Cloudflare’s limits page says the real one does. The fix is one JSON parameter:
// before: one bound parameter per open session, throws past 100
sql.exec(`SELECT id FROM chars WHERE alive = 1 AND id IN (${ids.map(() => "?").join(",")})`, ...ids)
// after: one parameter, however many ids
sql.exec("SELECT id FROM chars WHERE alive = 1 AND id IN (SELECT value FROM json_each(?))", JSON.stringify(ids))
What I’d check before adding players
- Real sockets against the deployed Worker: tick time on workerd, the message rate against that 1,000 requests a second soft limit, and real wire bytes to replace my zlib simulation.
- A price for the AI calls. Without it, “$15 a month” is only the Cloudflare half.
- The lab, kept. It’s the only reason I can say rubber banding got measurably better. The five-minute bench behind the headline number is gone, and I trust that number less for it.