@woosh/meep-engine 2.145.0 → 2.146.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts +33 -3
  3. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts.map +1 -1
  4. package/src/core/geom/3d/shape/HeightMapShape3D.js +486 -451
  5. package/src/engine/control/first-person/DESIGN_COLLISION.md +365 -352
  6. package/src/engine/control/first-person/FirstPersonPlayerController.d.ts +1 -3
  7. package/src/engine/control/first-person/FirstPersonPlayerController.d.ts.map +1 -1
  8. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts +12 -2
  9. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts.map +1 -1
  10. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.js +7 -2
  11. package/src/engine/control/first-person/TODO.md +13 -11
  12. package/src/engine/control/first-person/abilities/WallJump.d.ts.map +1 -1
  13. package/src/engine/control/first-person/abilities/WallJump.js +11 -3
  14. package/src/engine/control/first-person/abilities/WallRun.d.ts.map +1 -1
  15. package/src/engine/control/first-person/abilities/WallRun.js +12 -0
  16. package/src/engine/control/first-person/collision/KinematicMover.d.ts.map +1 -1
  17. package/src/engine/control/first-person/collision/KinematicMover.js +634 -592
  18. package/src/engine/control/first-person/prototype_first_person_controller.js +1003 -901
  19. package/src/engine/physics/PLAN.md +943 -809
  20. package/src/engine/physics/body/BodyStorage.d.ts +9 -0
  21. package/src/engine/physics/body/BodyStorage.d.ts.map +1 -1
  22. package/src/engine/physics/body/BodyStorage.js +23 -0
  23. package/src/engine/physics/broadphase/generate_pairs.d.ts.map +1 -1
  24. package/src/engine/physics/broadphase/generate_pairs.js +7 -0
  25. package/src/engine/physics/ccd/linear_sweep.d.ts +97 -0
  26. package/src/engine/physics/ccd/linear_sweep.d.ts.map +1 -0
  27. package/src/engine/physics/ccd/linear_sweep.js +238 -0
  28. package/src/engine/physics/ecs/PhysicsSystem.d.ts +18 -3
  29. package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
  30. package/src/engine/physics/ecs/PhysicsSystem.js +59 -8
  31. package/src/engine/physics/ecs/RigidBodyFlags.d.ts +6 -0
  32. package/src/engine/physics/ecs/RigidBodyFlags.d.ts.map +1 -1
  33. package/src/engine/physics/ecs/RigidBodyFlags.js +6 -0
  34. package/src/engine/physics/narrowphase/box_triangle_contact.js +811 -811
  35. package/src/engine/physics/narrowphase/compute_penetration.d.ts.map +1 -1
  36. package/src/engine/physics/narrowphase/compute_penetration.js +325 -323
  37. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.d.ts +27 -8
  38. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.d.ts.map +1 -1
  39. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.js +235 -204
  40. package/src/engine/physics/narrowphase/narrowphase_step.d.ts.map +1 -1
  41. package/src/engine/physics/narrowphase/narrowphase_step.js +70 -13
  42. package/src/engine/physics/queries/overlap_shape.d.ts.map +1 -1
  43. package/src/engine/physics/queries/overlap_shape.js +185 -183
  44. package/src/engine/simulation/Ticker.d.ts +14 -0
  45. package/src/engine/simulation/Ticker.d.ts.map +1 -1
  46. package/src/engine/simulation/Ticker.js +136 -1
@@ -1,352 +1,365 @@
1
- # Collision handling — ground-up construction plan
2
-
3
- Companion to DESIGN.md (base controller) and TODO.md.
4
-
5
- **Premise.** The control layer is excellent and stays. The collision
6
- layer is being **replaced wholesale** — the current
7
- `_moveAndSlide` + `_integrateVerticalAndResolveGround` + scalar
8
- ground-resolver regime is not a foundation to build on; it's scaffolding
9
- to delete once the replacement reaches parity. We keep relying on
10
- `PhysicsSystem` for all the heavy lifting (`shapeCast`, `raycast`,
11
- `overlap`, `compute_penetration`) — this plan does *more* with it, not
12
- less.
13
-
14
- Every phase is **guard-test-first**, and ordered so the controller stays
15
- shippable and green after each one. The new mover is built alongside,
16
- proven against ported + new specs, switched over, and only then is the
17
- old code deleted.
18
-
19
- ---
20
-
21
- ## 1. The seam: control produces motion, collision resolves it
22
-
23
- The clean split that makes a ground-up rebuild safe — the control layer
24
- already does its half today:
25
-
26
- ```
27
- CONTROL (sacred, unchanged) COLLISION (rebuilt)
28
- ────────────────────────── ──────────────────
29
- intent → desired velocity move(pose, shape, velocity, dt)
30
- accel curves, jump FSM, ──v──▶ → resolved position
31
- gravity/impulse, abilities, → corrected velocity
32
- mastery, posture → ground state {grounded, normal}
33
- ▲ │
34
- └────────── grounded, groundNormal ◀─────────┘
35
- (read next tick by jump FSM,
36
- gravity gating, slope logic)
37
- ```
38
-
39
- The control layer's output each tick is a **desired velocity** (it
40
- already computes this — intent+accel horizontally, gravity+jump
41
- vertically). The collision layer's *only* job is: given that velocity,
42
- the capsule pose, and the world, produce the actual resulting position,
43
- the velocity corrected for what it hit, and the ground state. It never
44
- invents motion; it only constrains it.
45
-
46
- One cleanup this forces (Phase 4): **gravity + jump-impulse application
47
- move out of the deleted collision method into a small motor helper**
48
- the base loop and abilities call. The solver stays purely about
49
- collision.
50
-
51
- ---
52
-
53
- ## 2. What we keep / replace / delete
54
-
55
- **Keep (untouched):** the whole control stack — intent, mono-exp accel,
56
- jump FSM, posture→collider sizing, abilities (Slide/WallRun/Mantle/
57
- LedgeGrab), mastery, camera composition, sensors.
58
-
59
- **Replace:** the regime that *consumes* velocity —
60
- `_moveAndSlide` (horizontal-only swept slide), the direct vertical
61
- position add, and the downward-ray scalar-Y ground snap.
62
-
63
- **Delete (Phase 4, after parity):** `_moveAndSlide`,
64
- `_integrateVerticalAndResolveGround`'s collision body, the
65
- `CAST_STEP_HEIGHT` implicit-step hack, and the scalar `groundResolver`
66
- *as the grounding authority* (it survives only as a no-physics fallback
67
- inside the new ground probe).
68
-
69
- ---
70
-
71
- ## 3. Physics capabilities we build on
72
-
73
- The P1–P6 narrowphase/query work is the enabling foundation — the
74
- rebuild is only feasible because these are now narrowphase-exact.
75
-
76
- | Capability | Status | Source |
77
- |---|---|---|
78
- | `raycast` exact surface **normal** (sphere/box/capsule/mesh/heightmap) | ✅ | refine_ray_hit P1–P3 (`6f931b4`…`4c2292c`) |
79
- | `shapeCast` true contact **normal at TOI** (MPR, not broadphase −dir) | ✅ | `shape_cast.js` normal-recovery |
80
- | `shapeCast` **start-in-contact** → `t=0` + valid normal | ✅ | `shape_cast.js:195`, `:350` |
81
- | `shapeCast` analytic slab-narrowing (long-sweep accuracy) | ✅ | `shape_cast.js:203` |
82
- | `compute_penetration(out, A,pa,ra, B,pb,rb) → depth`, `out`=unit B→A | ✅ **public** | `compute_penetration.js:138` |
83
- | `overlap(shape,pos,rot,out,off,filter)` → body-id list | ✅ | `overlap_shape.js` |
84
- | EPA adaptive tol / MPR fallback / box-box edge / capsule-tri manifold / GJK cache | ✅ | P2.2, P3.1, P3.2, P1.1c, P2.1 |
85
-
86
- Contracts the plan honours:
87
- - `shapeCast` `result.t` is a **world distance** when direction is
88
- unit-length and `tMax = sweepLength`. New casts keep that.
89
- - `compute_penetration` accepts the convex player capsule vs any
90
- shape (only concave-vs-concave throws); `out_direction` is the unit
91
- **B→A** separation, depth is the scalar return. Push the capsule (A)
92
- out along `+out_direction · depth`.
93
- - `shapeCast` `result.position` is the swept **centre**, not the
94
- contact point — we derive ground-surface Y from the cast `t` + the
95
- capsule's bottom offset, so we never need a contact point.
96
-
97
- ---
98
-
99
- ## 4. Target architecture — a dedicated `KinematicMover`
100
-
101
- Extract the collision solver into its own module (name placeholder —
102
- `KinematicMover` / `CharacterMover`), testable in isolation against a
103
- real `PhysicsSystem`, not buried in the 1500-line system file. Matches
104
- the repo's extraction taste (Spring, EyeOffsetStack, FirstPersonSensors).
105
-
106
- It knows nothing about the controller — just a capsule, a velocity, the
107
- world, and an `up` axis. Single entry point:
108
-
109
- ```
110
- move(transform, shape, velocity, dt, filter) → MoveResult
111
- // mutates transform.position; returns:
112
- // { velocityX/Y/Z (corrected), grounded, groundNormal, hit }
113
- ```
114
-
115
- Modelled on modern kinematic character controllers (Jolt
116
- `CharacterVirtual`, Source `TryPlayerMove`+`CategorizePosition`), the
117
- move is an explicit sequence — **recover → slide → (stairs) → ground →
118
- settle**:
119
-
120
- 1. **Recover (depenetration).** Before moving, get clear. `overlap()`
121
- the capsule; for each body, `compute_penetration` → push the capsule
122
- out by `depth · dir`; iterate a few times (deepest-first) to
123
- convergence. Handles spawned-in-geometry, world-crushed-into-us, and
124
- the start-solid case a pure sweep *cannot*. This is now a **core
125
- step**, available from Phase 1 — not a bolt-on — because
126
- `compute_penetration` is public.
127
-
128
- 2. **Sweep-and-slide (unified 3D).** Sweep the *full* velocity·dt
129
- (gravity folded in — no H/V split) via `shapeCast`; stop at TOI,
130
- clip velocity onto the contact tangent, project the residual, repeat
131
- up to N iterations. **Crease-aware**: a second plane re-violating the
132
- first → project onto the seam `normalize(n₁×n₂)`; a third plane in
133
- one move → dead-stop. Walls, floors, ceilings are all just contact
134
- planes, so anti-tunnelling (H and V) falls out of one loop.
135
-
136
- 3. **Stairs (explicit, optional per move).** When grounded and blocked
137
- by a low step: sweep up by `stepHeight`, sweep the residual forward,
138
- sweep back down — the Jolt/Source step pattern. Replaces the 5 cm
139
- implicit-cast hack with real stair climbing, gated so walls taller
140
- than `stepHeight` still block.
141
-
142
- 4. **Ground categorize + stick.** Short downward `shapeCast` (capsule
143
- swept down a `SKIN + band`); if it hits within the band with
144
- `normal.y ≥ MIN_WALK_NORMAL` (~0.7), set `grounded`, capture the
145
- normal, snap to the surface, and kill the into-ground velocity
146
- component. Band-test + active snap (not a strict `y ≤ testY`) is what
147
- structurally kills the landing **bounce**. Too-steep normal →
148
- not grounded → the slope was already a slide plane in step 2, so the
149
- player slides down it.
150
-
151
- 5. **Settle.** One final `compute_penetration` recover pass guarantees
152
- the tick ends penetration-free (resting-contact float drift
153
- insurance; usually a no-op).
154
-
155
- Slope handling, multi-body resting contact, and unstick all fall out of
156
- steps 1/2/4 no special cases. Abilities get correct collision for free
157
- by routing their motion through the same `move()`.
158
-
159
- ---
160
-
161
- ## 5. Phased plan (rebuild, not patch)
162
-
163
- ### Phase 1 `KinematicMover`: recover + unified sweep-and-slide
164
- Stand up the module. Implement **recover** (overlap +
165
- compute_penetration) and **unified 3D sweep-and-slide** (crease-aware).
166
- The system calls `move()` for the combined velocity·dt, replacing both
167
- `_moveAndSlide` and the direct vertical add. Grounding stays on the old
168
- resolver as a short-lived interim shim (removed in Phase 2) so this
169
- phase isolates "does the new mover move correctly."
170
-
171
- **Guard tests** (`KinematicMover.spec.js`, against a real PhysicsSystem):
172
- spawn straddling a box → recovered outside within a tick; wall stop;
173
- slide-along axis-aligned + oblique wall; **vertical** anti-tunnel (drop
174
- through a thin slab lands on top); jump into ceiling → stops; corner
175
- crease → clean stop, no leak/chatter. Port the existing
176
- `MoveAndSlide.spec.js` scenarios through the new path.
177
-
178
- **Risk:** medium. New module, but isolated; old grounding still in place.
179
-
180
- ### Phase 2 Ground categorize + slope + stick (inside the mover)
181
- Add steps 4 (categorize/stick) to the mover. Down-`shapeCast`
182
- `{grounded, normal}`; `MIN_WALK_NORMAL` gate; snap + kill into-ground
183
- velocity; reproject grounded velocity onto the slope. **Remove the
184
- interim grounding shim and the scalar resolver authority** (resolver
185
- demoted to no-physics fallback *inside* the probe, behind one function
186
- uniform flow). Feed `grounded`/`groundNormal` back to control.
187
-
188
- **Guard tests** (`GroundSlope.spec.js`): walk up a 30° ramp keeps speed,
189
- stays grounded, correct normal; stationary-on-floor stays at y≈0 across
190
- 120 ticks (the no-bounce repro, now with nothing masking it); 60° face →
191
- not grounded, slides down, not glued.
192
-
193
- **Risk:** medium. Changes grounding semantics; the bounce reconciliation
194
- lives here.
195
-
196
- ### Phase 3 Stairs ✅ **landed**
197
- Real stair climbing, gated by `stepHeight` (default 0.3 m, just under
198
- the capsule radius). Three cooperating pieces it took iterating to
199
- find that all three are needed:
200
-
201
- 1. **Explicit step-up** (`_tryStepUp`, Source/Jolt up-forward-down).
202
- When a grounded move is blocked, decide step-vs-wall with a **thin
203
- horizontal ray at the step-height plane** (`sy + stepHeight + skin`),
204
- then only if clear — lift by stepHeight, advance the residual
205
- forward, drop onto the step, commit when it gained ground within
206
- stepHeight, and **restore the horizontal velocity**. The velocity
207
- restore is the crux for REALISTIC stairs: the capsule's round bottom
208
- also rolls up low risers, but only with sustained forward momentum,
209
- and a controller that reads back the slide-clipped velocity loses
210
- that momentum at the riser and stalls. The step-up climbs without
211
- depending on momentum and hands the velocity back so the player keeps
212
- moving up the flight.
213
-
214
- The step-vs-wall ray is what stops the round bottom climbing a
215
- too-tall wall and the reason it's a THIN ray, not the obvious
216
- "lift the capsule and sweep it forward" clearance cast: lifted so its
217
- tip sits at stepHeight, the capsule's rounded bottom narrows through
218
- the band `(stepHeight, stepHeight+radius)`, so a wall whose top lands
219
- in that band is never reached by the swept shape it reads "clear"
220
- and the player climbs it, *faster speed reaching further into the
221
- round-off* (the gray-box bug: jitter at a walk, clean climb at a
222
- sprint). A thin ray has no round-off: it reads the obstacle's true
223
- height, so a step (top below the plane) is passed over and a wall
224
- (top above) is struck on its front face, at any approach speed.
225
-
226
- The ray is cast along the **blocked direction** (the horizontal
227
- velocity the slide removed = the obstacle's inward normal), from the
228
- **pre-slide** centre, reaching the swept distance plus a forward
229
- extent. Both matter for OBLIQUE approaches: at 45° the capsule meets a
230
- wall with its normal-direction extent, so a ray along *travel* stops
231
- short of the face and reads a wall as clear (climbing it); and
232
- rounding a convex corner the slide carries the *post*-slide centre
233
- just past the corner, so a ray from there shoots past the obstacle —
234
- the pre-slide centre was still in front of what blocked it. The reach
235
- tracking the sweep isn't the banned speed coupling: the climb-or-block
236
- decision is the step-height plane alone; the reach only governs how
237
- far ahead to look for what the slide already hit.
238
-
239
- A single ray still can't catch a convex *point*: grazing a corner a
240
- few degrees off its bisector, the blocked normal runs nearly parallel
241
- to a face, so the ray slips past the corner just outside the footprint
242
- and reads clear. The backstop is a post-hoc OVERLAP query after
243
- up-forward-down, if the destination overlaps geometry the climb landed
244
- the round body ON a wall corner, not on a step (a real step top is
245
- rested on `skin` above, never overlapping), so it's rejected. Probe for
246
- the common case, overlap-test to catch what a ray threads past.
247
-
248
- The same round-bottom perch is why the slide keeps every contact's true
249
- normal (no "flatten steep contacts to vertical" that would also rob
250
- a too-steep *slope* of its downhill slide) and why the footprint
251
- mount in (2) is gated on height above the **centre surface**, not the
252
- feet (gating on the feet lets a capsule that rode up a hair mount the
253
- next sliver and ratchet up a wall).
254
- 2. **Two-probe ground categorize** for honest grounded-ness on a step
255
- edge *walkability* from a centre raycast (ignores a step's convex
256
- top edge and a wall's side face, so a steep *slope* is correctly
257
- not-grounded) and *rest height* from a footprint shapecast (mounts
258
- a step the leading edge overhangs). A single probe can't tell a
259
- climbable step edge from a steep slope; the split can.
260
- 3. **Step-DOWN** = the ground-stick reach is `stepHeight`: walking off
261
- a drop stepHeight snaps onto the lower surface (stays grounded);
262
- a larger drop goes airborne.
263
-
264
- Footgun caught in the prototype: my first stair test used *deep
265
- overlapping* steps, where the round-bottom roll alone climbs and the
266
- explicit step-up looked unnecessary. Realistic *thin* treads (shallower
267
- than the capsule footprint) need the step-up see the thin-tread tests.
268
-
269
- **Guard tests** (`collision/Stairs.spec.js` + `KinematicMoverIntegration.spec.js`,
270
- all green): climb a 5-step staircase; climb a thin-tread staircase
271
- (treads < footprint) both at the mover level and through the full
272
- controller; clear a single curb; a 0.5 m riser (> stepHeight) blocks;
273
- a 0.5 m riser is a **clean wall no edge ride-up, and clearing is
274
- speed-independent** (walk and sprint both stop dead, the gray-box guard);
275
- descend a staircase grounded the whole way.
276
-
277
- ### Phase 4 Motor seam + delete old code ✅ **landed**
278
- The mover is now the **only** collision path when a `PhysicsSystem` is
279
- present — the opt-in flag is gone; dispatch is on physics presence, not
280
- a flag.
281
-
282
- What shipped:
283
- - `_integrateVerticalAndResolveGround` is now a thin dispatcher:
284
- `_applyGravity` (the motor) `_moveViaMover` (physics) **or**
285
- `_moveFlatGround` (no physics) → `_detectJumpApex`.
286
- - **Gravity** extracted to `_applyGravity`; **land / leave-ground**
287
- extracted to `_onLand` / `_onLeaveGround`, shared by both move paths
288
- (the duplicated scaffolding from Phase 1's wiring is gone).
289
- - **Deleted** `_moveAndSlide` (+ its `CAST_STEP_HEIGHT` / `SKIN`
290
- constants and the `slideRay` / `slideHit` scratch), the legacy ground-
291
- resolution body, and the `useKinematicMover` flag.
292
- - `MoveAndSlide.spec.js` deleted it tested `_moveAndSlide`; its
293
- scenarios (wall-stop, axis + oblique slide, anti-tunnel) are covered
294
- by `collision/KinematicMover.spec.js`.
295
-
296
- What stayed (deliberately):
297
- - `useBuiltInFlatGround` / `groundY` / `groundResolver` remain — they're
298
- the **no-physics** fallback (`_moveFlatGround`) for headless and
299
- control-layer unit tests, and the resolver is still spec-covered. With
300
- physics present they're unused (the mover probes the world).
301
- - **WallRun still self-integrates** its reduced-gravity model rather than
302
- routing through `move()`. Slide already routes through the mover (it
303
- calls `_integrateVerticalAndResolveGround`); WallRun's custom model is
304
- left as a future enhancement, not required for the legacy deletion.
305
-
306
- **Result:** 29 suites / 236 tests green; the entire ability + jump +
307
- momentum + posture suite passes through the consolidated path.
308
-
309
- ---
310
-
311
- ## 6. Physics requirements status
312
-
313
- **Resolved.** My one HIGH ask from the review a public penetration
314
- query is **satisfied**: `compute_penetration` is public and is exactly
315
- the right shape (unit B→A direction + scalar depth, capsule-vs-anything).
316
- It graduates from "deferred safety net" to the **core recover/settle
317
- primitive** the whole mover leans on (steps 1 & 5). Confirmed it works:
318
- yes, this is precisely what's needed — thank you for keeping it decoupled
319
- and public.
320
-
321
- **No remaining blocking requirements.** Phases 1–4 need nothing further
322
- from the physics engine. The two low-priority niceties from the review
323
- turned out not to bite:
324
- - *Contact point from `shapeCast`* not needed; ground-surface Y is
325
- derivable from the cast `t` + the capsule bottom offset.
326
- - *Multi-contact manifold from one query*covered for resting contact
327
- by iterating `compute_penetration` over **every** `overlap()` body in
328
- the recover pass; the single-sweep corner case is handled by the slide
329
- iteration instead. So no native multi-contact query is required.
330
-
331
- If anything *would* be a future nicety (not needed now): a swept query
332
- returning the *set* of TOI-simultaneous contacts (for one-pass corner
333
- creases instead of iterating). Strictly an optimisation; the iterative
334
- slide is correct without it.
335
-
336
- ---
337
-
338
- ## 7. Constants (ours reference lineage)
339
-
340
- | Constant | Plan value | Reference | Phase |
341
- |---|---|---|---|
342
- | slide iterations | 4 | Quake `numbumps` 4 | 1 |
343
- | `SKIN` | 0.005 m | Fauerby `veryCloseDistance` ~0.005 | 1 |
344
- | recover max iters | ~4 | (convergence cap) | 1 |
345
- | `MIN_WALK_NORMAL` | ~0.7 (≈45°) | Quake3 0.7 / Source `normal.z ≥ 0.7` | 2 |
346
- | ground-probe band | ~0.06 m | Source `StayOnGround` ~2 u | 2 |
347
- | crease dead-stop | zero on 3rd plane | Quake `SV_FlyMove` | 1 |
348
- | `stepHeight` | ~0.3 m (tunable) | Source `sv_stepsize` 18 u (~0.34 m) | 3 |
349
-
350
- Clip stays at effective overbounce `1.0` gated on into-wall (`v·n < 0`),
351
- relying on `SKIN` for clearance equivalent in practice to Quake3's
352
- `OVERCLIP 1.001` nudge; no change planned.
1
+ # Collision handling — ground-up construction plan
2
+
3
+ Companion to DESIGN.md (base controller) and TODO.md.
4
+
5
+ **Premise.** The control layer is excellent and stays. The collision
6
+ layer is being **replaced wholesale** — the current
7
+ `_moveAndSlide` + `_integrateVerticalAndResolveGround` + scalar
8
+ ground-resolver regime is not a foundation to build on; it's scaffolding
9
+ to delete once the replacement reaches parity. We keep relying on
10
+ `PhysicsSystem` for all the heavy lifting (`shapeCast`, `raycast`,
11
+ `overlap`, `compute_penetration`) — this plan does *more* with it, not
12
+ less.
13
+
14
+ Every phase is **guard-test-first**, and ordered so the controller stays
15
+ shippable and green after each one. The new mover is built alongside,
16
+ proven against ported + new specs, switched over, and only then is the
17
+ old code deleted.
18
+
19
+ ---
20
+
21
+ ## 1. The seam: control produces motion, collision resolves it
22
+
23
+ The clean split that makes a ground-up rebuild safe — the control layer
24
+ already does its half today:
25
+
26
+ ```
27
+ CONTROL (sacred, unchanged) COLLISION (rebuilt)
28
+ ────────────────────────── ──────────────────
29
+ intent → desired velocity move(pose, shape, velocity, dt)
30
+ accel curves, jump FSM, ──v──▶ → resolved position
31
+ gravity/impulse, abilities, → corrected velocity
32
+ mastery, posture → ground state {grounded, normal}
33
+ ▲ │
34
+ └────────── grounded, groundNormal ◀─────────┘
35
+ (read next tick by jump FSM,
36
+ gravity gating, slope logic)
37
+ ```
38
+
39
+ The control layer's output each tick is a **desired velocity** (it
40
+ already computes this — intent+accel horizontally, gravity+jump
41
+ vertically). The collision layer's *only* job is: given that velocity,
42
+ the capsule pose, and the world, produce the actual resulting position,
43
+ the velocity corrected for what it hit, and the ground state. It never
44
+ invents motion; it only constrains it.
45
+
46
+ One cleanup this forces (Phase 4): **gravity + jump-impulse application
47
+ move out of the deleted collision method into a small motor helper**
48
+ the base loop and abilities call. The solver stays purely about
49
+ collision.
50
+
51
+ ---
52
+
53
+ ## 2. What we keep / replace / delete
54
+
55
+ **Keep (untouched):** the whole control stack — intent, mono-exp accel,
56
+ jump FSM, posture→collider sizing, abilities (Slide/WallRun/Mantle/
57
+ LedgeGrab), mastery, camera composition, sensors.
58
+
59
+ **Replace:** the regime that *consumes* velocity —
60
+ `_moveAndSlide` (horizontal-only swept slide), the direct vertical
61
+ position add, and the downward-ray scalar-Y ground snap.
62
+
63
+ **Delete (Phase 4, after parity):** `_moveAndSlide`,
64
+ `_integrateVerticalAndResolveGround`'s collision body, the
65
+ `CAST_STEP_HEIGHT` implicit-step hack, and the scalar `groundResolver`
66
+ *as the grounding authority* (it survives only as a no-physics fallback
67
+ inside the new ground probe).
68
+
69
+ ---
70
+
71
+ ## 3. Physics capabilities we build on
72
+
73
+ The P1–P6 narrowphase/query work is the enabling foundation — the
74
+ rebuild is only feasible because these are now narrowphase-exact.
75
+
76
+ | Capability | Status | Source |
77
+ |---|---|---|
78
+ | `raycast` exact surface **normal** (sphere/box/capsule/mesh/heightmap) | ✅ | refine_ray_hit P1–P3 (`6f931b4`…`4c2292c`) |
79
+ | `shapeCast` true contact **normal at TOI** (MPR, not broadphase −dir) | ✅ | `shape_cast.js` normal-recovery |
80
+ | `shapeCast` **start-in-contact** → `t=0` + valid normal | ✅ | `shape_cast.js:195`, `:350` |
81
+ | `shapeCast` analytic slab-narrowing (long-sweep accuracy) | ✅ | `shape_cast.js:203` |
82
+ | `compute_penetration(out, A,pa,ra, B,pb,rb) → depth`, `out`=unit B→A | ✅ **public** | `compute_penetration.js:138` |
83
+ | `overlap(shape,pos,rot,out,off,filter)` → body-id list | ✅ | `overlap_shape.js` |
84
+ | EPA adaptive tol / MPR fallback / box-box edge / capsule-tri manifold / GJK cache | ✅ | P2.2, P3.1, P3.2, P1.1c, P2.1 |
85
+
86
+ Contracts the plan honours:
87
+ - `shapeCast` `result.t` is a **world distance** when direction is
88
+ unit-length and `tMax = sweepLength`. New casts keep that.
89
+ - `compute_penetration` accepts the convex player capsule vs any
90
+ shape (only concave-vs-concave throws); `out_direction` is the unit
91
+ **B→A** separation, depth is the scalar return. Push the capsule (A)
92
+ out along `+out_direction · depth`.
93
+ - `shapeCast` `result.position` is the swept **centre**, not the
94
+ contact point — we derive ground-surface Y from the cast `t` + the
95
+ capsule's bottom offset, so we never need a contact point.
96
+
97
+ ---
98
+
99
+ ## 4. Target architecture — a dedicated `KinematicMover`
100
+
101
+ Extract the collision solver into its own module (name placeholder —
102
+ `KinematicMover` / `CharacterMover`), testable in isolation against a
103
+ real `PhysicsSystem`, not buried in the 1500-line system file. Matches
104
+ the repo's extraction taste (Spring, EyeOffsetStack, FirstPersonSensors).
105
+
106
+ It knows nothing about the controller — just a capsule, a velocity, the
107
+ world, and an `up` axis. Single entry point:
108
+
109
+ ```
110
+ move(transform, shape, velocity, dt, filter) → MoveResult
111
+ // mutates transform.position; returns:
112
+ // { velocityX/Y/Z (corrected), grounded, groundNormal, hit }
113
+ ```
114
+
115
+ Modelled on modern kinematic character controllers (Jolt
116
+ `CharacterVirtual`, Source `TryPlayerMove`+`CategorizePosition`), the
117
+ move is an explicit sequence — **recover → slide → (stairs) → ground →
118
+ settle**:
119
+
120
+ 1. **Recover (depenetration).** Before moving, get clear. `overlap()`
121
+ the capsule; for each body, `compute_penetration` → push the capsule
122
+ out by `depth · dir`; iterate a few times (deepest-first) to
123
+ convergence. Handles spawned-in-geometry, world-crushed-into-us, and
124
+ the start-solid case a pure sweep *cannot*. This is now a **core
125
+ step**, available from Phase 1 — not a bolt-on — because
126
+ `compute_penetration` is public.
127
+
128
+ 2. **Sweep-and-slide (unified 3D).** Sweep the *full* velocity·dt
129
+ (gravity folded in — no H/V split) via `shapeCast`; stop at TOI,
130
+ clip velocity onto the contact tangent, project the residual, repeat
131
+ up to N iterations. **Crease-aware**: a second plane re-violating the
132
+ first → project onto the seam `normalize(n₁×n₂)`; a third plane in
133
+ one move → dead-stop. Walls, floors, ceilings are all just contact
134
+ planes, so anti-tunnelling (H and V) falls out of one loop.
135
+
136
+ 3. **Stairs (explicit, optional per move).** When grounded and blocked
137
+ by a low step: sweep up by `stepHeight`, sweep the residual forward,
138
+ sweep back down — the Jolt/Source step pattern. Replaces the 5 cm
139
+ implicit-cast hack with real stair climbing, gated so walls taller
140
+ than `stepHeight` still block.
141
+
142
+ 4. **Ground categorize + stick.** Short downward `shapeCast` (capsule
143
+ swept down a `SKIN + band`); if it hits within the band with
144
+ `normal.y ≥ MIN_WALK_NORMAL` (~0.7), set `grounded`, capture the
145
+ normal, snap to the surface, and kill the into-ground velocity
146
+ component. Band-test + active snap (not a strict `y ≤ testY`) is what
147
+ structurally kills the landing **bounce**. Too-steep normal →
148
+ not grounded → the slope was already a slide plane in step 2, so the
149
+ player slides down it.
150
+
151
+ 5. **Settle.** One final `compute_penetration` recover pass guarantees
152
+ the tick ends penetration-free (resting-contact float drift
153
+ insurance; usually a no-op).
154
+
155
+ **No-slip walkable (one gate, not two).** Between recover and slide,
156
+ when the feet rest on walkable ground (`_groundedAt` a walkable normal
157
+ within the stick band, the *same* `MIN_WALK_NORMAL` as step 4), the
158
+ downward **gravity** velocity is dropped before the slide. Otherwise
159
+ gravity projects onto a ramp's tangent as a downhill **drift** and a
160
+ standing player creeps down every slope it can walk. A biped grips any
161
+ slope it could walk up; the instant its feet slip it could not have
162
+ climbed either — so *"doesn't slide down"* and *"can be walked up"* are
163
+ the same threshold, not two independent tunables. A too-steep face fails
164
+ the gate, keeps its gravity, and slides (step 4) — purchase and no-slip
165
+ appear and vanish together as the one knob moves. A jump (`v.y > 0`) is
166
+ exempt so the launch is never cancelled.
167
+
168
+ Slope handling, multi-body resting contact, and unstick all fall out of
169
+ steps 1/2/4 no special cases. Abilities get correct collision for free
170
+ by routing their motion through the same `move()`.
171
+
172
+ ---
173
+
174
+ ## 5. Phased plan (rebuild, not patch)
175
+
176
+ ### Phase 1 — `KinematicMover`: recover + unified sweep-and-slide ✅ **landed**
177
+ Stand up the module. Implement **recover** (overlap +
178
+ compute_penetration) and **unified 3D sweep-and-slide** (crease-aware).
179
+ The system calls `move()` for the combined velocity·dt, replacing both
180
+ `_moveAndSlide` and the direct vertical add. Grounding stays on the old
181
+ resolver as a short-lived interim shim (removed in Phase 2) so this
182
+ phase isolates "does the new mover move correctly."
183
+
184
+ **Guard tests** (`KinematicMover.spec.js`, against a real PhysicsSystem):
185
+ spawn straddling a box recovered outside within a tick; wall stop;
186
+ slide-along axis-aligned + oblique wall; **vertical** anti-tunnel (drop
187
+ through a thin slab → lands on top); jump into ceiling → stops; corner
188
+ crease clean stop, no leak/chatter. Port the existing
189
+ `MoveAndSlide.spec.js` scenarios through the new path.
190
+
191
+ **Risk:** medium. New module, but isolated; old grounding still in place.
192
+
193
+ ### Phase 2 Ground categorize + slope + stick (inside the mover) ✅ **landed**
194
+ Add steps 4 (categorize/stick) to the mover. Down-`shapeCast` →
195
+ `{grounded, normal}`; `MIN_WALK_NORMAL` gate; snap + kill into-ground
196
+ velocity; reproject grounded velocity onto the slope. **Remove the
197
+ interim grounding shim and the scalar resolver authority** (resolver
198
+ demoted to no-physics fallback *inside* the probe, behind one function
199
+ uniform flow). Feed `grounded`/`groundNormal` back to control.
200
+
201
+ **Guard tests** (`GroundSlope.spec.js`): walk up a 30° ramp keeps speed,
202
+ stays grounded, correct normal; stationary-on-floor stays at y≈0 across
203
+ 120 ticks (the no-bounce repro, now with nothing masking it); 60° face →
204
+ not grounded, slides down, not glued.
205
+
206
+ **Risk:** medium. Changes grounding semantics; the bounce reconciliation
207
+ lives here.
208
+
209
+ ### Phase 3 Stairs ✅ **landed**
210
+ Real stair climbing, gated by `stepHeight` (default 0.3 m, just under
211
+ the capsule radius). Three cooperating pieces it took iterating to
212
+ find that all three are needed:
213
+
214
+ 1. **Explicit step-up** (`_tryStepUp`, Source/Jolt up-forward-down).
215
+ When a grounded move is blocked, decide step-vs-wall with a **thin
216
+ horizontal ray at the step-height plane** (`sy + stepHeight + skin`),
217
+ then only if clear lift by stepHeight, advance the residual
218
+ forward, drop onto the step, commit when it gained ground within
219
+ stepHeight, and **restore the horizontal velocity**. The velocity
220
+ restore is the crux for REALISTIC stairs: the capsule's round bottom
221
+ also rolls up low risers, but only with sustained forward momentum,
222
+ and a controller that reads back the slide-clipped velocity loses
223
+ that momentum at the riser and stalls. The step-up climbs without
224
+ depending on momentum and hands the velocity back so the player keeps
225
+ moving up the flight.
226
+
227
+ The step-vs-wall ray is what stops the round bottom climbing a
228
+ too-tall wall and the reason it's a THIN ray, not the obvious
229
+ "lift the capsule and sweep it forward" clearance cast: lifted so its
230
+ tip sits at stepHeight, the capsule's rounded bottom narrows through
231
+ the band `(stepHeight, stepHeight+radius)`, so a wall whose top lands
232
+ in that band is never reached by the swept shape — it reads "clear"
233
+ and the player climbs it, *faster speed reaching further into the
234
+ round-off* (the gray-box bug: jitter at a walk, clean climb at a
235
+ sprint). A thin ray has no round-off: it reads the obstacle's true
236
+ height, so a step (top below the plane) is passed over and a wall
237
+ (top above) is struck on its front face, at any approach speed.
238
+
239
+ The ray is cast along the **blocked direction** (the horizontal
240
+ velocity the slide removed = the obstacle's inward normal), from the
241
+ **pre-slide** centre, reaching the swept distance plus a forward
242
+ extent. Both matter for OBLIQUE approaches: at 45° the capsule meets a
243
+ wall with its normal-direction extent, so a ray along *travel* stops
244
+ short of the face and reads a wall as clear (climbing it); and
245
+ rounding a convex corner the slide carries the *post*-slide centre
246
+ just past the corner, so a ray from there shoots past the obstacle —
247
+ the pre-slide centre was still in front of what blocked it. The reach
248
+ tracking the sweep isn't the banned speed coupling: the climb-or-block
249
+ decision is the step-height plane alone; the reach only governs how
250
+ far ahead to look for what the slide already hit.
251
+
252
+ A single ray still can't catch a convex *point*: grazing a corner a
253
+ few degrees off its bisector, the blocked normal runs nearly parallel
254
+ to a face, so the ray slips past the corner just outside the footprint
255
+ and reads clear. The backstop is a post-hoc OVERLAP query after
256
+ up-forward-down, if the destination overlaps geometry the climb landed
257
+ the round body ON a wall corner, not on a step (a real step top is
258
+ rested on `skin` above, never overlapping), so it's rejected. Probe for
259
+ the common case, overlap-test to catch what a ray threads past.
260
+
261
+ The same round-bottom perch is why the slide keeps every contact's true
262
+ normal (no "flatten steep contacts to vertical" — that would also rob
263
+ a too-steep *slope* of its downhill slide) and why the footprint
264
+ mount in (2) is gated on height above the **centre surface**, not the
265
+ feet (gating on the feet lets a capsule that rode up a hair mount the
266
+ next sliver and ratchet up a wall).
267
+ 2. **Two-probe ground categorize** for honest grounded-ness on a step
268
+ edge — *walkability* from a centre raycast (ignores a step's convex
269
+ top edge and a wall's side face, so a steep *slope* is correctly
270
+ not-grounded) and *rest height* from a footprint shapecast (mounts
271
+ a step the leading edge overhangs). A single probe can't tell a
272
+ climbable step edge from a steep slope; the split can.
273
+ 3. **Step-DOWN** = the ground-stick reach is `stepHeight`: walking off
274
+ a drop stepHeight snaps onto the lower surface (stays grounded);
275
+ a larger drop goes airborne.
276
+
277
+ Footgun caught in the prototype: my first stair test used *deep
278
+ overlapping* steps, where the round-bottom roll alone climbs and the
279
+ explicit step-up looked unnecessary. Realistic *thin* treads (shallower
280
+ than the capsule footprint) need the step-up — see the thin-tread tests.
281
+
282
+ **Guard tests** (`collision/Stairs.spec.js` + `KinematicMoverIntegration.spec.js`,
283
+ all green): climb a 5-step staircase; climb a thin-tread staircase
284
+ (treads < footprint) both at the mover level and through the full
285
+ controller; clear a single curb; a 0.5 m riser (> stepHeight) blocks;
286
+ a 0.5 m riser is a **clean wall — no edge ride-up, and clearing is
287
+ speed-independent** (walk and sprint both stop dead, the gray-box guard);
288
+ descend a staircase grounded the whole way.
289
+
290
+ ### Phase 4 Motor seam + delete old code ✅ **landed**
291
+ The mover is now the **only** collision path when a `PhysicsSystem` is
292
+ present — the opt-in flag is gone; dispatch is on physics presence, not
293
+ a flag.
294
+
295
+ What shipped:
296
+ - `_integrateVerticalAndResolveGround` is now a thin dispatcher:
297
+ `_applyGravity` (the motor) `_moveViaMover` (physics) **or**
298
+ `_moveFlatGround` (no physics) `_detectJumpApex`.
299
+ - **Gravity** extracted to `_applyGravity`; **land / leave-ground**
300
+ extracted to `_onLand` / `_onLeaveGround`, shared by both move paths
301
+ (the duplicated scaffolding from Phase 1's wiring is gone).
302
+ - **Deleted** `_moveAndSlide` (+ its `CAST_STEP_HEIGHT` / `SKIN`
303
+ constants and the `slideRay` / `slideHit` scratch), the legacy ground-
304
+ resolution body, and the `useKinematicMover` flag.
305
+ - `MoveAndSlide.spec.js` deleted — it tested `_moveAndSlide`; its
306
+ scenarios (wall-stop, axis + oblique slide, anti-tunnel) are covered
307
+ by `collision/KinematicMover.spec.js`.
308
+
309
+ What stayed (deliberately):
310
+ - `useBuiltInFlatGround` / `groundY` / `groundResolver` remain — they're
311
+ the **no-physics** fallback (`_moveFlatGround`) for headless and
312
+ control-layer unit tests, and the resolver is still spec-covered. With
313
+ physics present they're unused (the mover probes the world).
314
+ - **WallRun still self-integrates** its reduced-gravity model rather than
315
+ routing through `move()`. Slide already routes through the mover (it
316
+ calls `_integrateVerticalAndResolveGround`); WallRun's custom model is
317
+ left as a future enhancement, not required for the legacy deletion.
318
+
319
+ **Result:** 29 suites / 236 tests green; the entire ability + jump +
320
+ momentum + posture suite passes through the consolidated path.
321
+
322
+ ---
323
+
324
+ ## 6. Physics requirementsstatus
325
+
326
+ **Resolved.** My one HIGH ask from the reviewa public penetration
327
+ query is **satisfied**: `compute_penetration` is public and is exactly
328
+ the right shape (unit B→A direction + scalar depth, capsule-vs-anything).
329
+ It graduates from "deferred safety net" to the **core recover/settle
330
+ primitive** the whole mover leans on (steps 1 & 5). Confirmed it works:
331
+ yes, this is precisely what's needed thank you for keeping it decoupled
332
+ and public.
333
+
334
+ **No remaining blocking requirements.** Phases 1–4 need nothing further
335
+ from the physics engine. The two low-priority niceties from the review
336
+ turned out not to bite:
337
+ - *Contact point from `shapeCast`* — not needed; ground-surface Y is
338
+ derivable from the cast `t` + the capsule bottom offset.
339
+ - *Multi-contact manifold from one query* — covered for resting contact
340
+ by iterating `compute_penetration` over **every** `overlap()` body in
341
+ the recover pass; the single-sweep corner case is handled by the slide
342
+ iteration instead. So no native multi-contact query is required.
343
+
344
+ If anything *would* be a future nicety (not needed now): a swept query
345
+ returning the *set* of TOI-simultaneous contacts (for one-pass corner
346
+ creases instead of iterating). Strictly an optimisation; the iterative
347
+ slide is correct without it.
348
+
349
+ ---
350
+
351
+ ## 7. Constants (ours reference lineage)
352
+
353
+ | Constant | Plan value | Reference | Phase |
354
+ |---|---|---|---|
355
+ | slide iterations | 4 | Quake `numbumps` 4 | 1 |
356
+ | `SKIN` | 0.005 m | Fauerby `veryCloseDistance` ~0.005 | 1 |
357
+ | recover max iters | ~4 | — (convergence cap) | 1 |
358
+ | `MIN_WALK_NORMAL` | ~0.7 (≈45°) | Quake3 0.7 / Source `normal.z ≥ 0.7` | 2 |
359
+ | ground-probe band | ~0.06 m | Source `StayOnGround` ~2 u | 2 |
360
+ | crease dead-stop | zero on 3rd plane | Quake `SV_FlyMove` | 1 |
361
+ | `stepHeight` | ~0.3 m (tunable) | Source `sv_stepsize` 18 u (~0.34 m) | 3 |
362
+
363
+ Clip stays at effective overbounce `1.0` gated on into-wall (`v·n < 0`),
364
+ relying on `SKIN` for clearance — equivalent in practice to Quake3's
365
+ `OVERCLIP 1.001` nudge; no change planned.