How growordie.io works

growordie.io is a massively-multiplayer snake game running on vanilla Node.js, one shared arena, every collision lethal, ~1,000 players per CPU core. The interesting parts aren't the rules, they're the engine. Below are the five mechanisms that make it hold together, each as an animated diagram. No slides, no walls of text, watch the thing move.

Everything here is server-authoritative: the client sends a heading, the server simulates at a fixed 30 Hz and streams back tiny binary snapshots. For the why-and-numbers of the whole stack, see 1,000 players per CPU core. This page is the how, in motion.

First, what "netcode" even means

If the word is new to you, here is the whole idea in a minute. Netcode is not one thing. It is the job of keeping a thousand screens agreeing on one shared world, quickly, over a network that is slow and drops things. It splits into a handful of separate problems, and the five diagrams on this page are each one of them.

Authority. The server runs the real game: positions, collisions, who died. Your browser only sends a heading and draws what it is told back. A client can never lie about its size or its kills, which is what keeps the arena honest.

Tick and snapshot rate. The server steps the simulation 30 times a second and streams the world back 15 times a second. The client fills the gaps between snapshots so it still feels like 60fps. That timing is diagram 03.

A compact wire format. Our real ceiling is bandwidth, not CPU, so a snake update is packed into 11 bytes instead of verbose JSON. Every byte you do not send is a player you can afford to add. The binary protocol deep dive counts them one by one.

Sending only what you can see. Nobody receives the whole world. Each player is sent just the snakes near their camera, which is what lets one arena hold a crowd without drowning everyone in data. That is diagram 02, area of interest.

Sending only what changed. Between two updates we send deltas, add and move and remove, not the full state every time. A snake that has not moved costs nothing.

Surviving a bad connection. Dropped packets, a phone that sleeps, a deploy in the middle of your run. The server snapshots the live arena and hands you back your exact snake when you reconnect, so an update no longer wipes your game. That story is the arena has to outlive the process.

Put together, that is netcode: who is in charge, how often you talk, how small you can make each message, sending only what a player can see, sending only what changed, and surviving a bad line. Everything below is one of those pieces, in motion.

01 · The incremental spatial hash

world grid · one bucket = one cell snake row · cy 7 1112 1314 1516 1718 grid ops · per tick O(1)/tick tick + insert head cell − remove tail cell exactly 2 ops, every tick cost ignores snake length head enters → insert O(1) tail leaves → remove O(1)

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

Collision needs every head tested against every body segment, every tick. We bucket segments into a grid, but we never rebuild it. A snake only grows one segment at its head and drops one at its tail, so each tick is one insert and one remove: constant time, no matter if the snake is 2 m or 100 m.

A 100-meter titan costs the grid exactly as much upkeep as a rookie. The structure scales with player count, not with total mass in the arena.

Read the full deep-dive: the incremental spatial hash →

02 · Area-of-interest (AOI)

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. Instead 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.

Read the full deep-dive: area-of-interest & the N² problem →

03 · Netcode timing

SERVER · sim 30 Hz 1 second · gold = snapshot tick (15 Hz) CLIENT · interpolates & renders 60 fps 27 B 34 B 22 B 29 B 31 B 3 B 3 B 3 B 22 ms tick ms snapshots 15 Hz tick > 22 ms → degrade to 10 Hz

30 Hz sim · 15 Hz binary snapshots· 3 B up / ~20–40 B per snake down

Two separate rates run in parallel, the whole idea is that they're separate:

Simulation rate (30 Hz), how often the server recomputes the world: moves every snake a step, lays body points, checks collisions. This is the game's truth.
Observation rate (15 Hz), how often the server sends that state to you: the snapshots your screen draws. Between two snapshots the client interpolates, so it looks smooth even though it only hears from the server 15×/s.

In the diagram: the tick marks are the 30 Hz sim, the taller gold ones are the 15 Hz snapshots (every 2nd tick). The little boxes are packets, tiny 3 byte inputs going up, ~20–40 byte snapshots going down. The red bar on the right is how long the current tick took.

When that bar blows past 22 ms, the server slows the observation rate (15→10 Hz), never the simulation: the physics stays exact and your view is only ~50 ms staler, which the interpolation hides. The full reasoning, and why a slightly stale frame beats an unfair death, lives in the architecture deep-dive.

Read the full deep-dive: a snake in 11 bytes →

04 · Swept segment-vs-segment collision

BEFORE · point-vs-point body sampled every 10 u, small radii gap passes through, bug AFTER · swept segment × segment body is continuous; head path is a segment segSegDist2 blocked, KILL

killing the tunnel bug· lib/geom.js · segSegDist2

The body used to be tested as a row of points sampled every 10 u. A thin, fast head could travel far enough in one 33 ms tick to thread between two of them and come out the other side alive, a classic tunneling bug that ate real runs.

The fix treats the body as continuous segments and the head's motion this tick as its own swept segment, then measures the closest distance between the two (Ericson, Real-Time Collision Detection §5.1.9). If it's under the kill radius, it's a hit, no gap to slip through, and the lateral kill distance stays exactly the same.

Read the full deep-dive: how I fixed collision tunneling →

05 · Neon without shadowBlur

recipe · 4 strokes, same path w 22 · a .08, halo w 14 · a .12 w 8 · a .20 w 3 · a 1, core stacking translucent strokes… glow, 0.1 ms/frame, zero GC shadowBlur ✕ slow strokes ✓ cheap

the no-shadowBlur glow· 0.1 ms/frame · zero GC

Canvas 2D's shadowBlur is the classic perf killer, a per-pixel gaussian that tanks the frame budget. growordie never calls it. The neon halo is faked by stroking the same path several times: a wide, very-low-alpha pass for the outer glow, down to a thin bright core.

Overlapping translucent strokes sum toward white in the middle and fade at the edges, exactly the falloff a blur would give, for a handful of cheap stroke calls and no allocation on the hot path.

Read the full deep-dive: neon without shadowBlur →

Every diagram on this page is inline SVG animated with SMIL and CSS keyframes, no JavaScript, no canvas, no external assets. It's the same instinct as the engine itself: do the cheap thing that looks expensive.

The growordie team