Files
nage/workshop/steps/10-move-and-slide
..
2026-07-11 17:48:28 +02:00
2026-07-11 17:48:28 +02:00
2026-07-11 17:48:28 +02:00

Step 10 — moveAndSlide (the whole engine, in one loop)

This is it. Every function you've written since step 01 gets tied together here into the exact loop your real nage engine runs. It's the hardest step, so I'll give you the skeleton — you write the code.

The idea

A single collision doesn't end the frame (step 08's "leftover that matters"). You hit a wall at t = 0.3, slide, and 70% of the frame is still owed — during which you might hit another wall, slide again, and so on. So moving is a small loop: sweep → stop at the nearest hit → slide → repeat with the leftover time.

We loop a maximum of 4 times (your engine's cap) — enough to handle a corner (hit a wall, slide, hit the perpendicular wall, slide, stop) without ever risking an infinite loop.

The algorithm

You're given the moving box, its full-frame displacement v, and a list of static walls. Track a running pos, a running vel, and timeLeft (fraction of the frame remaining, starts at 1).

pos = { box.x, box.y }
vel = { v.x, v.y }
timeLeft = 1

repeat up to 4 times, while timeLeft > 0:
    move = vel * timeLeft                         // what's left to travel this frame

    find the NEAREST hit: for each wall, sweptAABB(box-at-pos, move, wall);
        keep the hit with the smallest .time

    if no hit:
        pos = pos + move                          // clear path: take the rest of the move
        stop

    else:
        pos = pos + move * max(0, hit.time - EPSILON)   // advance to just before contact
        vel = slide(vel, hit.normal)                    // redirect along the wall
        timeLeft = timeLeft * (1 - hit.time)            // consume the used fraction

return pos

Two things worth understanding, not just copying:

  • move = vel * timeLeft. vel is a full-frame displacement (how far you'd go in a whole frame at this velocity). You only have timeLeft of the frame left, so the actual travel is vel * timeLeft. sweptAABB's returned time is then a fraction of that sub-move, which is why pos + move * time is correct.

  • The EPSILON backoff (max(0, hit.time - EPSILON)). Stop a hair short of the wall. If you land exactly on it, floating-point error can leave you a sliver inside — and next iteration's sweep would start already-overlapping, reporting a garbage negative-time "collision" that makes you stick or jitter. That tiny gap is exactly the Math.max(0, time - EPSILON) in your real moveAndSlide. Now you know why it's there. EPSILON is provided in given.ts.

Moving-vs-moving (why your real engine has velocity - otherVel). Here the walls are static, so we sweep with plain vel. When the other body also moves, you sweep in its frame of reference by using the relative velocity vel - otherVel — then the exact same loop works, because from the other body's point of view it's standing still. That's the only difference between this kata and the full engine. The loop itself doesn't change.

Task

Implement moveAndSlide(box, v, walls) in moveAndSlide.ts. Everything you need — sweptAABB, slide, the vector ops, EPSILON — is finished in given.ts.

bun test workshop/steps/10-move-and-slide