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.

The naive broadcast is O(N²): double the players, quadruple the traffic. At 1,000 players it's 15M updates/s, a non-starter on one core.

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.

arena · every snake the server simulates 1 cell = 1024 u coarse cells queried this tick visible visible vr your camera · view radius vr in view → sent out of view → simulated, never sent

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:

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

packets / tickN clients × N snakes16N = 425N = 536N = 649N = 764N = 881N = 9100N = 10youevery client is sent every snake

AOI only what is inside your camera

you receive / ticksnakes in camera × 11 B≈ 33 B3 in viewyouyour camera · view radius vr
your delta streamws.known drives add / upd / del, newest on topopidon wire+ ADD#5a1c~430 B  UPD#5a1c11 B+ ADD#a3f9~430 B  UPD#5a1c11 B  UPD#a3f911 B− DEL#5a1c4 B

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.

newly visible this tick full body · 4 B / point encodeAdd(s) s._addBuf · s._addTick = tick encoded ONCE client A client B client C buf buf buf naive: encode ×3 (same bytes) cached: encode ×1 → reuse buffer

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:

Broadcast goes from O(N²) to O(N × visible), and visible is bounded (a few dozen) by your view radius, not by N. At 1,000 players your connection still carries roughly one screenful at 15 Hz.

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:

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.

Every diagram here is inline SVG animated with SMIL and CSS: no JavaScript, no canvas, no external assets. The code snippets are the real broadcast() from the growordie server, trimmed a little for the page.

The growordie team