1,000 players per CPU core: the architecture of a browser MMO
growordie is a massively multiplayer snake game where every snake occupies one shared arena, every collision is lethal, and every player's score is on the line every tick. The engineering brief was blunt: hundreds of concurrent players per server, one mistake costs a player their entire run, and it all has to work in a browser tab on a phone. Here's the architecture that gets us to roughly 1,000 players on a single CPU core — measured, not projected — on vanilla Node.js with no game engine.
The server is the only truth
growordie's server is fully authoritative. Clients send inputs; the server simulates everything — movement, growth, collisions, kills — at a fixed 30Hz, and clients render what they're told. No client ever computes a collision that counts.
In a game where a kill transfers 40% of the victim's size, this isn't optional hygiene. A client-trusting design would be cheated within a week: fake positions, ignored collisions, teleporting heads. The classic objection to server authority is input latency, but a snake game is kind to us here — you steer a heading rather than aiming hitscan shots, so client-side interpolation between server states feels smooth well past 100ms.
The cost of authority is that the server does all the work, which makes the per-tick budget the whole game. At 30Hz we get 33.3ms per tick, and every subsystem below exists to protect that budget.
Bytes are the scarcest resource
The naive io-game approach — JSON state over WebSocket — dies at scale, not from CPU but from bandwidth and serialization. A JSON position update like {"id":"a3f","x":1042.7,"y":-338.2,"a":1.57} costs ~50 bytes before you've said anything interesting.
growordie speaks a hand-rolled binary protocol instead:
- Snapshots go out at 15Hz (the sim runs at 30Hz; clients don't need every frame, they interpolate). Each snake's update packs into 11 bytes: id, quantized position, heading, length delta, flags.
- Inputs are 3 bytes: enough for a heading and a boost bit, with room to spare.
Against the JSON baseline that's a 5×–15× reduction per message, and it compounds: encoding is a fixed-offset DataView write instead of string building, and the GC pressure of JSON stringification disappears from the profile. For a Fly.io-hosted game (we deploy in Paris), egress is also a real bill — bytes are money.
Collision detection: the incremental spatial hash
Collision is the algorithmic heart of a snake MMO. Every head must be tested against every body segment in the world, every tick. Brute force is O(heads × segments); with hundreds of snakes at hundreds of segments each, that's tens of millions of tests per tick. Dead on arrival.
The standard fix is a spatial hash: divide the world into a grid, bucket every segment by cell, and test each head only against its neighboring cells. The less-standard part of growordie's implementation is that the hash is incremental. We never rebuild the grid from scratch. A snake moves by growing one segment at its head and (when boosting or shrinking) dropping segments at its tail — so per tick, each snake touches O(1) cells: insert the new head segment, remove any expired tail segments. Update cost is O(1) per snake per tick, regardless of how long the snake is.
A 100-meter titan and a 2-meter rookie cost the grid exactly the same to keep current. Queries stay local too: each head checks a handful of cells around it. The whole collision system scales with player count, not with total mass in the arena — which matters enormously in a game whose entire premise is that everything keeps growing. (Why everything keeps growing is a design story, not an engineering one.)
AOI: nobody gets the whole world
The second scale killer in MMO networking is fan-out: N players each receiving updates about N players is O(N²) bandwidth. growordie caps this with area-of-interest (AOI) filtering by viewport: each client receives snapshots only for snakes that intersect what that client can actually see. Since we already have the spatial hash, "what's near this viewport" is a cheap grid query — the collision structure and the interest-management structure are the same data.
The pleasant side effect: a player's bandwidth scales with the local density of the action around them, not with server population. Whether the server holds 80 players or 800, your connection carries roughly one screenful of snakes at 15Hz, 11 bytes each.
Degrade gracefully, never pause
Load isn't constant, so the server adapts instead of falling over. Under pressure, snapshot rate degrades from 15Hz to 10Hz — clients interpolate slightly further and almost nobody notices — while the 30Hz simulation stays fixed, because collision fairness is non-negotiable in a one-touch-death game.
The measured numbers
The stack is deliberately boring: vanilla Node.js (no engine, no framework — an io game's server is a loop, a grid, and an encoder; frameworks mostly add allocation), Postgres via Supabase for the persistent layer (the all-time top-100,000-run leaderboard and lifetime-unique nicknames), and Fly.io in Paris for compute close to European players. The hot path — simulate, hash, snapshot, send — touches none of that; the database never blocks a tick.
What we'd tell you to steal
- Authoritative server, interpolating clients. For heading-steered games, latency is a solved problem and cheating isn't — pick accordingly.
- Binary protocol before horizontal scaling. An 11-byte update buys more headroom than a second server, and it's free forever after you write it.
- Make one spatial structure do double duty. Collision and AOI want the same query; build the grid once, incrementally.
- Degrade the observation rate, never the simulation rate. Players forgive 10Hz snapshots. They don't forgive a collision that ate their 40-meter run.
— the growordie team