2026-06-10·Sanchez

The Root That Slipped Between Frames — Collisions and Tunneling

Collision checks split into two kinds.
Point-shaped entities like carrots and rocks are approximated as spheres, so if the 3D distance to the player is less than the sum of the radii, I count it as a hit.
Simple, but it was enough.

A hit chips away a heart; run out and it's game over

The actual values are 0.45 for the rabbit, 0.35 for a carrot, and 0.7 for a rock.
Carrots are meant to be collected, so they're generous; rocks are meant to be avoided, so they're big.
Even with the same sphere approximation, one radius decides what feels easy to pick up and what feels threatening.

Arc-shaped obstacles

The tricky one was the roots.
A root isn't a point — it's an arc-shaped obstacle stretching across the tunnel's circumference.
So it's a collision only when all three conditions hold: the depth is close, the rabbit's angle is within the root's arc range, and the jump height didn't clear the root.
Jump high enough and you pass over the root.

The clearance height is 1.1m.
A normal jump peaks around 1.69m, so simply jumping clears it comfortably.
A root is an obstacle you react to, not one you have to time precisely.

The root that slipped between frames

This is where the real bug showed up.
At high speed, the rabbit just punched straight through the roots.
The cause was the per-frame travel distance.
At a top speed of 30m/s, if the frame rate drops to 20fps, you skip 1.5m in a single frame.
But the root's depth check window was narrower than that (1.46m).
The rabbit is in front of the root one frame and behind it the next — and the moment they actually overlap slips between the two frames.
Classic tunneling.

The fix was to add this frame's travel distance (sweep) to the check window.

export function hitsRoot(playerDepth, p, rootDepth, root, sweep = 0): boolean {
  if (Math.abs(playerDepth - rootDepth) > SPAWN.ROOT_TUBE + PLAYER.RADIUS + sweep)
    return false;
  if (p.height >= SPAWN.ROOT_CLEARANCE) return false;
  return angularDist(p.angle, root.angle) < root.arc / 2 + PLAYER.RADIUS / TUNNEL.RADIUS;
}

This way, the faster you go the wider the check window grows, so you won't miss a root unless you jumped.
Because the window widens exactly as the frame rate drops, the related problem of collisions getting looser on slow devices disappears with it.

Making the hitbox and the drawing disagree on purpose

The root has two radii.
The one used for collision is 0.28, and the one drawn on screen is 0.36 — about 29% thicker.

The mismatch is deliberate.
Drawn thin at its true hit size, a root read like dirt texture on the background, and the "you need to clear this" signal was too weak.
Drawn thicker, it reads as an obstacle at a glance.
The direction matters: because the drawing is larger than the hitbox, the only surprises a player gets are "I thought that would hit me and it didn't." They never get the unfair version.

In exchange I wrote down a rule that the collision value is never touched.
The visual radius is a readability knob; the collision radius is a rule of the game.
Tie them to one constant and the difficulty quietly shifts the moment someone tunes it to "look better."

Near-misses are measured after the fact

Near-misses (+50 points) had a trick too.
I judge them by the cross-section distance after passing, not as you approach.
The moment you didn't hit but barely grazed by, I measure it right after the pass and hand out the bonus.
That one little detail rewards the thrill of dodging with points.

The threshold is farther than the sum of the radii but within 0.6m of it.
Measured on approach, the outcome isn't decided yet, so there's nothing to judge.
Only once the result is settled does "you grazed it" mean anything.

Roots work differently.
Their near-miss only counts if you cross in the 0.6m band above the 1.1m clearance, so between 1.1 and 1.7m.
Fly high and you're safe but get nothing; skim low and you're at risk but score.
It gives an obstacle you clear by simply pressing jump a second, better way to be cleared.

What pure functions bought

All of these checks are functions that take arguments and return a boolean.
They don't reference Three.js objects or the scene graph.
That's why I could just write a test that reproduces the tunneling bug: feed in a large dt and a high speed, move past a root, and assert the collision registers.
There's no need to manufacture an actual 20fps situation.

Next time, the last one: synthesizing sound effects in code.

Next: Synthesizing SFX with Web Audio →