Files
nage/workshop/steps/12-overlap/README.md
T
2026-09-04 13:57:52 +02:00

5.2 KiB
Raw Blame History

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 in engine/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):

  1. What time does sweptAABB report? 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.)
  2. Follow that time into the else branch of moveAndSlide. Three lines use it. Which line is saved by the Math.max(0, …)? What happens to vel when you slide against that normal? And what does timeLeft = timeLeft * (1 - time) do when time is negative — shrink, or grow?
  3. Next iteration: vel is now (0, 0) but the loop keeps going. What does sweepInterval return for v = 0 while inside the interval (look at the first branch — you wrote it in step 05)? So what is entry now, and what does timeLeft become after multiplying by (1 - entry)?
  4. Last link. In JavaScript, what is 0 * Infinity? That's scale(vel, timeLeft) on iteration three. And once one NaN exists, every comparison against it is false — so which branch of the loop does the poisoned move fall into, and what does pos = 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 outLeft when a sits fully to the right of b? When they merely touch?)
  • The answer is the smallest of the four, as a vector, with the sign that moves a away. Smallest, because depenetration is a teleport the player can see — one pixel of pop beats being flung across the room. Note this handles a fully swallowed by b too, where "the overlap of the intervals" would lie to you — one of the tests is exactly that case.

Task

Two functions in overlap.ts:

  1. penetrationVector(a, b) — the smallest single-axis push that separates the boxes, or null if they don't strictly overlap.
  2. safeMoveAndSlide(box, v, walls) — check every wall; if the box is inside one, apply the push plus an EPSILON of 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 given moveAndSlide from 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?