How I fixed collision tunneling with swept segments in a 1000-player browser snake game

For a while, people kept driving straight through each other and living. That's the bug that quietly broke my brain for a couple of weeks. growordie.io is my massively-multiplayer snake game (one shared arena, every contact lethal, ~1,000 players per CPU core on vanilla Node.js) and its whole promise is that touching another snake kills you. So watching a thin head sail clean through somebody's body and come out the far side alive felt like the floor dropping out. The bug has a tidy name (tunneling) and it turned out to be two different things wearing the same costume, both closed by one satisfying little piece of geometry.

The rule is one sentence: if your head touches any body, you die. And the server is the boss here: it simulates every snake at a fixed 30 Hz and is the only thing that gets to decide who died. So when someone tells me "I went through him and lived," I can't wave it off as lag or a client mispredicting. It means my collision check looked straight at a real overlap and said no hit. In a game whose whole pitch is that touching means dying, that's about the worst thing that can be quietly wrong.

The setup: how a body is stored

Each snake's body is a trail of sample points. Every tick, if the head has moved at least SPACING = 10 world units since the last sample, we drop a new point at the head and trim one from the tail. Those points go into an incremental spatial hash (a grid of buckets) so that collision only ever tests a head against the handful of points in nearby cells, not against all ~40,000 body points in the arena. (That structure is its own story; there's an animated walk-through of the whole engine if you want it.)

A snake's collision radius grows with its length: r = clamp(len/7, 2, 80). A newborn snake is as skinny as they come, radius 2. Two snakes collide when the gap between a head and a body point drops under the kill threshold:

10 u
SPACING between body samples
2 u
radius of the thinnest snake
3.8 u
kill threshold: r + r·0.9

That third number is where it all goes wrong. rr = r1 + r2·0.9, for two thin snakes that's 2 + 2·0.9 = 3.8. Now put it next to the spacing: samples sit 10 u apart, so the midpoint between two of them is 5 u from either one. And 3.8 < 5. Which means there's a little corridor, dead centre between every pair of body samples, where no point is within kill range. Aim a thin head straight down that corridor and the collision check feels nothing at all. That's exactly what had people phasing through each other.

That's failure mode one. There's a second one hiding right behind it, and it has nothing to do with spacing, which is exactly why it took me so long to spot.

The bug: two kinds of tunneling

The collision I'd written is the textbook cheap kind: discrete. It tests snapshots: where is everything right now, at the end of this tick? Fast, simple, fine for months. It also tunnels in two completely separate ways, and I was somehow hitting both.

1 · Spatial tunneling: threading the gap

Remember, the body is a row of points, not a wall. If your kill radius is smaller than half the gap between those points, a thin enough attacker can cut perpendicular to the body, right down the corridor between two samples, and never get within 3.8 u of either. The nearest either point ever comes is 5 u. No hit. On screen the body looks perfectly solid (the renderer paints a smooth ribbon over the points) but as far as collision is concerned it's a picket fence, and the head is strolling between the pickets.

2 · Temporal tunneling: jumping the fence

Now add speed. A snake on boost hits BOOST_SPEED = 215 u/s. At 30 Hz that's ~7.2 u per tick. And my check only ever looked at where the head ended up, at the head's final position each tick. So picture a thin body lying across your path: on tick t your head is 4 u short of it, on tick t+1 it's 3 u past it. At no instant I actually sampled was the head within 3.8 u of the body, because it teleported over it in the 33 ms gap between two ticks. The collision genuinely happened; I just never looked at the moment it did.

And these two stack. A thin snake on boost gets both at the same time: it can thread the gap and skip over in a single move. Here's the same head failing both ways at once:

SPATIAL · threading the gap samples 10 u apart · kill radius 3.8 u · corridor at the midpoint 5.0 u > 3.8 u no point within 3.8 u → passes through TEMPORAL · jumping the fence boost 215 u/s = ~7.2 u/tick · only the final position is tested tick t tick t+1 body was between two ticks → never sampled → skipped

discrete collision, two ways to miss· before

Top: the body is a row of points, not a wall. With kill radius 3.8 u and samples 10 u apart, the midpoint corridor is 5 u from either point, the head slips down it untouched. Bottom: testing only the head's final position each tick lets a boosted head (~7.2 u/tick) straddle a thin body: 4 u short on tick t, 3 u past on tick t+1, and the actual crossing happened in the 33 ms nobody looked at.

The fixes that don't work

Before I landed on the real fix, I burned time on the obvious wrong ones. They're worth walking through, because every one of them is genuinely tempting in the moment, and each fails in its own instructive way.

Just make the kill radius bigger

My first instinct: if 3.8 < 5 is the problem, just crank rr up past rr > 5 and the corridor closes. It "works" for spatial tunneling, and it's a trap. A bigger kill radius means you start dying while gliding next to a body without ever touching it, the exact opposite complaint, and honestly a worse thing to play against. In a game that's all about threading gaps at speed, "I died and he was clearly a body-width away from me" is way more enraging than the occasional tunnel. I'd be swapping a bug that flatters the killer for a bug that punishes clean play. And it does nothing for temporal tunneling anyway: make the radius big enough and a fast enough head still leaps right over it. Wrong axis.

Sample the body more densely

Okay, tighter fence: halve SPACING and the corridor halves with it, the pickets get closer together. But every extra sample is one more object in the spatial hash, double the samples and you double the grid memory and double the per-tick insert/remove/scan cost, for every snake in the arena, forever, all to patch over one bit of geometry. And it still doesn't lay a finger on temporal tunneling, a fast head hops a dense fence exactly as easily as a sparse one. So I'd be paying a permanent, arena-wide tax and only fixing half the bug. Wrong lever.

Full continuous collision detection, physics-engine style

Then the "grown-up" answer tempted me: conservative advancement, speculative contacts, the real continuous-collision machinery a proper rigid-body engine ships. It would absolutely fix this. It's also wildly out of proportion. I don't have rigid bodies, restitution, or contact manifolds. I've got circles moving in straight lines for 33 ms and one boolean question: did the head touch a body? Dragging in all that apparatus for a single yes/no distance test is a pile of complexity I'd have to feed and maintain forever, for no gain. Right idea, wrong weight class.

The honest read on all three: the first two each treat the symptom of one mode, and the third is the right principle hauled in on far too big a truck. The fix I actually wanted was the smallest possible thing that hits the root cause both modes share.

The fix: swept segment vs segment

Here's the moment it clicked. Both failure modes come from the exact same modeling mistake: I was treating things that are continuous as if they were points. The body isn't a scatter of samples, it's a continuous ribbon. And the head this tick isn't at a position, it traces a path, from where it started to where it ended up. Model both of those as line segments and both tunnels just evaporate, because a segment has no gap to thread and no in-between moment to skip.

So each body point gets a link to the next point along the trail (.next), which turns the body into a polyline of segments. The head's motion this tick becomes its own swept segment, from where it was to where it is: (hx0, hy0) → (x, y). And collision is now just the shortest distance between those two segments, checked against the kill radius:

// lib/geom.js: squared distance between segments P0P1 and Q0Q1.
// The head's swept path this tick vs a body segment. Sampling the body
// as points (every SPACING) with small radii let a thin fast head thread
// BETWEEN two consecutive points; segment-vs-segment closes that tunnel
// without inflating the lateral kill distance. (Ericson, RTCD §5.1.9.)
function segSegDist2(p0x, p0y, p1x, p1y, q0x, q0y, q1x, q1y) {
  const clamp01 = v => (v < 0 ? 0 : v > 1 ? 1 : v);
  const d1x = p1x - p0x, d1y = p1y - p0y;   // direction of segment 1
  const d2x = q1x - q0x, d2y = q1y - q0y;   // direction of segment 2
  const rx = p0x - q0x, ry = p0y - q0y;
  const a = d1x * d1x + d1y * d1y;          // squared length of seg 1
  const e = d2x * d2x + d2y * d2y;          // squared length of seg 2
  const f = d2x * rx + d2y * ry;
  const EPS = 1e-9;
  let sc, tc;
  if (a <= EPS && e <= EPS) {               // both degenerate to points
    return rx * rx + ry * ry;
  }
  if (a <= EPS) {                           // seg 1 is a point
    sc = 0; tc = clamp01(f / e);
  } else {
    const c = d1x * rx + d1y * ry;
    if (e <= EPS) {                         // seg 2 is a point
      tc = 0; sc = clamp01(-c / a);
    } else {
      const b = d1x * d2x + d1y * d2y;
      const denom = a * e - b * b;
      sc = denom > EPS ? clamp01((b * f - c * e) / denom) : 0;
      tc = (b * sc + f) / e;
      if (tc < 0) { tc = 0; sc = clamp01(-c / a); }
      else if (tc > 1) { tc = 1; sc = clamp01((b - c) / a); }
    }
  }
  const cx = (p0x + d1x * sc) - (q0x + d2x * tc);
  const cy = (p0y + d1y * sc) - (q0y + d2y * tc);
  return cx * cx + cy * cy;
}

It hands back the squared distance so the hot path never has to call Math.sqrt, I compare against rr*rr instead. And the call site in the collision loop is a straight drop-in for the old point test:

const nx = pt.next;
let hit;
if (nx && !nx.hole) {
  // head's swept path this tick  vs  body segment pt -> pt.next
  hit = segSegDist2(s.hx0, s.hy0, s.x, s.y, pt.x, pt.y, nx.x, nx.y) < rr * rr;
} else {
  // no successor point (very tail): fall back to point-vs-point
  const dx = pt.x - s.x, dy = pt.y - s.y;
  hit = dx * dx + dy * dy < rr * rr;
}

Why this closes both tunnels at once, and does it without bringing back the "I died next to a body" regression:

Here's that same attacker again, this time running into segments instead of points:

AFTER · swept segment × segment body = continuous polyline · head = its path this tick · dist² vs rr² pt → pt.next segSegDist2 ↓ path meets segment · dist < 3.8 u → blocked, KILL 4 u to the side → still 4 u → still lives

both tunnels closed, lateral kill unchanged· after · lib/geom.js

The body is one continuous segment now, so there's no corridor to thread (kills spatial tunneling). The head is tested along its entire path this tick, not just its endpoint, so a boosted head can't straddle a thin body (kills temporal tunneling). And because we only changed what the distance is measured between (never rr itself), a head passing 4 u to the side still measures 4 u and still lives.

The cost

Segment-vs-segment is heavier than point-vs-point, call it ~20 floating-point ops against ~4 for a squared-distance-between-points, plus a couple of branches for the degenerate cases. On paper that's 5× the work, and I braced for it to show up. It doesn't, for one reason: the check only ever runs on candidates already near the head. The spatial hash has done its job long before segSegDist2 is ever called, I only scan the body points in the grid cells right around the head, usually a handful per tick, never the ~40,000 in the arena. Five times a tiny number is still a tiny number.

~4 → ~20
flops per candidate check
0.8 → 0.8 ms
mean tick time (unchanged)
~1,000
players per core, held

Measured across a full arena, mean tick time didn't budge off ~0.8 ms, the extra arithmetic just vanishes under everything else a tick already does. Nothing gets allocated on the hot path either: segSegDist2 is pure arithmetic over primitives, so there's nothing for the garbage collector to chase. My 1,000-players-per-core budget came through untouched. (The exact tick figure wobbles run to run, so read 0.8 ms as "I couldn't tell before from after," not a benchmarked constant.)

The lesson

Tunneling is one of the oldest traps in collision detection, and the thing that finally clicked for me is that it's really two traps wearing one trench coat: a spatial one (your obstacle has gaps because you sampled it) and a temporal one (your mover has gaps because you sampled it, in time). Widen a radius and you fix one while wrecking the feel. Sample denser and you fix half of one while paying forever. The move that actually pays off is to quit pretending continuous things are points: model the obstacle as a segment and the mover as its swept segment, and just ask for the honest distance between them. Both gaps close at once, for about sixteen extra flops on a set the broadphase had already shrunk to nothing.

The whole change was one small pure function and a two-line swap at the call site. That's almost always the shape of the right fix for me, not more machinery bolted on, but a truer model of the thing I was already computing. Honestly one of the more satisfying little rabbit holes I've fallen down building this game.

Every diagram here is inline SVG animated with SMIL and CSS, no JavaScript, no canvas, no external assets. Same instinct as the engine: do the cheap thing that looks expensive.

The growordie team