Step 12 — The overlap trap (when the sweep has no answer)
You found this one yourself, in the capstone: start a box inside another
collider, press a key, and the position turns into NaN. That's not a typo in
your code from steps 01–10 — the kernel is correct and still does this. It's a
blind spot in the whole approach, and every real engine has to patch it.
This is a genuine hole in the finished kernel, shared by the capstone's
game.js. It is a third thing, separate from the two bugs you're hunting inengine/system/physics.ts— no spoilers here.
Why the sweep can't see it
Everything since step 05 answers one question: "when, during this frame, will I enter the box?" The whole ladder quietly assumes the answer lies in the future — that you start the frame outside.
Start inside, and "when will I enter?" has no sane answer. The math doesn't
refuse — it cheerfully reports that you entered in the past. Remember step 05:
what sign does entry have when p is already between min and max? Every
function above sweepInterval trusts that number without checking it.
The autopsy (do this before reading on)
The NaN isn't born where you see it — it's the end of a chain, and each link
is one line you wrote. Reproduce it in a scratch file and trace it:
import { moveAndSlide, sweptAABB } from "./given.ts";
const box = { x: 10, y: 10, w: 2, h: 2 };
const wall = { x: 0, y: 0, w: 32, h: 32 };
console.log(sweptAABB(box, { x: 5, y: 0 }, wall));
console.log(moveAndSlide(box, { x: 5, y: 0 }, [wall]));
Work through these in order, predicting each answer before checking (add
console.logs inside your step-10 loop — it's your code, instrument it):
- What
timedoessweptAABBreport? Now flip the velocity so the box moves away from the wall — why do you still get a hit? (This is why you can't even walk out of a wall you're stuck in.) - Follow that
timeinto theelsebranch ofmoveAndSlide. Three lines use it. Which line is saved by theMath.max(0, …)? What happens tovelwhen youslideagainst that normal? And what doestimeLeft = timeLeft * (1 - time)do whentimeis negative — shrink, or grow? - Next iteration:
velis now(0, 0)but the loop keeps going. What doessweepIntervalreturn forv = 0while inside the interval (look at the first branch — you wrote it in step 05)? So what isentrynow, and what doestimeLeftbecome after multiplying by(1 - entry)? - Last link. In JavaScript, what is
0 * Infinity? That'sscale(vel, timeLeft)on iteration three. And once oneNaNexists, every comparison against it isfalse— so which branch of the loop does the poisoned move fall into, and what doespos = add(pos, move)do then?
Four links: overlap → a hit in the past → dead velocity + growing time debt →
0 × ∞. When you can retell that chain from memory, you own it.
The fix: measure the overlap, push out
The sweep is continuous detection — it prevents overlap but can't recover from it. So real engines pair it with a discrete partner: if you're already inside, don't ask "when do I enter?" — ask "how deep am I, and what's the shortest way out?", then teleport that far and only then sweep.
That shortest-way-out is the penetration vector (the famous minimum
translation vector). For two overlapping AABBs there are exactly four escapes —
push a left, right, up, or down until the boxes just separate:
outLeft = (a.x + a.w) - b.x // slide a left this far -> separated
outRight = (b.x + b.w) - a.x // slide a right this far -> separated
outUp = (a.y + a.h) - b.y
outDown = (b.y + b.h) - a.y
Two things to convince yourself of (don't skip — the tests check both):
- The boxes strictly overlap iff all four distances are positive. (What is
outLeftwhenasits fully to the right ofb? When they merely touch?) - The answer is the smallest of the four, as a vector, with the sign that
moves
aaway. Smallest, because depenetration is a teleport the player can see — one pixel of pop beats being flung across the room. Note this handlesafully swallowed bybtoo, where "the overlap of the intervals" would lie to you — one of the tests is exactly that case.
Task
Two functions in overlap.ts:
penetrationVector(a, b)— the smallest single-axis push that separates the boxes, ornullif they don't strictly overlap.safeMoveAndSlide(box, v, walls)— check every wall; if the box is inside one, apply the push plus anEPSILONof slack in the push direction (same idea as the backoff in the loop: land flush on the wall and next frame's sweep starts half-trapped again). Then run the givenmoveAndSlidefrom the safe position.
bun test workshop/steps/12-overlap
Make the capstone unbreakable (optional)
Port both functions into 11-capstone/game.js, swap the moveAndSlide call for
safeMoveAndSlide, and set the player's spawn inside the pillar. It should pop
out and play on like nothing happened. Then the question you actually care
about: does your real engine survive the same experiment?