1,000 players per CPU core: the architecture of a browser MMO

I'll be honest: the thing that kept me up at night wasn't whether anyone would play growordie, it was whether one cheap little server could actually hold them all at once. It's a massively multiplayer snake game where every snake shares a single arena, every collision is lethal, and every player's whole score is on the line every single tick. The brief I set myself was blunt: hundreds of players per server, one mistake wipes a player's entire run, and all of it has to run in a browser tab on a phone. This is the architecture I landed on, the one that holds roughly 1,000 players on a single CPU core, measured on a real box, not projected on a napkin, on vanilla Node.js with no game engine. And the part I didn't see coming was how much of making it hold up is really about what you refuse to do.

The server is the only truth

The first decision is the one everything else hangs off: the server is the only thing allowed to be right. I made it fully authoritative. Clients just whisper what they'd like to do; the server simulates everything that actually matters (movement, growth, collisions, kills) at a fixed 30Hz, and clients draw whatever they're handed. No browser anywhere ever gets to decide a collision that counts.

In a game where killing someone hands you 40% of their size, that's not fussy hygiene, it's survival. Trust the client and you'd be cheated to death inside a week: faked positions, collisions quietly ignored, heads teleporting across the map. The usual gripe about server authority is input lag, and this is where a snake game is quietly kind to me, you're steering a heading, not lining up a hitscan headshot. So the client can interpolate between the server's states and it still feels smooth well past 100ms.

The bill for all that authority is that the server does every scrap of the work, which turns the per-tick budget into the whole game. At 30Hz I get 33.3ms per tick, and honestly every trick below exists for one reason: to protect that 33ms.

client client client AUTHORITATIVE SERVER simulate · 30 Hz the only truth 3 B 3 B 3 B 11 B 11 B 11 B 3 B intent → up ← 11 B snapshot @ 15 Hz down

clients propose · the server decides· no client-side collision counts

Every client sends nothing but a tiny 3-byte intent, a heading and a boost bit. The server simulates movement, growth and every lethal collision at a fixed 30 Hz, then streams the result back as 11-byte binary snapshots. No head, no kill, no score exists until the server says so.

In a game where a kill transfers 40% of a run, that authority isn't hygiene, it's the whole anti-cheat model. Latency is forgiven because you steer a heading, not a hitscan shot; clients interpolate between snapshots and it stays smooth well past 100 ms.

Bytes are the scarcest resource

The obvious way to build an io game is to fling JSON over a WebSocket, and it works beautifully right up until it doesn't, and what kills it isn't CPU, it's bandwidth and serialization. A single position update like {"id":"a3f","x":1042.7,"y":-338.2,"a":1.57} already costs ~50 bytes before you've said anything actually interesting about the snake.

So I threw JSON out and taught growordie a little hand-rolled binary dialect instead:

Against the JSON baseline that's a 5×–15× cut per message, and it keeps paying off: encoding is a fixed-offset DataView write instead of building strings, and all that GC pressure from stringifying JSON just vanishes from the profile. And since I host on Fly.io (in Paris), egress is a literal line on the bill, so bytes are money in the most direct sense.

SERVER · sim 30 Hz 1 second · gold = snapshot tick (15 Hz) CLIENT · interpolates & renders 60 fps 11 B 11 B 11 B 3 B 3 B 3 B JSON {"id":"a3f","x":1042.7,…} ≈ 50 B binary snake update = 11 B · 5–15× smaller

30 Hz sim · 15 Hz binary snapshots· 3 B up / 11 B per snake down

The simulation runs at a fixed 30 Hz; snapshots go out on every second tick (15 Hz) because clients interpolate the gaps. Each snake packs into 11 bytes (id, quantized position, heading, length delta, flags) written at a fixed DataView offset instead of stringified.

The same update as JSON runs ~50 bytes and drags GC pressure into the profile. Binary is 5–15× smaller per message, and on a Paris-hosted Fly.io box that egress is a literal bill.

Collision detection: the incremental spatial hash

Collision is the beating heart of a snake MMO, and it's exactly where a naive approach dies loudest. Every head has to be tested against every body segment in the world, every tick. Brute force is O(heads × segments); with hundreds of snakes carrying hundreds of segments each, that's tens of millions of tests per tick. Dead on arrival.

The textbook fix is a spatial hash: chop the world into a grid, drop every segment into a cell, and only test each head against the cells around it. The part I'm a little proud of is that growordie's grid is incremental, I never rebuild it from scratch. A snake moves by growing one segment at its head and (when it's boosting or shrinking) shedding segments off its tail, so every tick each snake only touches O(1) cells: insert the new head segment, remove any expired tail ones. The upkeep is O(1) per snake per tick, no matter how long the snake has gotten.

A 100-meter titan and a 2-meter rookie cost the grid exactly the same to keep current. Queries stay cheap too, each head only peeks at the handful of cells around it. The whole collision system scales with player count, not with the total mass sloshing around 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.)

+ head world grid · one bucket = one cell head enters → insert O(1) tail leaves → remove O(1)

O(1) per snake per tick· no world rebuild

We bucket every body segment into a grid so a head only tests its neighbouring cells, but we never rebuild the grid. A snake grows one segment at its head and drops expired ones at its tail, so each tick is one insert and one remove: constant time, whether the snake is 2 m or 100 m.

The structure's upkeep scales with player count, not with total mass in the arena, exactly the property you want in a game where everything keeps growing.

AOI: nobody gets the whole world

The other thing that quietly murders MMO networking is fan-out: N players each hearing about N other players is O(N²) bandwidth, and that curve gets scary fast. I cap it with area-of-interest (AOI) filtering by viewport, each client only receives snapshots for the snakes that actually cross what it can see on screen. And because I'm already keeping that spatial hash around for collisions, "what's near this viewport" is just another cheap grid query. The collision structure and the who-can-see-what structure are the same data, I only had to build it once.

The nice surprise is what falls out of it for free: a player's bandwidth tracks the density of the action right around them, not the size of the server. Whether the box is holding 80 players or 800, your connection carries about one screenful of snakes at 15Hz, 11 bytes apiece.

arena · everyone the server simulates your camera +add −del

you only receive what's near your camera· known-set delta

The server simulates every snake, but sending all of them to everyone is O(N²) bandwidth. Each client gets snapshots only for snakes intersecting its viewport, a cheap query on the same grid the collision system already maintains.

The client keeps a known set: a snake crossing into view is a +add, one leaving is a −del. Your bandwidth tracks the density of the action around you, not the server's population.

Degrade gracefully, never pause

Here's the part it took me a while to really internalize: there are two clocks ticking at once in growordie, and keeping them straight in my head is what made the whole thing robust. So let me name them precisely:

The trick is that these two clocks are genuinely independent: the server can recompute the world 30 times a second but only photograph it 15 times. Which means that when a tick blows past its budget (over 22ms against my ~33ms ceiling), I get to choose what to give up. And the two choices are absolutely not equal:

So the rule I settled on is simple, and I never break it: degrade the observation rate, never the simulation rate. Players will happily forgive a slightly older frame, most of the time they can't even tell. What they will never, ever forgive is a collision that quietly ate their run.

tick cost 22 ms tick ms SNAPSHOTS · observation rate 1 second 15 Hz 10 Hz SIMULATION · fairness rate 30 Hz, held constant, never degraded load OK · snapshots 15 Hz tick > 22 ms → degrade to 10 Hz

degrade the observation rate, never the simulation· 15 Hz → 10 Hz above 22 ms

When a tick blows past 22 ms, the server drops snapshots from 15 Hz to 10 Hz, clients interpolate slightly further and almost nobody notices. The 30 Hz simulation never moves: it's the bottom rail, held constant while the top one flexes.

Players forgive a marginally older frame. They don't forgive a collision that ate their 40-meter run, so the rate that decides who dies is the one thing that's non-negotiable.

The measured numbers

On one core: 500 players ≈ 11ms tick (a third of the 33ms budget) · 1,000 players ≈ 33ms, the honest ceiling, and it's per core.

The stack underneath is deliberately, almost aggressively boring, and I mean that as a compliment. Vanilla Node.js, no engine, no framework: when you get right down to it an io game server is a loop, a grid, and an encoder, and most frameworks would only add allocation I'd have to pay for. Postgres via Supabase holds the stuff that has to outlive a match (the all-time top-100,000-run leaderboard and the lifetime-unique nicknames), and Fly.io in Paris keeps the compute close to European players. The hot path (simulate, hash, snapshot, send) never touches any of that, so the database can't block a tick even if it wanted to.

One event loop, on purpose

People ask how I handle thread starvation, and the honest answer is that I don't have any threads to starve. The whole arena is a single Node.js process on a single event loop: no worker pool fighting over the CPU, no locks to manage, no chance of a greedy high-priority thread choking out everyone else. That entire class of bug just isn't in the building, and dodging it is one of the reasons I kept the server single-process in the first place.

The catch is that the same choice hands you a different failure mode: event-loop starvation. Block the loop once, with a slow synchronous call or an unbounded piece of work, and every socket's I/O piles up behind it. So the discipline is exactly the two things this whole post is about. The tick is bounded and never does unbounded work per frame (that is what the O(1) hash and the AOI buy me), and nothing synchronous ever touches I/O on the hot path (the database writes are async and off the tick). Hold those two lines and one loop stays snappy for a very long time.

Where it genuinely bites is the ceiling. One event loop cannot spread a single arena across cores, so I am not scaling by adding threads, I am scaling by adding arenas: past roughly a thousand concurrent, the plan is room sharding, more processes and more machines, each one its own loop owning its own world. Threads were never going to save this game. Sharding will.

HEALTHY the loop cycles: input → simulate → hash → snapshot → send input simulate hash snapshot send 1 loop 30 Hz async write, off the hot path it never blocks the tick Postgres STARVED one blocking call and every socket waits behind it input hash snapshot send ! blocking sync I/O the loop cannot turn STALLED sockets pile up latency ↑ timeouts SHARDING scale by adding arenas, not threads 1 loop = 1 arena N independent loops · more processes, more machines

one loop, on purpose· healthy vs starved · shard to scale

A healthy arena is one loop that never stops turning: input, simulate, hash, snapshot, send, over and over, while the database write peels off to the side and finishes on its own time. That write is the only slow thing in sight, so it lives off the hot path where it can never stall a tick.

Break that rule once with a blocking synchronous call and the whole loop freezes mid cycle. Every socket queues up behind it, latency climbs, timeouts follow. There are no threads to starve here, so the discipline is simply this: keep the tick bounded and keep I/O async.

And when one core finally fills, you don't reach for threads. You add another arena, its own loop owning its own world. One loop becomes many, and the game scales sideways.

What we'd tell you to steal

  1. Authoritative server, interpolating clients. If your game steers a heading rather than aiming a shot, latency is basically solved and cheating isn't, so spend your worry on the one that'll actually hurt you.
  2. Reach for a binary protocol before you reach for a second server. An 11-byte update bought me more headroom than another machine would have, and unlike a machine it's free forever once it's written.
  3. Make one spatial structure pull double duty. Collision and AOI are secretly asking the same question: build the grid once, keep it incremental, and let both of them lean on it.
  4. Degrade the observation rate, never the simulation rate. Players will shrug off 10Hz snapshots. They will not shrug off a collision that ate their 40-meter run, and honestly, they shouldn't have to.

The growordie team