This commit is contained in:
Schluffe
2026-09-04 13:57:52 +02:00
parent 71cfa64793
commit e961887953
24 changed files with 340 additions and 360 deletions
+36 -20
View File
@@ -10,9 +10,10 @@ Each folder under `steps/` is one self-contained kata:
- `README.md` — the concept (taught from zero) and the task.
- a stub file — the function(s) **you** implement. They start by `throw`ing.
- a `*.test.ts` file — the validator. Red until you implement it, green when you're done.
- sometimes a `given.ts` — prerequisites from earlier steps, already finished, so
you only ever implement the **one new idea** of this step.
- a `*.test.ts` file — the validator. Red until you implement it, green when
you're done.
- sometimes a `given.ts` — prerequisites from earlier steps, already finished,
so you only ever implement the **one new idea** of this step.
### Run one step
@@ -22,40 +23,55 @@ From the project root:
bun test workshop/steps/01-vectors
```
Red (failing) is the starting state. Implement the stub until it goes green, then
ping me and I'll validate + unlock the next batch.
Red (failing) is the starting state. Implement the stub until it goes green,
then ping me and I'll validate + unlock the next batch.
> Skip nothing silently, but blast through what you already know — the early steps
> are deliberately trivial so the test loop becomes muscle memory before the hard
> rungs.
> Skip nothing silently, but blast through what you already know — the early
> steps are deliberately trivial so the test loop becomes muscle memory before
> the hard rungs.
## The ladder
Each rung is a concept that the real `nage` physics depends on. We climb until
the top rung *is* a working engine.
the top rung _is_ a working engine.
**Batch 1 — foundations (these files exist now):**
- [x] `01-vectors` — vectors as pairs of numbers: add, sub, scale
- [x] `02-vectors-length` — length, normalize (and the zero-vector trap), dot
- [x] `03-integration` — `pos += vel * delta`, and why framerate independence matters
- [x] `04-aabb` — axis-aligned boxes, point-in-box, box overlap (the *discrete* test)
- [x] `05-sweep-1d` — the **entry/exit time** of a moving point against an interval. The seed of everything.
- [x] `03-integration` — `pos += vel * delta`, and why framerate independence
matters
- [x] `04-aabb` — axis-aligned boxes, point-in-box, box overlap (the _discrete_
test)
- [x] `05-sweep-1d` — the **entry/exit time** of a moving point against an
interval. The seed of everything.
**Batch 2 — the swept core (these files exist now):**
- [x] `06-ray-vs-aabb` — combine two 1D sweeps into one: ray vs box, with the surface **normal**
- [x] `07-swept-aabb` — the **Minkowski** trick: shrink the moving box to a point, reuse step 06
- [x] `06-ray-vs-aabb` — combine two 1D sweeps into one: ray vs box, with the
surface **normal**
- [x] `07-swept-aabb` — the **Minkowski** trick: shrink the moving box to a
point, reuse step 06
**Batch 3 — response (these files exist now):**
- [x] `08-resolve` — stop at the moment of contact (`t`), not after
- [x] `09-slide` — subtract the into-the-wall part of velocity and keep going along the wall
- [x] `09-slide` — subtract the into-the-wall part of velocity and keep going
along the wall
**Batch 4 — the real loop + payoff (these files exist now):**
- [x] `10-move-and-slide` — the full loop: multiple obstacles, iteration cap (relative velocity explained)
- [x] `10-move-and-slide` — the full loop: multiple obstacles, iteration cap
(relative velocity explained)
- [x] `11-capstone` — a canvas demo (no test — just run it and play)
**Batch 5 — hardening (unlocked by your own capstone finds):**
- [x] `12-overlap` — the overlap trap: why positions go `NaN` when you start inside a collider, and the depenetration (minimum-translation-vector) fix
- [ ] `13-crush` — when one push lands you in the next wall: iterative depenetration, and the honest answer for gaps narrower than the hero
When step 11 is green you'll have re-derived your own engine's heart — and walking
back into `sweptAABB` should feel like reading your own handwriting again.
- [x] `12-overlap` — the overlap trap: why positions go `NaN` when you start
inside a collider, and the depenetration (minimum-translation-vector) fix
- [ ] `13-crush` — when one push lands you in the next wall: iterative
depenetration, and the honest answer for gaps narrower than the hero
When step 11 is green you'll have re-derived your own engine's heart — and
walking back into `sweptAABB` should feel like reading your own handwriting
again.
+3 -2
View File
@@ -9,13 +9,14 @@ A **vector** here is nothing mystical: a pair of numbers `(x, y)`. We use the
same value to mean two different things depending on context:
- a **position** — a point in the world.
- a **displacement / velocity** — an arrow: "move this much in x, this much in y."
- a **displacement / velocity** — an arrow: "move this much in x, this much in
y."
That's it. All of 2D physics is built on adding, subtracting, and scaling these
pairs.
- `add(a, b)` → `(a.x + b.x, a.y + b.y)` — apply an arrow to a point.
- `sub(a, b)` → `(a.x - b.x, a.y - b.y)` — the arrow that points *from b to a*.
- `sub(a, b)` → `(a.x - b.x, a.y - b.y)` — the arrow that points _from b to a_.
- `scale(a, s)` → `(a.x * s, a.y * s)` — make an arrow longer/shorter.
> Note: we return **new** objects (pure functions) here for clarity. Your real
+6 -5
View File
@@ -9,7 +9,7 @@ An arrow `(x, y)` has a length: how far it reaches. Pythagoras:
### Normalize
Often you want *just the direction* of an arrow, with length exactly 1 (a "unit
Often you want _just the direction_ of an arrow, with length exactly 1 (a "unit
vector"). You get it by dividing the arrow by its own length:
`(x / len, y / len)`.
@@ -20,14 +20,15 @@ straight movement.
> ⚠️ **The zero-vector trap.** What is the length of `(0, 0)`? Zero. What is
> `0 / 0`? `NaN`. If you normalize a zero vector naively, you poison it with
> `NaN`, and `NaN` spreads through every later calculation silently. A correct
> `normalize` must check for zero length and return `(0, 0)` instead of dividing.
> Remember this trap — it is exactly the kind of bug that hides in a real engine.
> `normalize` must check for zero length and return `(0, 0)` instead of
> dividing. Remember this trap — it is exactly the kind of bug that hides in a
> real engine.
### Dot product
`dot(a, b) = a.x*b.x + a.y*b.y`. One number out of two vectors. For now just
implement it; in step 09 you'll learn that it answers "how much of arrow A points
along arrow B?" — the key to sliding along a wall.
implement it; in step 09 you'll learn that it answers "how much of arrow A
points along arrow B?" — the key to sliding along a wall.
## Task
+2 -2
View File
@@ -16,8 +16,8 @@ every game's update loop.
### Why `delta`?
`delta` is the number of **milliseconds since the last frame**. Frames are not
evenly spaced — a busy frame takes longer. If you moved a fixed amount *per
frame* instead of *per millisecond*, your game would run faster on a fast
evenly spaced — a busy frame takes longer. If you moved a fixed amount _per
frame_ instead of _per millisecond_, your game would run faster on a fast
computer and slower on a slow one.
By storing velocity as **units-per-millisecond** and multiplying by `delta`, the
+7 -6
View File
@@ -2,9 +2,10 @@
## Concept
**AABB** = **A**xis-**A**ligned **B**ounding **B**ox: a rectangle whose sides are
parallel to the x and y axes (never rotated). They're cheap to test, which is why
almost every 2D engine — including yours — uses them as the base collision shape.
**AABB** = **A**xis-**A**ligned **B**ounding **B**ox: a rectangle whose sides
are parallel to the x and y axes (never rotated). They're cheap to test, which
is why almost every 2D engine — including yours — uses them as the base
collision shape.
We represent one as a corner plus a size:
@@ -18,7 +19,7 @@ So the box spans `x .. x+w` horizontally and `y .. y+h` vertically.
This is the key insight you'll reuse for the rest of the workshop. Think of each
box as a **shadow on the x-axis** and a **shadow on the y-axis**. Two boxes
intersect only if *both* pairs of shadows intersect:
intersect only if _both_ pairs of shadows intersect:
```
overlapX: a.x < b.x + b.w AND b.x < a.x + a.w
@@ -32,10 +33,10 @@ behind swept collision.
### The discrete trap (why this test alone isn't enough)
`aabbOverlap` only answers "are they overlapping *right now*?" If a fast object
`aabbOverlap` only answers "are they overlapping _right now_?" If a fast object
jumps from one side of a thin wall to the other in a single frame, it never
overlaps the wall at any sampled instant — so this test says "no collision" and
the object tunnels straight through. Steps 05+ fix that by testing the *path*,
the object tunnels straight through. Steps 05+ fix that by testing the _path_,
not the endpoints. Feel the gap here first; it's why everything after exists.
## Task
+10 -8
View File
@@ -1,6 +1,6 @@
# Step 05 — Sweeping in 1D (entry & exit time)
This is the seed of the whole engine. Get this one *in your bones* and the scary
This is the seed of the whole engine. Get this one _in your bones_ and the scary
2D `sweptAABB` becomes "do this twice and combine."
## Concept
@@ -9,26 +9,28 @@ Forget 2D. Forget boxes. We have:
- a **point** sitting at position `p` on a number line,
- moving with velocity `v` — meaning over this one frame it travels a total of
`v` units (so at fraction `t` of the frame, it's at `p + v*t`, for `t` from 0 to 1),
`v` units (so at fraction `t` of the frame, it's at `p + v*t`, for `t` from 0
to 1),
- and a static **interval** `[min, max]` on that same line.
Question: **during this frame, for which `t` is the point inside `[min, max]`?**
### The slab math
The point reaches `min` when `p + v*t = min`, i.e. `t = (min - p) / v`.
Likewise it reaches `max` at `t = (max - p) / v`.
The point reaches `min` when `p + v*t = min`, i.e. `t = (min - p) / v`. Likewise
it reaches `max` at `t = (max - p) / v`.
```
t1 = (min - p) / v
t2 = (max - p) / v
```
If `v` is **negative** (moving left), the point hits `max` *before* `min`, so
If `v` is **negative** (moving left), the point hits `max` _before_ `min`, so
`t1 > t2`. We always want `entry` to be the smaller and `exit` the larger, so
**swap them if they're out of order**. Then:
- `entry` = the time the point *enters* the interval,
- `exit` = the time it *leaves*.
- `entry` = the time the point _enters_ the interval,
- `exit` = the time it _leaves_.
> These can be negative or greater than 1 — that just means the crossing happens
> before this frame started or after it ends. Don't clamp here; the caller (step
@@ -37,7 +39,7 @@ If `v` is **negative** (moving left), the point hits `max` *before* `min`, so
### The `v == 0` edge case
If the point isn't moving (`v == 0`), it never *crosses* an edge — dividing by
If the point isn't moving (`v == 0`), it never _crosses_ an edge — dividing by
zero is meaningless. Instead: it's either already inside the interval for the
whole frame, or never. So:
+17 -16
View File
@@ -7,8 +7,8 @@ entry/exit time of one sweep") finally fuse. A **moving point vs a static box**.
A point at `p` moves by `v` over the frame. A static box has a left/right edge
(its x-interval) and a top/bottom edge (its y-interval). The point is inside the
**box** only while it's inside the x-interval **and** the y-interval *at the same
time*.
**box** only while it's inside the x-interval **and** the y-interval _at the
same time_.
So run `sweepInterval` twice:
@@ -18,7 +18,7 @@ spanY = sweepInterval(p.y, v.y, box.y, box.y + box.h) // the y-edges
```
Each gives you a time-window `[entry, exit]` during which the point is inside
*that one axis's* strip. You're inside the box during the **overlap of the two
_that one axis's_ strip. You're inside the box during the **overlap of the two
windows**:
```
@@ -28,7 +28,7 @@ exit = min(spanX.exit, spanY.exit) // out of the box once you leave the FIR
Read those two lines until they feel obvious — they're the whole algorithm:
- You're only truly *inside the box* once you've entered **both** strips, so the
- You're only truly _inside the box_ once you've entered **both** strips, so the
real entry is the **later** of the two entries → `max`.
- You **leave** the box the instant you exit **either** strip → the **earlier**
exit → `min`.
@@ -36,28 +36,29 @@ Read those two lines until they feel obvious — they're the whole algorithm:
### When is there NO hit?
1. **A span is `null`** — on some axis the point isn't moving and is already
outside that strip. It can never be inside the box. Return `null` immediately.
outside that strip. It can never be inside the box. Return `null`
immediately.
2. **`entry > exit`** — the two windows never overlap. The point is inside one
strip, then the other, but never both at once. That's the classic "flies past
the corner" miss.
3. **`entry >= 1` or `exit <= 0`** — the windows overlap, but not *during this
frame* (it's entirely in the future, or entirely in the past). Not our problem
this frame.
3. **`entry >= 1` or `exit <= 0`** — the windows overlap, but not _during this
frame_ (it's entirely in the future, or entirely in the past). Not our
problem this frame.
### The normal (which wall did we hit?)
When you do collide, you also want to know **which face** you hit, so the response
later can push you back the right way. That's the `normal` — a unit vector
pointing out of the surface you struck.
When you do collide, you also want to know **which face** you hit, so the
response later can push you back the right way. That's the `normal` — a unit
vector pointing out of the surface you struck.
The trick: **the axis you entered *last* is the axis you actually hit.** Compare
The trick: **the axis you entered _last_ is the axis you actually hit.** Compare
the two entry times — whichever is larger is the blocking axis:
- if `spanX.entry > spanY.entry` → you hit a **vertical** wall (left/right face).
The normal is horizontal, pointing back against your x-motion:
- if `spanX.entry > spanY.entry` → you hit a **vertical** wall (left/right
face). The normal is horizontal, pointing back against your x-motion:
`normal = { x: v.x > 0 ? -1 : 1, y: 0 }`.
- otherwise → you hit a **horizontal** wall (top/bottom). The normal is vertical:
`normal = { x: 0, y: v.y > 0 ? -1 : 1 }`.
- otherwise → you hit a **horizontal** wall (top/bottom). The normal is
vertical: `normal = { x: 0, y: v.y > 0 ? -1 : 1 }`.
(Moving right and hitting something → the surface pushes you left → normal `-1`.
That sign rule is all there is to it.)
+19 -19
View File
@@ -1,16 +1,16 @@
# Step 07 — Swept AABB (the Minkowski trick)
Step 06 handled a moving **point** vs a box. But in a real game the thing that
moves is a **box** (the player), not a point. This step turns "moving box vs box"
into "moving point vs box" so you can reuse step 06 *unchanged*. That conversion
is the single cleverest idea in the whole engine.
moves is a **box** (the player), not a point. This step turns "moving box vs
box" into "moving point vs box" so you can reuse step 06 _unchanged_. That
conversion is the single cleverest idea in the whole engine.
## The problem
Box A (the player) sits at corner `(a.x, a.y)` with size `a.w × a.h`, and moves by
`v` this frame. Box B (a wall) is static. When do they touch?
Box A (the player) sits at corner `(a.x, a.y)` with size `a.w × a.h`, and moves
by `v` this frame. Box B (a wall) is static. When do they touch?
It's fiddly because *both* shapes have size. You'd have to track four edges of A
It's fiddly because _both_ shapes have size. You'd have to track four edges of A
against four edges of B. Ugh.
## The trick: grow B, shrink A to a point
@@ -22,14 +22,14 @@ spans `[b.x, b.x + b.w]`. They overlap when:
a.x < b.x + b.w AND b.x < a.x + a.w
```
Rearrange the second one (`b.x - a.w < a.x`) and you get a statement purely about
**`a.x`**, the corner of A:
Rearrange the second one (`b.x - a.w < a.x`) and you get a statement purely
about **`a.x`**, the corner of A:
```
b.x - a.w < a.x < b.x + b.w
```
Read that: A's *corner* `a.x` behaves exactly like a **point** sliding inside a
Read that: A's _corner_ `a.x` behaves exactly like a **point** sliding inside a
**wider interval** — one that starts `a.w` earlier and is `a.w` longer than B.
The same happens on y with `a.h`.
@@ -45,18 +45,18 @@ inflated = {
point = { x: a.x, y: a.y } // A is now just its corner
```
This grown box is the **Minkowski sum** of B with A. And "does this point, moving
by `v`, hit `inflated`?" is *exactly* `rayVsAABB` from step 06. You're done in
three lines.
This grown box is the **Minkowski sum** of B with A. And "does this point,
moving by `v`, hit `inflated`?" is _exactly_ `rayVsAABB` from step 06. You're
done in three lines.
> Sanity picture: player box 2 wide with its right edge at x=2, wall left edge at
> x=5 → real gap is 3. Inflate: `inflated.x = 5 - 2 = 3`, and the player's corner
> sits at x=0, so the corner-to-inflated-edge gap is also 3. Same answer, simpler
> shape. The inflation *bakes A's size into the wall* so the corner can pretend to
> be a point.
> Sanity picture: player box 2 wide with its right edge at x=2, wall left edge
> at x=5 → real gap is 3. Inflate: `inflated.x = 5 - 2 = 3`, and the player's
> corner sits at x=0, so the corner-to-inflated-edge gap is also 3. Same answer,
> simpler shape. The inflation _bakes A's size into the wall_ so the corner can
> pretend to be a point.
This is the heart of your real engine's `sweptAABB` — the `inflAABB` it builds is
this very inflated box, and `(ax, ay)` is this corner point.
This is the heart of your real engine's `sweptAABB` — the `inflAABB` it builds
is this very inflated box, and `(ax, ay)` is this corner point.
## Task
+19 -18
View File
@@ -1,15 +1,15 @@
# Step 08 — Resolve (move *to* the wall, not through it)
# Step 08 — Resolve (move _to_ the wall, not through it)
Detection is done. Now for **response** — actually reacting to the hit. This first
half is almost embarrassingly small, but it introduces the idea the whole loop
(step 10) is built on: **the frame isn't all-or-nothing.**
Detection is done. Now for **response** — actually reacting to the hit. This
first half is almost embarrassingly small, but it introduces the idea the whole
loop (step 10) is built on: **the frame isn't all-or-nothing.**
## Concept
`sweptAABB` hands you a `Hit` with a `time` between 0 and 1 — the fraction of the
frame at which you'd collide. So instead of moving the full displacement `v`
(which would bury you inside the wall), you move only the part of it that happens
*before* impact:
`sweptAABB` hands you a `Hit` with a `time` between 0 and 1 — the fraction of
the frame at which you'd collide. So instead of moving the full displacement `v`
(which would bury you inside the wall), you move only the part of it that
happens _before_ impact:
```
no hit → newPos = pos + v (nothing in the way: take the whole move)
@@ -22,21 +22,22 @@ That's it — you already have `add` and `scale`; this is them, gated on the hit
Here's the seed for step 10: if you hit at `t = 0.3`, you only used **30%** of
this frame. The other **70%** is still owed to the player — they should keep
moving for the rest of the frame, just not *into* the wall. That leftover time is
exactly why sliding (step 09) and the loop (step 10) exist. A collision doesn't
end the frame; it **interrupts** it.
moving for the rest of the frame, just not _into_ the wall. That leftover time
is exactly why sliding (step 09) and the loop (step 10) exist. A collision
doesn't end the frame; it **interrupts** it.
> Real-engine footnote: production code usually moves to `t - EPSILON` (a hair
> *short* of contact) so floating-point error can't leave the box a sliver inside
> the wall, where the next frame's sweep would start already-overlapping. Your
> `nage` does this with `Math.max(0, time - EPSILON)`. We keep the kata exact so
> the numbers stay clean — just know that tiny backoff is there for a real reason.
> _short_ of contact) so floating-point error can't leave the box a sliver
> inside the wall, where the next frame's sweep would start already-overlapping.
> Your `nage` does this with `Math.max(0, time - EPSILON)`. We keep the kata
> exact so the numbers stay clean — just know that tiny backoff is there for a
> real reason.
## Task
Implement `resolve(pos, v, hit)` in `resolve.ts`: return the full move when `hit`
is `null`, otherwise the position at contact. `vec/add/scale` and the `Hit` type
are in `given.ts`.
Implement `resolve(pos, v, hit)` in `resolve.ts`: return the full move when
`hit` is `null`, otherwise the position at contact. `vec/add/scale` and the
`Hit` type are in `given.ts`.
```sh
bun test workshop/steps/08-resolve
+16 -13
View File
@@ -1,28 +1,31 @@
# Step 09 — Slide (this is what `dot` was for)
Back in step 02 you implemented `dot` and I said "you'll see later." This is
later. Sliding is the difference between a game that feels good and one where you
stick to every wall like glue.
later. Sliding is the difference between a game that feels good and one where
you stick to every wall like glue.
## The problem
You're moving with velocity `v` and you hit a wall whose outward normal is `n`.
If you just *stop* (velocity → 0), the player jams against the wall — press
into a wall diagonally and all motion dies, even the part that was parallel to
the wall and perfectly fine. What we actually want: **cancel only the part of `v`
that pushes *into* the wall, and keep the part that runs *along* it.** That's a
If you just _stop_ (velocity → 0), the player jams against the wall — press into
a wall diagonally and all motion dies, even the part that was parallel to the
wall and perfectly fine. What we actually want: **cancel only the part of `v`
that pushes _into_ the wall, and keep the part that runs _along_ it.** That's a
slide.
## The math (projection)
Any velocity `v` can be split into two pieces relative to the wall:
- the part **along the normal** (into/out of the wall) — this is what the wall forbids,
- the part **along the wall surface** (perpendicular to the normal) — this is fine.
- the part **along the normal** (into/out of the wall) — this is what the wall
forbids,
- the part **along the wall surface** (perpendicular to the normal) — this is
fine.
Because `n` is a **unit vector**, the amount of `v` pointing along `n` is exactly
`dot(v, n)`. That single number is "how much of `v` goes straight into the wall."
The vector piece pointing into the wall is `n * dot(v, n)`. Subtract it off:
Because `n` is a **unit vector**, the amount of `v` pointing along `n` is
exactly `dot(v, n)`. That single number is "how much of `v` goes straight into
the wall." The vector piece pointing into the wall is `n * dot(v, n)`. Subtract
it off:
```
vSlide = v - n * dot(v, n)
@@ -30,8 +33,8 @@ vSlide = v - n * dot(v, n)
What's left has **zero** component along the normal — it lies flat against the
wall. (That's the geometric meaning of `dot`: it measures how much two vectors
share a direction. Subtract the shared-with-the-normal part, and nothing pointing
into the wall survives.)
share a direction. Subtract the shared-with-the-normal part, and nothing
pointing into the wall survives.)
### Feel it with numbers
+28 -24
View File
@@ -6,14 +6,15 @@ 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.
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.
(hit a wall, slide, hit the perpendicular wall, slide, stop) without ever
risking an infinite loop.
## The algorithm
@@ -46,29 +47,32 @@ 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.
- **`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`.
- **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.
> **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`.
Implement `moveAndSlide(box, v, walls)` in `moveAndSlide.ts`. Everything you
need — `sweptAABB`, `slide`, the vector ops, `EPSILON` — is finished in
`given.ts`.
```sh
bun test workshop/steps/10-move-and-slide
+13 -11
View File
@@ -19,27 +19,29 @@ bunx serve workshop/steps/11-capstone
```
Arrow keys move the pink box. Run it into the border, the ledge, the pillar, the
bar. Push diagonally into a wall and watch it **slide** along instead of sticking.
That sliding is your step-09 `dot`-product projection. The fact that it stops
*at* the wall instead of tunneling through, even at speed, is your step-07 swept
detection. The clean corners are your step-10 loop running twice in one frame.
bar. Push diagonally into a wall and watch it **slide** along instead of
sticking. That sliding is your step-09 `dot`-product projection. The fact that
it stops _at_ the wall instead of tunneling through, even at speed, is your
step-07 swept detection. The clean corners are your step-10 loop running twice
in one frame.
## Make it yours (optional)
- Open `game.js`. The top half is your kernel — read it and confirm it matches
what you wrote. Swap in your own `moveAndSlide` from step 10 and check it feels
identical (it will).
what you wrote. Swap in your own `moveAndSlide` from step 10 and check it
feels identical (it will).
- Add a wall to the `walls` array. Change `SPEED`. Make the player bigger.
- Try **deleting the `EPSILON` backoff** (`Math.max(0, nearest.time - EPSILON)`
→ `nearest.time`) and push into a wall. Watch it stick and jitter. Then put it
back. Now you've *felt* why that line exists in your real engine.
back. Now you've _felt_ why that line exists in your real engine.
## You're back
That's the whole climb: pairs of numbers → sweeping a point → sweeping a box via
Minkowski → detecting the hit → stopping and sliding → the full loop → a thing you
can play. Every rung is a function that exists, by name, inside your real
Minkowski → detecting the hit → stopping and sliding → the full loop → a thing
you can play. Every rung is a function that exists, by name, inside your real
`engine/system/physics.ts`.
Now go open the real `sweptAABB` with fresh eyes. You know exactly what every line
is *supposed* to do — so the two lines that don't should stand out. Happy hunting.
Now go open the real `sweptAABB` with fresh eyes. You know exactly what every
line is _supposed_ to do — so the two lines that don't should stand out. Happy
hunting.
+55 -55
View File
@@ -1,61 +1,61 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>nage physics kernel — capstone</title>
<style>
html,
body {
margin: 0;
height: 100%;
background: #0b0c10;
color: #c8cde0;
font: 14px/1.5 ui-monospace, "SF Mono", Menlo, monospace;
display: grid;
place-items: center;
}
.wrap {
text-align: center;
}
canvas {
width: 720px;
max-width: 96vw;
height: auto;
image-rendering: pixelated;
border: 1px solid #2e3550;
border-radius: 4px;
box-shadow: 0 10px 40px #0008;
}
h1 {
font-size: 15px;
font-weight: 600;
letter-spacing: 0.02em;
color: #ee459e;
margin: 0 0 12px;
}
p {
margin: 12px 0 0;
color: #7d84a0;
}
kbd {
background: #1b1f2b;
border: 1px solid #2e3550;
border-radius: 3px;
padding: 1px 6px;
color: #c8cde0;
}
</style>
</head>
<body>
<div class="wrap">
<h1>your swept-AABB kernel, live</h1>
<canvas id="view" width="240" height="160"></canvas>
<p>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>nage physics kernel — capstone</title>
<style>
html,
body {
margin: 0;
height: 100%;
background: #0b0c10;
color: #c8cde0;
font: 14px/1.5 ui-monospace, "SF Mono", Menlo, monospace;
display: grid;
place-items: center;
}
.wrap {
text-align: center;
}
canvas {
width: 720px;
max-width: 96vw;
height: auto;
image-rendering: pixelated;
border: 1px solid #2e3550;
border-radius: 4px;
box-shadow: 0 10px 40px #0008;
}
h1 {
font-size: 15px;
font-weight: 600;
letter-spacing: 0.02em;
color: #ee459e;
margin: 0 0 12px;
}
p {
margin: 12px 0 0;
color: #7d84a0;
}
kbd {
background: #1b1f2b;
border: 1px solid #2e3550;
border-radius: 3px;
padding: 1px 6px;
color: #c8cde0;
}
</style>
</head>
<body>
<div class="wrap">
<h1>your swept-AABB kernel, live</h1>
<canvas id="view" width="240" height="160"></canvas>
<p>
<kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> <kbd>→</kbd> to move — run into
the walls and feel it slide
</p>
</div>
<script src="./game.js"></script>
</body>
</div>
<script src="./game.js"></script>
</body>
</html>
+22 -21
View File
@@ -2,21 +2,21 @@
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
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
> `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
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:
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.
@@ -38,33 +38,34 @@ Work through these **in order, predicting each answer before checking** (add
`console.log`s 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
_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*?
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?
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,
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.
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 —
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:
```
@@ -79,9 +80,9 @@ 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
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
`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
@@ -92,7 +93,7 @@ Two functions in `overlap.ts`:
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
(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.
@@ -105,4 +106,4 @@ bun test workshop/steps/12-overlap
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?
about: does your _real_ engine survive the same experiment?
+21 -21
View File
@@ -3,17 +3,17 @@
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*.
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
> 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
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.
@@ -28,7 +28,7 @@ Two experiments in a scratch file, **predicting each outcome before running**
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*?
`x` bounce between, and _why will it never stop_?
## The negotiation, and when it honestly fails
@@ -36,11 +36,11 @@ The fix for experiment 1 is patience: don't do one pass — **repeat whole passe
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
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.
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
@@ -48,9 +48,9 @@ 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
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
@@ -67,8 +67,8 @@ Two functions in `crush.ts`:
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.)
"bonus" question last session — it's still open, and one of the tests refuses to
look away.)
```sh
bun test workshop/steps/13-crush
@@ -76,13 +76,13 @@ 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.
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
@@ -93,4 +93,4 @@ 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.
the winner change? That flip _is_ the watermelon seed.