Files
2026-09-04 13:57:52 +02:00

4.9 KiB

Step 13 — The crush (when there is no way out)

You found this one yourself too, chasing the capstone's ledge: the hero gets pushed out of the moving ledge, lands inside the pillar, and the NaN you buried in step 12 climbs right back out of its grave. Your autopsy chain from last time is unchanged — the only new thing is how a box you just freed ends up inside a wall again in the very same frame.

Still a fourth thing, separate from the two bugs you're hunting in engine/system/physics.ts — no spoilers there.

Why one pass isn't enough

Step 12's safeMoveAndSlide walks the walls once, in array order, fixing each overlap it meets. For one wall that's airtight. But a depenetration push is a teleport — and a teleport can land you inside a wall the loop already checked and cleared, or one it hasn't reached yet (in which case it works, by luck of the ordering). A resolver whose correctness depends on the order of the wall array isn't a resolver — it's a coin flip.

Two experiments in a scratch file, predicting each outcome before running (use your step-12 code as-is):

  1. Hero {x: 9, y: 1, w: 2, h: 2}, walls A = {x: 10, y: 0, w: 4, h: 4} and B = {x: 4, y: 0, w: 4.5, h: 1.4}. The hero overlaps only A. Run one pass with the array [A, B], then with [B, A]. Where does the hero end up in each case, and is it free? Explain the difference before moving on.
  2. Walls {x: 10, y: -5, w: 4, h: 10} and {x: 6.5, y: -5, w: 2, h: 10} — a gap 1.5 wide. Hero (2 wide) at {x: 9, y: 0}. Apply penetrationVector pushes in a loop and log x each time. Does it converge? What number does x bounce between, and why will it never stop?

The negotiation, and when it honestly fails

The fix for experiment 1 is patience: don't do one pass — repeat whole passes until a full pass finds nothing to fix. That clean pass is your proof of freedom. Each pass is cheap, and in sane geometry it settles in one or two.

But experiment 2 shows the negotiation can be unwinnable: when the gap is narrower than the box, no overlap-free position exists. No amount of math fixes that, because it isn't a math problem — it's a game-design question, and every game answers it differently. Mario between a Thwomp and the floor: crushed = death. Some engines let the wall shove you through its partner. Zelda-flavored games mostly refuse the situation: solid wins, the hero holds still until the gap opens. We take that one — it's the smallest honest answer: cap the passes, and if the cap fires, report it (settled: false) instead of pretending. You already believe in caps; your step-10 loop carries one for exactly the same reason.

Last session you proposed armoring sweepInterval against the Infinity directly. You can — see the optional section — but notice what that answer skips over: even with the NaN gone, what should a crushed hero do? The kernel can't know; it only measures. Deciding is the resolver's job. Keeping detection and policy separate is the actual lesson of this step.

Task

Two functions in crush.ts:

  1. resolveOverlaps(box, walls) — the negotiation: passes until clean or capped, returning {x, y, settled}.
  2. safeMoveAndSlide(box, v, walls) — step 12's version rebuilt on top of it. Settled → sweep as usual. Crushed → don't feed the sweep an overlapping box (you know its opinion of those); the box stays where the resolver left it and waits.

One warning on the EPSILON slack: apply it only along the axis you actually pushed. Before you port your step-12 slack code verbatim, play computer with pv = { x: -3, y: 0 } and watch what your two lines do to y. (That was my "bonus" question last session — it's still open, and one of the tests refuses to look away.)

bun test workshop/steps/13-crush

Optional 1 — the airbag

Defense in depth: even if some future caller hands moveAndSlide an overlapping box directly, it should return finite numbers — wrong-ish, maybe, but finite. You traced in step 12 exactly which value poisons the well. The loop already clamps it once (Math.max(0, …)) — find the other line that trusts nearest.time to be non-negative. The fix is almost nothing. Then write the test step 12 should have had: moveAndSlide (not safe…) with an overlapping start returns finite coordinates.

Optional 2 — the capstone payoff

Port resolveOverlaps into 11-capstone/game.js and rebuild its safeMoveAndSlide on it. Now let the ledge squeeze the hero against the pillar, and against the border. Watch closely: instead of vanishing, the hero should squirt around the ledge like a watermelon seed pinched between two fingers.

Then earn the effect: the ledge is 16 tall and the hero is 12. As the ledge digs deeper, which of penetrationVector's four escapes wins, and at what depth does the winner change? That flip is the watermelon seed.