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):
- Hero
{x: 9, y: 1, w: 2, h: 2}, wallsA = {x: 10, y: 0, w: 4, h: 4}andB = {x: 4, y: 0, w: 4.5, h: 1.4}. The hero overlaps onlyA. 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. - 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}. ApplypenetrationVectorpushes in a loop and logxeach time. Does it converge? What number doesxbounce 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:
resolveOverlaps(box, walls)— the negotiation: passes until clean or capped, returning{x, y, settled}.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.