You can't send N² updates
The first time I had more than a dozen snakes sharing one arena on growordie.io, everything was fine on my machine and then a friend joined from his phone and the whole thing turned to syrup. Not the game logic, that was cheap. It was me trying to tell everyone about everyone. I'd quietly built an N² bomb and lit the fuse. This is the story of how I defused it, and honestly it's my favorite part of the whole engine.
If you want the bird's-eye view of the stack first, I wrote that up in 1,000 players per CPU core. This one goes the other way, it zooms all the way down into a single function, broadcast(), and doesn't leave.
The wall I walked into
Here's the naive thing I did first, the thing every io game does first: every snapshot, send every snake to every client. Feels innocent. It's a quadratic. N clients × N snakes = N² messages, every single snapshot.
Let me put a number on how bad that gets, because the number is what scared me straight. At 1,000 players, one snapshot is a million snake-states. I send snapshots 15 times a second, so that's 15 million per second. My on-wire snake update is a tight little 11 bytes (id, a quantized position, heading, length, a flag byte) and even at 11 bytes that's ~165 MB/s pouring out of one core, north of a gigabit, before TCP even opens its mouth. And that's just the bytes leaving; I still have to build all million of them first. It doesn't work. It doesn't come close to working.
But there's a way out, and it's almost embarrassingly obvious once you say it out loud: you can only ever see one screenful of arena. Doesn't matter if 80 snakes are alive or 800, only a couple dozen are anywhere near your camera at any moment. Every byte I spent describing the rest was describing snakes you'd never draw. So: stop sending them. That's the whole idea. The rest is just doing it cheaply.
Only send what the camera can see
Every connection carries a camera. If you're alive it's just your head, (me.x, me.y); if you're spectating it's a window drifting around the map. Around that camera I take a view radius (I call it vr in the code) and I only bother with snakes whose bounding box overlaps that square.
The bit I'm weirdly proud of is how vr is computed. It grows with you:
const vr = me ? clamp(1150 + me.len * SPACING * 0.35, 1150, 3200) : 1500;
A little snake sees 1,150 units in each direction. As you eat and grow, that window widens, right up to a hard ceiling of 3,200 units when you're a monster. I love that this is two things at once. It's a rendering necessity (a 100-meter snake literally wouldn't fit on a rookie's screen, so a giant's camera has to zoom out) but it's also a gameplay gift: get big and you're rewarded with a wider view of the battlefield you now own. The AOI radius just quietly rides along with the camera zoom. One number does both jobs.
Now, "which snakes are near this camera?" could itself be a scan of all N snakes, which would drag the quadratic right back in through the side door. So before I loop over clients, I bucket every snake once into a coarse grid: one shared pass. The cells are deliberately huge:
const COARSE = 1024; // one coarse cell = 1024 world units
// once per net tick, for every snake:
const b = snakeBBox(s);
for (let cx = c0x; cx <= c1x; cx++)
for (let cy = c0y; cy <= c1y; cy++)
coarse.get(cx*1000 + cy).push(s); // bbox may span a few cells
Then, for each client, I only peek at the handful of cells the view square actually touches, and do a cheap box-test on whatever's inside them:
for (let cx = c0x; cx <= c1x; cx++)
for (let cy = c0y; cy <= c1y; cy++) {
const cell = coarse.get(cx*1000 + cy);
if (!cell) continue;
for (const s of cell) {
if (cand.has(s)) continue; // dedupe: a snake spanning cells
cand.add(s);
const b = boxes.get(s.id);
if (b.minX > camX+vr || b.maxX < camX-vr ||
b.minY > camY+vr || b.maxY < camY-vr) continue; // box miss
seen.add(s.id);
/* ... this snake is visible ... */
}
}
What makes me happy about this is that it's the same trick the collision system already uses, just at a chunkier resolution: bucket once, ask locally. Looking up who's near you costs a few cells, never a walk over the whole arena. No client ever touches the full population, which is exactly the property the quadratic needed me to break.
bucket once, query local· coarse grid · box test
Every snake is bucketed into a coarse 1024-unit grid a single time per tick. Each client only reads the cells its view square overlaps and box-tests the candidates inside, so answering "who's near me?" costs a few cells, never a scan of the arena. Snakes outside the square are fully simulated but never serialized for you.
Because vr grows with your length (1,150 u → 3,200 u), the square you see through widens as you do, the camera zooms out with your body.
The part I actually geeked out on: the known-set delta
Okay, so AOI tells me which snakes to send you. But it doesn't say what to send about each one, and this is where I nearly re-lost all my savings. My first cut re-sent each visible snake's whole description every snapshot: its name, its color, its cosmetic skin, and every single point of its body. For a long snake that's kilobytes, 15 times a second, and here's the dumb thing: the body barely changes between two frames. I was re-describing the same worm over and over just to tell you it slid forward a little.
The fix is the thing I'm proudest of in this whole file, and it's tiny. Each connection remembers what it already knows. Literally: ws.known is just a Set of the snake ids that client currently has on screen. Every snapshot, each visible snake lands in one of three buckets depending on that Set:
seen.add(s.id);
if (ws.known.has(s.id)) upd.push(s); // known + in view → UPDATE
else { ws.known.add(s.id); addBufs.push(encodeAddCached(s)); } // new → full ADD
// afterwards, anything known but no longer seen has left the view:
for (const id of ws.known)
if (!seen.has(id)) { del.push(id); ws.known.delete(id); } // gone → DELETE
Three kinds of message come out of that, and the sizes are the entire punchline:
- ADD: in view, not yet known. Send the full snake (id, hue, skin, name, position, and the entire body). ~430 bytes for a big one. Add its id to
ws.known. Paid once, when it first enters your view. - UPDATE: in view, already known. Send just 11 bytes: id, new quantized position, heading, length, flags. The client already has the body; it moves what it has.
- DELETE: known, but no longer in view. Send just 4 bytes: the id. The client drops it and frees the memory. Remove it from
ws.known.
And once you're just cruising through a stable patch of the map, almost everything you get is that 11-byte UPDATE. The pricey ADD and the DELETE only fire at the edges of your view, the moment a snake crosses in or drops out. So your bandwidth settles into "snakes on my screen × 11 bytes, plus a trickle of adds and dels at the rim." It tracks the churn right around you, not how many people are on the server. That was the moment the fan on my laptop finally shut up.
NAIVE send every snake to every client
AOI only what is inside your camera
one camera, a stack of deltas· ws.known · add / upd / del
Left, the naive fan-out: every tick the server hands you every snake in the arena, and it does the same for every other client, so packets per tick climb with the total player count (10 clients × 10 snakes = 100, and it only gets worse). The bill tracks how many people are online, not what you can see.
Right, area of interest: a camera the size of your view radius vr keeps only the snakes inside it. The dimmed ones out in the arena are fully simulated but never serialized for you. The stream underneath is what you actually receive, written line by line as snakes cross your edges: a snake enters and fires one full ADD (~430 B) that joins ws.known, streams cheap 11-byte UPDATEs while it stays, and fires a single 4-byte DELETE when it leaves. You only ever pay for the churn right around your own camera.
The sneaky one: encode the body once, not twenty times
I thought I was done, and then I found a spike in the profiler I hadn't reasoned about. Building that full-body ADD is the single most expensive thing the broadcast does: it walks every point of the snake and writes four bytes for each. Fine when one client sees a snake for the first time. But picture a big snake plunging head-first into a crowded pileup: suddenly it's newly visible to twenty people on the very same tick. My code was cheerfully encoding that identical worm twenty times over, in a single 33 ms budget. That's a stall you can feel.
The thing that took me a beat to notice is that the encoded body is byte-for-byte identical for everyone who's seeing it this tick. So encode it once, and pass the same buffer to all of them:
// A newly-visible snake is encoded once per tick, however many clients see it.
function encodeAddCached(s) {
if (s._addTick !== tick) { s._addBuf = encodeAdd(s); s._addTick = tick; }
return s._addBuf;
}
Whoever sees the snake first this tick pays for the encode; I stash the result right on the snake as s._addBuf and stamp it with the current tick. Everybody else who adds it on that same tick just gets the cached buffer handed back, free. The per-client loop still decides who gets the add (I'm not changing what anyone receives); I'm only killing the repeated work. A whole crowd noticing the same snake at once costs one encode instead of twenty. It's a five-line change and it flattened the spike completely.
encode once, hand out many· encodeAddCached · s._addBuf
When one snake becomes visible to a crowd on the same tick, its expensive full-body encode runs a single time. The buffer is cached on the snake, tagged with the tick, and the same bytes are handed to every client that just added it. The AOI loop still chooses who receives it, the cache only kills the duplicated CPU.
Where it lands, and where I'd stop short of bragging
Stack the three pieces up and the shape of the whole problem changes. Nobody hears about everybody anymore. You hear about what you can see, mostly as 11-byte nudges:
And what it costs me to get there is almost nothing: one Set per connection, holding just the ids currently on that client's screen: a few dozen integers. No global subscription table, no per-pair bookkeeping, none of the machinery I braced myself to write. The coarse grid gets built once a tick and everyone shares it.
But I don't want to oversell it, because a few things genuinely don't get solved here, and I'd rather say them out loud:
- It does nothing for the simulation. I still move, grow, and collision-check every single snake every tick, AOI only saves me from talking about all of them. How much sim I can afford is a totally separate fight, and I wrote about that one in the architecture piece.
- "A few dozen" is a hope, not a guarantee. Dive into a 40-snake dogpile and you'll honestly receive 40 snakes. Nothing here caps what you see, it only refuses to send what you can't. The worst case is a giant brawl, which is also exactly the moment you most want every worm on screen. I decided I'm fine with that.
- Big players are expensive players. Because
vrclimbs to 3,200 u, a titan genuinely pulls a wider slice of the arena down its pipe than a rookie does. It's the size-is-power fantasy working as intended, but it does mean my chunkiest connections are the whales. - New snakes still pop in. The first time a snake enters your view it arrives as a full body in one snapshot, so tearing fast across a busy region gives you a little burst of ADDs at your leading edge.
encodeAddCachedsaves my CPU there, but it can't save the bytes, I still have to actually tell each of you about a worm you'd never met.
None of that dents the thing that matters, though. What I started with was a quadratic that quietly detonates somewhere shy of a hundred players. What I've got now is a curve you never feel: your bandwidth is just a function of the mayhem right around you, and the size of the server underneath becomes something you never have to think about. For a project that began as an excuse to learn netcode, watching that fan finally go quiet was worth every evening.
broadcast() from the growordie server, trimmed a little for the page.The growordie team