# Collision handling — ground-up construction plan

Companion to DESIGN.md (base controller) and TODO.md.

**Premise.** The control layer is excellent and stays. The collision
layer is being **replaced wholesale** — the current
`_moveAndSlide` + `_integrateVerticalAndResolveGround` + scalar
ground-resolver regime is not a foundation to build on; it's scaffolding
to delete once the replacement reaches parity. We keep relying on
`PhysicsSystem` for all the heavy lifting (`shapeCast`, `raycast`,
`overlap`, `compute_penetration`) — this plan does *more* with it, not
less.

Every phase is **guard-test-first**, and ordered so the controller stays
shippable and green after each one. The new mover is built alongside,
proven against ported + new specs, switched over, and only then is the
old code deleted.

---

## 1. The seam: control produces motion, collision resolves it

The clean split that makes a ground-up rebuild safe — the control layer
already does its half today:

```
CONTROL (sacred, unchanged)                COLLISION (rebuilt)
──────────────────────────                 ──────────────────
intent → desired velocity                  move(pose, shape, velocity, dt)
accel curves, jump FSM,          ──v──▶       → resolved position
gravity/impulse, abilities,                   → corrected velocity
mastery, posture                              → ground state {grounded, normal}
        ▲                                              │
        └──────────  grounded, groundNormal  ◀─────────┘
                     (read next tick by jump FSM,
                      gravity gating, slope logic)
```

The control layer's output each tick is a **desired velocity** (it
already computes this — intent+accel horizontally, gravity+jump
vertically). The collision layer's *only* job is: given that velocity,
the capsule pose, and the world, produce the actual resulting position,
the velocity corrected for what it hit, and the ground state. It never
invents motion; it only constrains it.

One cleanup this forces (Phase 4): **gravity + jump-impulse application
move out of the deleted collision method into a small motor helper**
the base loop and abilities call. The solver stays purely about
collision.

---

## 2. What we keep / replace / delete

**Keep (untouched):** the whole control stack — intent, mono-exp accel,
jump FSM, posture→collider sizing, abilities (Slide/WallRun/Mantle/
LedgeGrab), mastery, camera composition, sensors.

**Replace:** the regime that *consumes* velocity —
`_moveAndSlide` (horizontal-only swept slide), the direct vertical
position add, and the downward-ray scalar-Y ground snap.

**Delete (Phase 4, after parity):** `_moveAndSlide`,
`_integrateVerticalAndResolveGround`'s collision body, the
`CAST_STEP_HEIGHT` implicit-step hack, and the scalar `groundResolver`
*as the grounding authority* (it survives only as a no-physics fallback
inside the new ground probe).

---

## 3. Physics capabilities we build on

The P1–P6 narrowphase/query work is the enabling foundation — the
rebuild is only feasible because these are now narrowphase-exact.

| Capability | Status | Source |
|---|---|---|
| `raycast` exact surface **normal** (sphere/box/capsule/mesh/heightmap) | ✅ | refine_ray_hit P1–P3 (`6f931b4`…`4c2292c`) |
| `shapeCast` true contact **normal at TOI** (MPR, not broadphase −dir) | ✅ | `shape_cast.js` normal-recovery |
| `shapeCast` **start-in-contact** → `t=0` + valid normal | ✅ | `shape_cast.js:195`, `:350` |
| `shapeCast` analytic slab-narrowing (long-sweep accuracy) | ✅ | `shape_cast.js:203` |
| `compute_penetration(out, A,pa,ra, B,pb,rb) → depth`, `out`=unit B→A | ✅ **public** | `compute_penetration.js:138` |
| `overlap(shape,pos,rot,out,off,filter)` → body-id list | ✅ | `overlap_shape.js` |
| EPA adaptive tol / MPR fallback / box-box edge / capsule-tri manifold / GJK cache | ✅ | P2.2, P3.1, P3.2, P1.1c, P2.1 |

Contracts the plan honours:
- `shapeCast` `result.t` is a **world distance** when direction is
  unit-length and `tMax = sweepLength`. New casts keep that.
- `compute_penetration` accepts the convex player capsule vs any
  shape (only concave-vs-concave throws); `out_direction` is the unit
  **B→A** separation, depth is the scalar return. Push the capsule (A)
  out along `+out_direction · depth`.
- `shapeCast` `result.position` is the swept **centre**, not the
  contact point — we derive ground-surface Y from the cast `t` + the
  capsule's bottom offset, so we never need a contact point.

---

## 4. Target architecture — a dedicated `KinematicMover`

Extract the collision solver into its own module (name placeholder —
`KinematicMover` / `CharacterMover`), testable in isolation against a
real `PhysicsSystem`, not buried in the 1500-line system file. Matches
the repo's extraction taste (Spring, EyeOffsetStack, FirstPersonSensors).

It knows nothing about the controller — just a capsule, a velocity, the
world, and an `up` axis. Single entry point:

```
move(transform, shape, velocity, dt, filter) → MoveResult
    // mutates transform.position; returns:
    //   { velocityX/Y/Z (corrected), grounded, groundNormal, hit }
```

Modelled on modern kinematic character controllers (Jolt
`CharacterVirtual`, Source `TryPlayerMove`+`CategorizePosition`), the
move is an explicit sequence — **recover → slide → (stairs) → ground →
settle**:

1. **Recover (depenetration).** Before moving, get clear. `overlap()`
   the capsule; for each body, `compute_penetration` → push the capsule
   out by `depth · dir`; iterate a few times (deepest-first) to
   convergence. Handles spawned-in-geometry, world-crushed-into-us, and
   the start-solid case a pure sweep *cannot*. This is now a **core
   step**, available from Phase 1 — not a bolt-on — because
   `compute_penetration` is public.

2. **Sweep-and-slide (unified 3D).** Sweep the *full* velocity·dt
   (gravity folded in — no H/V split) via `shapeCast`; stop at TOI,
   clip velocity onto the contact tangent, project the residual, repeat
   up to N iterations. **Crease-aware**: a second plane re-violating the
   first → project onto the seam `normalize(n₁×n₂)`; a third plane in
   one move → dead-stop. Walls, floors, ceilings are all just contact
   planes, so anti-tunnelling (H and V) falls out of one loop.

3. **Stairs (explicit, optional per move).** When grounded and blocked
   by a low step: sweep up by `stepHeight`, sweep the residual forward,
   sweep back down — the Jolt/Source step pattern. Replaces the 5 cm
   implicit-cast hack with real stair climbing, gated so walls taller
   than `stepHeight` still block.

4. **Ground categorize + stick.** Short downward `shapeCast` (capsule
   swept down a `SKIN + band`); if it hits within the band with
   `normal.y ≥ MIN_WALK_NORMAL` (~0.7), set `grounded`, capture the
   normal, snap to the surface, and kill the into-ground velocity
   component. Band-test + active snap (not a strict `y ≤ testY`) is what
   structurally kills the landing **bounce**. Too-steep normal →
   not grounded → the slope was already a slide plane in step 2, so the
   player slides down it.

5. **Settle.** One final `compute_penetration` recover pass guarantees
   the tick ends penetration-free (resting-contact float drift
   insurance; usually a no-op).

**No-slip ⇔ walkable (one gate, not two).** Between recover and slide,
when the feet rest on walkable ground (`_groundedAt` — a walkable normal
within the stick band, the *same* `MIN_WALK_NORMAL` as step 4), the
downward **gravity** velocity is dropped before the slide. Otherwise
gravity projects onto a ramp's tangent as a downhill **drift** and a
standing player creeps down every slope it can walk. A biped grips any
slope it could walk up; the instant its feet slip it could not have
climbed either — so *"doesn't slide down"* and *"can be walked up"* are
the same threshold, not two independent tunables. A too-steep face fails
the gate, keeps its gravity, and slides (step 4) — purchase and no-slip
appear and vanish together as the one knob moves. A jump (`v.y > 0`) is
exempt so the launch is never cancelled.

Slope handling, multi-body resting contact, and unstick all fall out of
steps 1/2/4 — no special cases. Abilities get correct collision for free
by routing their motion through the same `move()`.

---

## 5. Phased plan (rebuild, not patch)

### Phase 1 — `KinematicMover`: recover + unified sweep-and-slide  ✅ **landed**
Stand up the module. Implement **recover** (overlap +
compute_penetration) and **unified 3D sweep-and-slide** (crease-aware).
The system calls `move()` for the combined velocity·dt, replacing both
`_moveAndSlide` and the direct vertical add. Grounding stays on the old
resolver as a short-lived interim shim (removed in Phase 2) so this
phase isolates "does the new mover move correctly."

**Guard tests** (`KinematicMover.spec.js`, against a real PhysicsSystem):
spawn straddling a box → recovered outside within a tick; wall stop;
slide-along axis-aligned + oblique wall; **vertical** anti-tunnel (drop
through a thin slab → lands on top); jump into ceiling → stops; corner
crease → clean stop, no leak/chatter. Port the existing
`MoveAndSlide.spec.js` scenarios through the new path.

**Risk:** medium. New module, but isolated; old grounding still in place.

### Phase 2 — Ground categorize + slope + stick (inside the mover)  ✅ **landed**
Add steps 4 (categorize/stick) to the mover. Down-`shapeCast` →
`{grounded, normal}`; `MIN_WALK_NORMAL` gate; snap + kill into-ground
velocity; reproject grounded velocity onto the slope. **Remove the
interim grounding shim and the scalar resolver authority** (resolver
demoted to no-physics fallback *inside* the probe, behind one function —
uniform flow). Feed `grounded`/`groundNormal` back to control.

**Guard tests** (`GroundSlope.spec.js`): walk up a 30° ramp keeps speed,
stays grounded, correct normal; stationary-on-floor stays at y≈0 across
120 ticks (the no-bounce repro, now with nothing masking it); 60° face →
not grounded, slides down, not glued.

**Risk:** medium. Changes grounding semantics; the bounce reconciliation
lives here.

### Phase 3 — Stairs  ✅ **landed**
Real stair climbing, gated by `stepHeight` (default 0.3 m, just under
the capsule radius). Three cooperating pieces — it took iterating to
find that all three are needed:

  1. **Explicit step-up** (`_tryStepUp`, Source/Jolt up-forward-down).
     When a grounded move is blocked, decide step-vs-wall with a **thin
     horizontal ray at the step-height plane** (`sy + stepHeight + skin`),
     then — only if clear — lift by stepHeight, advance the residual
     forward, drop onto the step, commit when it gained ground within
     stepHeight, and **restore the horizontal velocity**. The velocity
     restore is the crux for REALISTIC stairs: the capsule's round bottom
     also rolls up low risers, but only with sustained forward momentum,
     and a controller that reads back the slide-clipped velocity loses
     that momentum at the riser and stalls. The step-up climbs without
     depending on momentum and hands the velocity back so the player keeps
     moving up the flight.

     The step-vs-wall ray is what stops the round bottom climbing a
     too-tall wall — and the reason it's a THIN ray, not the obvious
     "lift the capsule and sweep it forward" clearance cast: lifted so its
     tip sits at stepHeight, the capsule's rounded bottom narrows through
     the band `(stepHeight, stepHeight+radius)`, so a wall whose top lands
     in that band is never reached by the swept shape — it reads "clear"
     and the player climbs it, *faster speed reaching further into the
     round-off* (the gray-box bug: jitter at a walk, clean climb at a
     sprint). A thin ray has no round-off: it reads the obstacle's true
     height, so a step (top below the plane) is passed over and a wall
     (top above) is struck on its front face, at any approach speed.

     The ray is cast along the **blocked direction** (the horizontal
     velocity the slide removed = the obstacle's inward normal), from the
     **pre-slide** centre, reaching the swept distance plus a forward
     extent. Both matter for OBLIQUE approaches: at 45° the capsule meets a
     wall with its normal-direction extent, so a ray along *travel* stops
     short of the face and reads a wall as clear (climbing it); and
     rounding a convex corner the slide carries the *post*-slide centre
     just past the corner, so a ray from there shoots past the obstacle —
     the pre-slide centre was still in front of what blocked it. The reach
     tracking the sweep isn't the banned speed coupling: the climb-or-block
     decision is the step-height plane alone; the reach only governs how
     far ahead to look for what the slide already hit.

     A single ray still can't catch a convex *point*: grazing a corner a
     few degrees off its bisector, the blocked normal runs nearly parallel
     to a face, so the ray slips past the corner just outside the footprint
     and reads clear. The backstop is a post-hoc OVERLAP query — after
     up-forward-down, if the destination overlaps geometry the climb landed
     the round body ON a wall corner, not on a step (a real step top is
     rested on `skin` above, never overlapping), so it's rejected. Probe for
     the common case, overlap-test to catch what a ray threads past.

     The same round-bottom perch is why the slide keeps every contact's true
     normal (no "flatten steep contacts to vertical" — that would also rob
     a too-steep *slope* of its downhill slide) and why the footprint
     mount in (2) is gated on height above the **centre surface**, not the
     feet (gating on the feet lets a capsule that rode up a hair mount the
     next sliver and ratchet up a wall).
  2. **Two-probe ground categorize** for honest grounded-ness on a step
     edge — *walkability* from a centre raycast (ignores a step's convex
     top edge and a wall's side face, so a steep *slope* is correctly
     not-grounded) and *rest height* from a footprint shapecast (mounts
     a step the leading edge overhangs). A single probe can't tell a
     climbable step edge from a steep slope; the split can.
  3. **Step-DOWN** = the ground-stick reach is `stepHeight`: walking off
     a drop ≤ stepHeight snaps onto the lower surface (stays grounded);
     a larger drop goes airborne.

Footgun caught in the prototype: my first stair test used *deep
overlapping* steps, where the round-bottom roll alone climbs and the
explicit step-up looked unnecessary. Realistic *thin* treads (shallower
than the capsule footprint) need the step-up — see the thin-tread tests.

**Guard tests** (`collision/Stairs.spec.js` + `KinematicMoverIntegration.spec.js`,
all green): climb a 5-step staircase; climb a thin-tread staircase
(treads < footprint) both at the mover level and through the full
controller; clear a single curb; a 0.5 m riser (> stepHeight) blocks;
a 0.5 m riser is a **clean wall — no edge ride-up, and clearing is
speed-independent** (walk and sprint both stop dead, the gray-box guard);
descend a staircase grounded the whole way.

### Phase 4 — Motor seam + delete old code  ✅ **landed**
The mover is now the **only** collision path when a `PhysicsSystem` is
present — the opt-in flag is gone; dispatch is on physics presence, not
a flag.

What shipped:
- `_integrateVerticalAndResolveGround` is now a thin dispatcher:
  `_applyGravity` (the motor) → `_moveViaMover` (physics) **or**
  `_moveFlatGround` (no physics) → `_detectJumpApex`.
- **Gravity** extracted to `_applyGravity`; **land / leave-ground**
  extracted to `_onLand` / `_onLeaveGround`, shared by both move paths
  (the duplicated scaffolding from Phase 1's wiring is gone).
- **Deleted** `_moveAndSlide` (+ its `CAST_STEP_HEIGHT` / `SKIN`
  constants and the `slideRay` / `slideHit` scratch), the legacy ground-
  resolution body, and the `useKinematicMover` flag.
- `MoveAndSlide.spec.js` deleted — it tested `_moveAndSlide`; its
  scenarios (wall-stop, axis + oblique slide, anti-tunnel) are covered
  by `collision/KinematicMover.spec.js`.

What stayed (deliberately):
- `useBuiltInFlatGround` / `groundY` / `groundResolver` remain — they're
  the **no-physics** fallback (`_moveFlatGround`) for headless and
  control-layer unit tests, and the resolver is still spec-covered. With
  physics present they're unused (the mover probes the world).
- **WallRun still self-integrates** its reduced-gravity model rather than
  routing through `move()`. Slide already routes through the mover (it
  calls `_integrateVerticalAndResolveGround`); WallRun's custom model is
  left as a future enhancement, not required for the legacy deletion.

**Result:** 29 suites / 236 tests green; the entire ability + jump +
momentum + posture suite passes through the consolidated path.

---

## 6. Physics requirements — status

**Resolved.** My one HIGH ask from the review — a public penetration
query — is **satisfied**: `compute_penetration` is public and is exactly
the right shape (unit B→A direction + scalar depth, capsule-vs-anything).
It graduates from "deferred safety net" to the **core recover/settle
primitive** the whole mover leans on (steps 1 & 5). Confirmed it works:
yes, this is precisely what's needed — thank you for keeping it decoupled
and public.

**No remaining blocking requirements.** Phases 1–4 need nothing further
from the physics engine. The two low-priority niceties from the review
turned out not to bite:
- *Contact point from `shapeCast`* — not needed; ground-surface Y is
  derivable from the cast `t` + the capsule bottom offset.
- *Multi-contact manifold from one query* — covered for resting contact
  by iterating `compute_penetration` over **every** `overlap()` body in
  the recover pass; the single-sweep corner case is handled by the slide
  iteration instead. So no native multi-contact query is required.

If anything *would* be a future nicety (not needed now): a swept query
returning the *set* of TOI-simultaneous contacts (for one-pass corner
creases instead of iterating). Strictly an optimisation; the iterative
slide is correct without it.

---

## 7. Constants (ours ← reference lineage)

| Constant | Plan value | Reference | Phase |
|---|---|---|---|
| slide iterations | 4 | Quake `numbumps` 4 | 1 |
| `SKIN` | 0.005 m | Fauerby `veryCloseDistance` ~0.005 | 1 |
| recover max iters | ~4 | — (convergence cap) | 1 |
| `MIN_WALK_NORMAL` | ~0.7 (≈45°) | Quake3 0.7 / Source `normal.z ≥ 0.7` | 2 |
| ground-probe band | ~0.06 m | Source `StayOnGround` ~2 u | 2 |
| crease dead-stop | zero on 3rd plane | Quake `SV_FlyMove` | 1 |
| `stepHeight` | ~0.3 m (tunable) | Source `sv_stepsize` 18 u (~0.34 m) | 3 |

Clip stays at effective overbounce `1.0` gated on into-wall (`v·n < 0`),
relying on `SKIN` for clearance — equivalent in practice to Quake3's
`OVERCLIP 1.001` nudge; no change planned.
