A snake in 11 bytes

I built growordie.io mostly as an excuse to learn multiplayer netcode, and the part I fell down the deepest rabbit hole on was the wire format, the actual bytes going back and forth over the WebSocket. I want to walk you through it, because I think it's the prettiest thing in the whole project: every snake you can see on your screen costs me exactly 11 bytes to update. No JSON, no field names, no quotes. Just eleven bytes, fifteen times a second.

Quick context so the rest makes sense: the server is the boss. Your browser never decides where anything is, it just tells the server "I want to turn left," the server runs the actual simulation 30 times a second, and it streams the world back to you in tiny binary snapshots. I wrote about the timing of those two clocks and the server that feeds a thousand of them at once elsewhere. This one's the microscope view, right down to the octets on the socket.

01 · Why not JSON

My first version sent JSON, of course it did. You reach for JSON.stringify, it's one line, you can read it in the network tab, it just works. And for about a week it was fine, because I was the only player and there were three bots. Then I started imagining a busy arena, every snake in your view, resent fifteen times a second, for every person connected, and the JSON started to feel like carrying a filing cabinet up the stairs every trip.

Here's what finally sold me on ripping it out. Same update, a snake at (2600, 1450), 63 m long, mid-boost, drawn both ways:

JSON.stringify(update) {"id":1234,"x":2600,"y":1450,"len":63,"boost":true} 54 B Buffer.allocUnsafe(11) 11 B same information · ~5× smaller · and nothing to parse

the same update, two encodings· 54 B vs 11 B

The thing that bugged me is that JSON re-sends the whole schema every single time. The word "boost" is five bytes spent to tell you one bit of information. The binary version ships only the values, at spots both sides already agreed on ahead of time, so it comes out roughly 5× smaller for this little update, and way more than that once a snake has a body, because JSON would spell out every trail point as {"x":2600,"y":1450}, a couple dozen bytes a pop.

But honestly the size was never what got me: it was the garbage. Every stringify builds a throwaway string, every parse builds a throwaway object, and at 15 Hz across every snake and every player, that's a landfill the garbage collector has to keep sweeping up, mid-game, during frames I'd really rather it left alone. The binary path allocates one buffer, writes into it by offset, and the other side reads it back by offset. Nothing to tokenize, nothing to collect. That felt clean in a way I couldn't stop thinking about.

02 · The hand-packed layout

I want to be upfront: there's no clever machinery here. No protobuf, no schema library, no MessagePack. I allocate a buffer of the exact size I need and write each field at a hard-coded offset, by hand. The client reads it back the same way, it walks the bytes with a running counter I literally call o, for offset, pulling values out in the identical order I put them in. The encoder and the decoder are two little functions that have to agree, byte for byte, or the whole thing turns to mush. (More on how scary that is later.) Three message shapes carry the entire game.

UPDATE: a live snake in 11 bytes

This is the workhorse, the packet that repeats. A snake that's already on your screen just needs its new position, its length, and a couple of status flags. Watch it get written, one offset at a time, this is copied straight out of server.js, I didn't dress it up:

0123 4567 8910 const buf = Buffer.allocUnsafe(11) little-endian · low byte first D2040000 id · u32 · 4 B 280A x · u16 · 2 B AA05 y · u16 · 2 B 7602 len×10 · u16 · 2 B 01 flags · 1 B buf.writeUInt32LE(s.id, 0) buf.writeUInt16LE(u16c(s.x), 4) buf.writeUInt16LE(u16c(s.y), 6) buf.writeUInt16LE(u16c(s.len * 10), 8) buf.writeUInt8(flags, 10) 11 bytes ready → ws.send(buf) o = 0 o = 4 o = 6 o = 8 o = 10 o = 11 ✓ one visible snake = 11 bytes, every 66 ms

the UPDATE packet· server.js · broadcast()

Four bytes of id, position as two 16-bit numbers, length times ten as another (that ×10 is a cheap trick to keep one decimal place without paying for a float), and one flags byte. Eleven bytes, left to right, no separators, no keys. The client reads them back in the exact same order: getUint32, then getUint16 three times, then getUint8. That's the whole contract, and nothing enforces it but me remembering to change both sides together.

A full snapshot is a little header up front, then a run of these 11-byte rows (one for every snake the server decided is close enough to your camera to matter) with a count of newcomers and a count of the ones that just left the frame. But this row is the heartbeat. It's the thing that fires thousands of times a second, so it's the thing I obsessed over shrinking.

ADD: the full snake, once

Eleven bytes works because both sides already know the snake. But the first time a snake slides into your view, you know nothing about it: not its name, not its color, not its skin, not the shape of its body. So that first frame it gets the full introduction: a heavier ADD block, sent exactly once, and from then on it rides the cheap 11-byte lane forever. Here's everything in that introduction, drawn to scale so you can see where the bytes actually go:

Buffer.allocUnsafe(4 + 2 + 1 + 1 + name.length + 16 + 2 + 4 * n) id u32 · 4 hue u16 flags nameLen name utf-8 x f32 · 4 y f32 · 4 angle f32 · 4 len f32 · 4 nPts u16 body points n × (u16 x, u16 y) head state · f32 · exact body · u16 · quantized sent once, when the snake enters your view… after that: 11-byte updates only a 3-letter name + 2 body points ≈ 37 bytes; a long snake is bigger, but paid exactly once

the ADD packet· server.js · encodeAdd(s)

The bit I like here is the split personality. The head (where the snake is, which way it's pointing, how long it is) goes out as full 32-bit floats, because that's the snake's exact truth at the instant you meet it, and I want the smooth-motion code to start from something precise. But the body is just a trail of dots I draw a curve through. Nobody can tell if a tail segment is off by half a unit, so those points get squished down to 16 bits each and take half the room. Same shortcut the update packet pulls on position.

One thing I'm quietly proud of: this buffer gets built once per tick and shared. If ten people are all looking at the same new snake, I encode it once and hand everyone the same bytes, instead of doing the work ten times.

u32 · id u16 / f32 · position hue / length flags / body name & counts

03 · Quantization: u16 instead of f32

If there's one decision that carries this whole format, it's refusing to send floats for positions. The arena is a square a few thousand units across, so every coordinate fits comfortably in a 16-bit integer (0 to 65,535) and I round it there on the way out with one embarrassingly small helper:

const u16c = v => Math.max(0, Math.min(65535, Math.round(v)));

A float would buy me fractions of a unit of precision. And here's the thing that made me feel silly for ever sending them: you'd never see that precision, because the client is faking the motion anyway. Between two snapshots it just glides each snake from where it was toward where it's going, over that ~66 ms gap. Rounding to the nearest whole unit and then smoothly sliding through it looks identical to carrying the exact fraction, the tween paints right over the rounding. So I halve the bytes and lose nothing a human eye could catch. And I get to do it twice per update, and twice per body point.

f32  x = 2600.3742 B0B1B2B3 4 bytes · precision you can't see Math.round() → u16 u16 x = 2600 280A 2 bytes · whole-unit client interpolates between snapshots t t+66ms the tween hides the rounding

positions are 16-bit· u16c · half the bytes

Whole-unit steps are already finer than the spacing between a snake's own body dots, and the client sands off the last fraction for free while it glides between snapshots. Half the bytes, nothing lost. The only place I hang onto real floats is that ADD head state, before the smoothing has two points to slide between, I want the first one to be exact.

04 · Bit-packing: a skin and a bot flag in one byte

Once you start counting bytes you can't stop, and pretty soon you're counting bits. My favorite little example is the flags byte in the ADD packet, where I stuffed two totally unrelated things into the same eight bits. One is a yes/no: is this a bot or a real player? The other is a skin, which of 32 cosmetic looks the snake is wearing. A skin needs 5 bits to count to 32; a yes/no needs 1. That's 6 bits, and a byte gives you 8, so why send two bytes? They can just move in together:

// bit 0 = real bot; bits 3-7 = cosmetic skin index (0-31).
b.writeUInt8(((s.isBot && !s.ghost) ? 1 : 0) | ((s.skin & 31) << 3), o);
one flags byte · example: bot = true, skin = 5 bit 7654 3210 0 0 1 0 1 0 0 1 bits 3-7 = skin (0-31)  (5 ← 00101) bit 0 = bot byte = 1 | (5 ≪ 3) = 41 = 0x29 = 0b00101001 decode → bot = aflags & 1 · skin = (aflags ≫ 3) & 31

two fields, one octet· bot bit + skin≪3

I shove the skin up into the high five bits (that's what ≪ 3 does, slide it left, out of the way), park the bot flag in bit 0, and leave bits 1 and 2 empty for whenever future-me needs another flag. It costs nothing, and the skin only ever rides in this one intro packet, never in the hot updates. Unpacking it on the client is a single line: bot: !!(aflags & 1), skin: (aflags >> 3) & 31. The update packet's own flags byte does the same thing with three yes/no bits: boosting, kill-buff, and a "hole" in the trail, all crammed into one byte. Once you've done it once you see room for it everywhere.

05 · The 3-byte input: intent, not angle

The traffic going the other way (you, steering) is even tinier, and it's got the best story attached to it. The whole input is three bytes: a tag that says "this is an input," a single signed number for which way you want to turn (−1, 0, or +1), and one bit for whether you're boosting. Notice what's not in there: your heading. The client never tells the server which direction the snake is facing. It only ever says "I'd like to go left," and lets the server figure out the rest.

// client.js, sent on an interval, ~15×/s
inputDv.setUint8(0, 2);                          // [0] type = 2
inputDv.setInt8(1, turn);                        // [1] turn intent: -1 / 0 / +1
inputDv.setUint8(2, (spaceDown || keys.up) ? 1 : 0); // [2] boost
ws.send(inputBuf);                               // 3 bytes

I didn't start here. My first steering sent an absolute angle, "point me at 2.7 radians," as a nice precise 4-byte float. It seemed obviously correct. And it played awful: hold a turn and your snake would shimmy, this jittery little zigzag instead of a clean arc. I stared at it for an embarrassingly long time thinking my math was wrong.

It wasn't the math, it was the whole idea. Big snakes turn slowly: the server caps how fast a snake can rotate based on its size. But the client, running its own smooth cursor, would compute a desired angle that raced way ahead of the heading the server was actually allowed to reach. And angles wrap around: cross π and you flip from "+3.1" to "−3.1," the long way round the circle. So the server would forever be chasing a target that had already teleported to the far side, overcorrecting one way, then the other, then back, the zigzag. Sending intent instead just deletes the whole problem. There's no absolute angle to race ahead or wrap, so there's nothing to overshoot. The server nudges the heading a capped step each tick and it comes out smooth. (If that "sampling a curve as points and getting bitten by the gaps between them" flavor sounds familiar, it's the exact cousin of a collision bug I chased for way too long, turns out I keep learning the same lesson.)

absolute angle · f32 client target outruns the capped turn rate wanted angle wraps past π zigzag, oversteer turn intent · i8 server integrates +1 at the snake's own rate +1 steady intent smooth, can't overshoot [ type 2 ][ turn -1/0/+1 ][ boost ] = 3 bytes up, ~45 B/s

turn intent beats absolute angle· 3 B up · [type=2][i8 turn][boost]

The server owns your heading, full stop. Every tick it nudges the angle by turnInput × maxTurn, and maxTurn shrinks as the snake gets bigger, that's why the giants feel like barges. Because the client can only ever ask for "left," "straight," or "right," a laggy or cheating client can't teleport its facing or oversteer; there's just no angle to lie about. Three bytes, about fifteen times a second, is roughly 45 bytes per second going up. The socket barely knows you're there.

06 · The numbers

So where does all this land? Roughly here:

MessageSizeRateNotes
Input (up)3 B~15 Hztype + turn intent + boost → ~45 B/s
Update (per snake)11 B15 Hzid + x + y + len×10 + flags
Add (per newcomer)~26 B + name + 4×ptsonce on entryfull head state + quantized body
Snapshot header~15 B15 Hzonline, world, your rank, heat
Downstream / player~6–10 KB/s, tracking the crowd density around your camera

I'll be honest about the tradeoff, because it's real. This format has no safety rails. It's a Buffer, a stack of write calls, a matching stack of read calls, and an offset I have to keep straight in my own head. No code generation, no versioning, nothing. If I add a field to the encoder and forget to add it to the decoder in the same spot, everything after that point reads pure garbage, and the failure is silent and baffling, a snake suddenly at coordinate four billion. I've done it. It's not fun. I put up with that fragility because what I get back is a format with genuinely no fat on it: the CPU never turns a number into text, the collector never sees a per-snake object, and every byte on the wire is actually carrying something.

IN SYNC encoder offsets = decoder offsets ENCODER writes D2 04 00 00 28 0A AA 05 76 02 01 id · u32 x y len flags DECODER reads (same offset map) id 1234 ✓ x 2600 ✓ y 1450 ✓ len 63 ✓ 01 every field lands exactly where it was written one offset counter, kept straight in both heads DESYNC ▸ +1 phantom byte encoder wrote a field the decoder never reads ENCODER writes (one byte longer) D2 04 00 00 FF phantom FF next snake 28 0A AA 05 76 02 01 every offset past here slides +1 DECODER reads (still the OLD map) id 1234 ✓ x 10495 ✗ y 43530 ✗ len 3021 ✗ ? next readUInt32LE reads the shifted bytes 76 02 01 FF 4,278,256,246 a snake at coordinate four billion, off the arena no crash, no error. just garbage, silent and baffling.

one forgotten byte, and every offset after it slides· the failure mode with no safety net

This is the whole reason the hand-packed format is scary. When the encoder and decoder agree byte for byte, the top strip and the bottom strip line up and every field decodes clean. But add one field to the encoder, forget it in the decoder, and the two o counters drift apart by a single byte. From the gap onward the decoder still reads at the old offsets, so each read straddles two real fields: x comes back 10,495, y comes back 43,530, all plausible-looking nonsense.

The nasty part is that nothing throws. The next 32-bit read simply lands on the wrong four bytes, 76 02 01 FF, and deserializes to 4,278,256,246, so a snake blinks into existence four billion units past the edge of a map that is only a few thousand across. That is the silent, baffling failure I keep talking about: no exception, no red log line, just a snake teleported to nowhere because a counter was off by one.

That's kind of the whole personality of this project, if I'm honest: do the small, exact, slightly tedious thing by hand and let it scale quietly while you sleep. If you want to see how these packets get timed against the 30 Hz simulation, I drew that out in How growordie works. And the server that manages to feed a thousand of these little streams off a single CPU core is its own rabbit hole here. Thanks for reading this far, it genuinely started as an excuse to learn netcode, and somewhere along the way it turned into the thing I'm proudest of.

Every diagram on this page is inline SVG animated with SMIL and CSS keyframes: no JavaScript, no canvas, no external assets. Fitting, for an article about spending nothing you don't have to.

The growordie team