# Deep Technical Review: meep physics vs. Rapier / Parry

A focused, side-by-side comparison of the in-house deterministic JS rigid-body
engine in `engine/physics/` against [Rapier](https://github.com/dimforge/rapier)
(Rust dynamics) and its narrowphase library
[Parry](https://github.com/dimforge/parry). The aim is to surface correctness
gaps, numerical issues, and structural divergences worth investing in — not to
recommend a rewrite. Pure-JS, single-threaded and no-SAB are deliberate
constraints (see `PLAN.md`) and are not listed as gaps.

Repo paths in this document are relative to
`engine/physics/` for the meep side, and relative to the upstream repo
(`rapier/src/...`, `parry/src/...`) for the Rapier side.

---

## 1. Overall architecture

### 1.1 Pipeline shape

Both engines run the same canonical fixed-step pipeline. The difference is
mostly granularity and where the boundaries fall:

| Stage | meep `PhysicsSystem.fixedUpdate` | Rapier (per substep inside `VelocitySolver::solve_constraints`) |
|---|---|---|
| Integrate velocities (forces + gravity) | semi-implicit Euler, `integrate_velocity.js` | per-substep velocity increment (`solver_vels_increment`) |
| Refit broadphase (fat AABB) | `compute_fat_world_aabb.js` per awake-body leaf | `BroadPhaseBvh::update`, change-tracked subtree refit |
| Broadphase pair generation | per-leaf query against static + dynamic BVH, dedup via touched flag | single-tree `BroadPhaseBvh` with pair hashmap |
| Wake propagation | `__wake_pairs` walks pair list, wakes sleepers | island-manager sleep root traversal |
| Narrowphase | type-switch dispatcher in `narrowphase_step.js` | dispatch into specialised `contact_manifolds_*` per pair type |
| Island build | union-find, deterministic CSR layout, separates static/dynamic | DFS active-list propagation, per-island sleep |
| Solver | per-island PGS+Baumgarte, 10 velocity iters | per-island PGS-S (TGS substepping) + bias/no-bias passes; warm-start |
| Integrate position | semi-implicit, post-solve | between substeps |
| Sleep test | per-island atomic, threshold on `\|v\|² + \|ω\|²` | per-island energy threshold, sleep-root traversal |
| Event diff | `diff_manifolds.js` → Begin/Stay/End | identical model via `ContactEventBuffer` |

The high-level shape is essentially identical. Where meep is closest to
Rapier is **broadphase refit + pair generation + island building +
sleep + event dispatch**. Where meep is structurally distinct is **solver
architecture** (no TGS / split-impulse), **CCD** (speculative margin only),
and **closed-form narrowphase coverage** (a fraction of Parry's pair
generators).

### 1.2 Data layout

Both engines use SoA arena storage with generation-tagged stable IDs:

- meep: `BodyStorage.js` — `Int32Array __entities`, `Uint8Array __generations`,
  packed `(index << 8) | gen`, min-heap free-list (deterministic reuse), dense
  awake list + reverse map.
- Rapier: `RigidBodySet` is an `Arena<RigidBody>` (the `Arena` is generic and
  carries `(index, generation)` handles); `ColliderSet` likewise. The arena's
  free-slot picking is also LIFO/heap-style for deterministic replay.

**These are structurally the same design.** The differences are:

- meep packs `(index, gen)` into a single u32 with an 8-bit generation; Rapier
  uses a 64-bit `(u32, u32)` pair. Our wrap-mod-256 generation is fine for
  games (256 alloc/free cycles before ABA risk on a slot) but is much tighter
  than Rapier's.
- meep maintains a **separate dense awake list with reverse map** so the hot
  loop iterates the active subset directly. Rapier achieves the same via the
  `IslandManager`'s `awake_islands` list of island ids — different layer of
  abstraction, same end result.
- meep stores per-body component pointers in a sparse `__bodies` array
  (`PhysicsSystem.js:201`) by body index. Rapier returns `RigidBody` refs
  directly out of the arena. Equivalent.

### 1.3 Broadphase

meep currently runs **two BVHs (static + dynamic)** with fat-AABB refit,
mirroring Jolt's separation. Rapier was previously `BroadPhaseMultiSap` but
is now (current master) `BroadPhaseBvh` — a **single unified BVH** with
change-tracked subtree refits:

```rust
pub struct BroadPhaseBvh {
    pub(crate) tree: Bvh,
    workspace: BvhWorkspace,
    pairs: HashMap<(ColliderHandle, ColliderHandle), u32>,
    frame_index: u32,
    optimization_strategy: BvhOptimizationStrategy,
}
```

The directional shift is interesting: **our two-tree split is what Rapier
moved away from** when it converged on BVH. Rapier's single-tree approach
relies on `collider.needs_broad_phase_update()` to skip static leaves at
refit time, which is roughly equivalent to our static-tree never being
refitted at all. The two trees, however, give us tighter static-side
queries (the dynamic tree is small even on huge scenes, so the
dynamic↔static query side of `generate_pairs.js:82-99` walks a balanced
shallow tree). The trade-off vs. Rapier:

- **Memory:** marginally higher for us — two BVH roots, two free-lists.
- **Refit cost:** lower for us — we never touch static leaves.
- **Query cost:** ~same; we pay one extra root-test per query, Rapier pays
  for static leaf early-outs.
- **Hot-collider streaming (LOD-load / world streaming):** they're equivalent
  — both must insert/remove leaves on the active tree.

Our pair de-dup goes through `ManifoldStore.is_touched(slot)` in
`generate_pairs.js:76,94`. Rapier uses the `HashMap<(handle, handle), u32>`
directly. Functionally equivalent; ours is allocation-free in steady state
(open-addressed `PairUint32Map`).

### 1.4 Island structure

Two genuinely different designs:

- **meep** (`IslandBuilder.js`) does **union-find with path halving + union
  by min-index**, then writes a CSR-laid-out `(body_offsets, body_data,
  contact_offsets, contact_data)` per frame. Deterministic and re-buildable
  from scratch each step.
- **Rapier** (`island_manager/manager.rs`) uses **incremental DFS with
  sleep-root traversal**. Islands persist across frames; only sleep-state
  transitions trigger re-traversal, with a `MAX_PER_FRAME_COST` budget to
  avoid frame spikes.

The Rapier approach is fundamentally laziness-driven: it pays cost only
when a body changes sleep state. The meep approach pays a small union-find
cost every frame.

For our workload (many mostly-sleeping bodies + a small active set), our
rebuild-from-scratch approach is fine because we only iterate awake
bodies + touched contacts. The union-find pass is `O((|awake_bodies| +
|touched_contacts|) · α(n))`, dominated by the awake count which is small
by design. **The deterministic CSR output is a meep-side win** — Rapier
sacrifices output ordering predictability for incrementality, which makes
it harder to do bit-exact replays of solver behaviour across two
instances.

### 1.5 Solver architecture

This is the largest structural divergence.

- **meep**: PGS in `solver/solve_contacts.js`, single velocity-iteration loop,
  warm-start, Baumgarte position-correction folded into the velocity solve
  via `bias_n` (`solve_contacts.js:444-449`), Coulomb friction with disk
  clamp.
- **Rapier**: PGS-S (TGS substepping) in `velocity_solver.rs`. Two-phase
  per substep:
  1. `num_internal_pgs_iterations` of `contact_constraints.solve(...)` —
     **with bias** (`rhs` includes restitution + position-correction).
  2. `num_internal_stabilization_iterations` of
     `contact_constraints.solve_wo_bias(...)` — pure non-penetration,
     stabilising the bias-pumped velocity.
  Between substeps Rapier calls `integrate_positions(...)`. The constraint
  element (`contact_constraint_element.rs`) carries **two impulse fields:
  `impulse` (current substep) and `impulse_accumulator` (sum across substeps)**
  and **two RHS fields: `rhs` and `rhs_wo_bias`**. That's the split-impulse
  + TGS architecture that PLAN.md notes we tried and rolled back.

The relevant Rapier snippet (paraphrased from `contact_constraint_element.rs`):

```rust
// solve() at iteration k:
let dvel = dir1·v1 + ... + self.rhs;
let new_impulse = cfm * (self.impulse - r * dvel).max(0);
self.impulse = new_impulse;
// apply lambda = new_impulse - prev_impulse
```

```rust
// solve_wo_bias() — same arithmetic but with self.rhs replaced by self.rhs_wo_bias.
// Position-bias contribution is excluded so the iterates only enforce vn ≥ 0.
```

The crucial property is that **restitution is encoded once into `rhs` at
constraint build time and `rhs_wo_bias` does not include it**, so the
post-bias stabilisation pass cannot subtract a previously-applied
restitution impulse. That's the fix for PLAN.md's "restitution × warm-start
clamp" failure mode.

### 1.6 Sleep system

Both have **atomic per-island sleep**. The mechanics differ:

- **meep** (`PhysicsSystem.__sleep_test` at `PhysicsSystem.js:942`): walks
  islands once per step; if max `|v|² + |ω|²` is below threshold for the
  whole island and the lowest-stabilising member's sleep_timer crosses the
  time threshold, threads members into a circular doubly-linked list
  (`sleep_group_next/prev`), zeroes velocities, and removes from the awake
  set. Atomic wake walks the chain.
- **Rapier**: per-body kinetic energy (`activation.update_energy()`) drives
  a sleep_root candidate flag; the IslandManager extracts sleeping islands
  by graph traversal from those roots, time-sliced via `MAX_PER_FRAME_COST`.
  Joint and contact connections re-awaken bodies.

Functionally similar — both wake all members in one frame when any member
is disturbed. The meep approach is simpler and runs every frame; Rapier's
is lazier and budget-aware.

### 1.7 Threading

Rapier offers per-island parallel solve via Rayon when the `parallel`
feature is enabled. meep is deliberately single-threaded (no SAB, no
workers). The island CSR layout in meep would support a worker-based
solver but isn't wired up. **Not a gap** — explicit out-of-scope decision.

---

## 2. Specific algorithms and trade-offs

### 2.1 Broadphase

| Aspect | meep | Rapier |
|---|---|---|
| Structure | Two BVHs (static + dynamic), Jolt-style | One BVH (`BroadPhaseBvh`), change-tracked |
| Refit | Per awake-body fat AABB (`compute_fat_world_aabb.js:34`) | `collider.needs_broad_phase_update()` then subtree refit |
| Pair output | `PairList` canonical `(min,max)` | `HashMap<(handle,handle), u32>` |
| Dedup | `ManifoldStore.is_touched` (one slot per pair) | hashmap key uniqueness + per-frame frame_index |
| Velocity-pad | `FAT_LINEAR + FAT_VELOCITY_MULTIPLIER * speed * dt` (2 s look-ahead) | configurable; default similar (multiple of step) |

Rapier's optimisation strategy (`SubtreeOptimizer`) does incremental subtree
re-balancing each frame to amortise the cost of trees becoming
unbalanced as bodies move. We don't have an equivalent — our BVH's quality
degrades slowly under sustained motion patterns. Worth investigating for
the awake set, less so for the static tree which we never touch.

### 2.2 Narrowphase dispatch and contact generators

Parry's narrowphase is a **shape-type-pair dispatch table** with about
**18 contact-manifold generators**, each a closed-form algorithm for a
specific shape pair. meep's dispatcher in `narrowphase_step.js:192-353`
covers **five closed-form pairs** plus GJK+EPA fallback for everything else.

Side-by-side coverage:

| Pair | meep | Parry equivalent |
|---|---|---|
| sphere-sphere | ✅ `sphere_sphere_contact.js` | `contact_manifolds_ball_ball.rs` |
| sphere-box (cuboid) | ✅ `sphere_box_contact.js` | `contact_manifolds_convex_ball.rs` (general) |
| capsule-sphere | ✅ in `capsule_contacts.js` | `contact_manifolds_convex_ball.rs` |
| capsule-capsule | ✅ in `capsule_contacts.js` | `contact_manifolds_capsule_capsule.rs` |
| capsule-box | ✅ multi-contact in `capsule_contacts.js` | `contact_manifolds_cuboid_capsule.rs` |
| box-box | ✅ SAT + clipping `box_box_manifold.js` | `contact_manifolds_cuboid_cuboid.rs` |
| sphere-triangle (heightmap/mesh) | ❌ GJK+EPA per triangle | `contact_manifolds_trimesh_shape.rs` dispatches to closed-form |
| box-triangle | ❌ GJK+EPA per triangle | ✅ `contact_manifolds_cuboid_triangle.rs` |
| capsule-triangle | ❌ GJK+EPA per triangle | dispatched via convex-vs-trimesh; closed-form exists |
| half-space (plane) vs convex | ❌ (no plane shape) | ✅ `contact_manifolds_halfspace_pfm.rs` |
| heightfield | per-triangle GJK+EPA via `heightmap_enumerate_triangles` | `contact_manifolds_heightfield_shape.rs` |
| convex-convex generic | GJK+EPA | `contact_manifolds_pfm_pfm.rs` (polyhedral-feature-mapping) |

**The big gap is closed-form primitive-vs-triangle.** `PLAN.md` is correct
that this is the biggest accuracy gap, and Parry's `contact_manifolds_cuboid_triangle.rs`
is the direct port target. Its algorithm is:

1. SAT over cuboid faces (3 axes), triangle plane (1 axis), and cuboid×edge
   edge crosses (9 axes in 3D).
2. Build `PolygonalFeature` from the cuboid's support face and the triangle.
3. Clip features via `PolygonalFeature::contacts(...)` to produce up to 4
   contacts.

Crucially Parry's algorithm assigns **feature IDs to each clip output** so
contact persistence across frames is robust. Our per-triangle GJK+EPA has
no feature-id story at all (see §2.5).

### 2.3 GJK and EPA

**meep GJK** (`gjk/gjk.js`): Kevin Moran reference implementation, no
Voronoi simplex — direct case-analysis of triangle / tetrahedron
sub-regions; 64-iter cap; **no frame-coherent simplex caching**. The
recently-fixed simplex-shift bugs around `gjk.js:214-264` (clear comments
in source) suggest this code was carrying subtle correctness issues until
recently.

**Parry GJK** (`parry/src/query/gjk/gjk.rs`): **Voronoi simplex**
(`VoronoiSimplex`) — explicit projected-origin-and-reduce step. Frame
coherence via simplex passed in by caller (so callers can persist it
between frames; Parry persists in `ContactManifoldsWorkspace`).

The Voronoi simplex is the technically-correct GJK formulation — every
simplex update projects the origin onto the simplex once and reduces
based on Voronoi regions, rather than testing each potential reduction
edge/face manually. The trade-off in our case is small for static-shape
pairs (boxes don't have many support directions), but meaningful for
mesh / smooth shapes where the reduction path matters.

**meep EPA** (`gjk/expanding_polytope_algorithm.js`): up to 64 faces, 32
loose edges, 64 iterations; degeneration is silently swallowed and the
best-so-far face is returned. The `debugger;` statement at line 136 is a
leftover that should be removed.

**Parry EPA** (`parry/src/query/epa/epa3.rs`): adaptive `eps_tol` scaled
by `max(scale_of_coordinates, 1.0)` to prevent spurious failures on
large-coordinate geometry (fixes parry issue #415). Hard 100-iter cap.
Both stagnation- and progress-based termination criteria.

The **scale-adaptive tolerance** is a concrete, copyable improvement. Our
`EPA_TOLERANCE = 0.0001` (line 10) is an absolute threshold — on a
character-scale shape it's fine but on a kilometre-scale heightmap
collision it's well below the practical noise floor and may cause early
"convergence" on a degenerate face.

### 2.4 MPR

Both engines have MPR (`gjk/mpr.js` vs `parry/src/query/contact/`/various).
Parry uses MPR primarily for **support-map vs support-map proximity tests**
where GJK is overkill. Our `mpr.js` is used only by `shape_cast.js:364` for
the normal-recovery probe — it's **not wired into `narrowphase_step.js`** as
a fallback for non-convergent EPA on smooth shapes (PLAN.md notes this as
its intended role). That's a low-risk swap: per-pair config to prefer MPR
for shapes whose support function is smooth (sphere, capsule, possibly
the unwired cylinder), or as a fallback when EPA returns a non-positive
depth.

### 2.5 Manifold caching and contact persistence

This is where meep and Parry diverge most subtly.

- **meep** (`contact/ManifoldStore.js`):
  - Slot keyed by `(idA, idB)` via `PairUint32Map`.
  - Per-slot meta: count, touched bit, prev_touched bit, grace counter.
  - Each step `acquire(idA, idB)` flips touched=1. `advance_frame` rolls
    touched → prev_touched.
  - Contacts overwritten each frame (`clear_contacts(slot)` at
    `narrowphase_step.js:687`); only `j_n`, `j_t1`, `j_t2` (warm-start
    impulses) survive into the next frame.
  - **There is no per-contact feature-id matching.** Warm-start impulses
    are written by index `(slot, k)` — if the narrowphase emits contacts
    in a different order this frame vs. last frame (which can absolutely
    happen for a rotating box-on-box), the warm-start impulses are applied
    to the **wrong contact points**.

- **Parry** (`contact_manifold.rs`):
  ```rust
  pub struct TrackedContact<Data> {
      pub local_p1: Vector,
      pub local_p2: Vector,
      pub dist: Real,
      pub fid1: PackedFeatureId,  // feature id on shape 1
      pub fid2: PackedFeatureId,  // feature id on shape 2
      pub data: Data,             // user data (warm-start impulses)
  }
  ```
  Each per-pair contact generator (cuboid-cuboid, cuboid-triangle, etc.)
  produces contacts with **stable feature IDs**: e.g. `face(2) × face(5)`,
  `edge(0,1) × vertex(3)`, etc. `match_contacts()` then transfers the
  user data (impulses) onto the corresponding contact in the new
  manifold by feature-ID equality.

**This is a real correctness bug in our solver.** The warm-start cache
arithmetic is fine in isolation, but in the rotating-box-on-box case the
reduction pass in `box_box_manifold.js:498-530` and the candidate
reduction in `narrowphase_step.js:149-178` produce contact orderings that
can re-shuffle between frames. The solver then warm-starts contact[0]
with impulse from a different physical contact, smearing across the
manifold and producing the kind of low-amplitude jitter that's commonly
attributed to "PGS being lossy" but is actually warm-start mis-aim.

There are two ways to fix:

1. **Add feature-id columns to the manifold stride**, populate from
   `box_box_manifold` (the reference + incident face indices + Sutherland-
   Hodgman edge index already define a feature ID), and match by
   feature ID when assigning impulses. Parry's `match_contacts` is the
   reference. Stride goes from 13 → 15 floats. Needs cooperation from
   each closed-form pair generator.
2. **Fallback: match by spatial proximity** like Parry's
   `match_contacts_using_positions`. Cheaper to implement (just a 4×4
   distance matrix at solver pre-step) but loses precision when contacts
   are within the matching threshold of each other.

This is the single most impactful **correctness** fix in this review.

### 2.6 Solver: PGS vs PGS-S, Baumgarte vs split-impulse

Rapier carries **two impulses and two RHS values** per constraint element:

```rust
pub struct ContactConstraintNormalPart<N> {
    torque_dir1, torque_dir2, ii_torque_dir1, ii_torque_dir2,
    rhs,             // restitution + position bias
    rhs_wo_bias,     // pure non-penetration
    impulse,         // this substep's accumulator
    impulse_accumulator,  // cumulative across substeps
    r, r_mat_elts,
}
```

Inside a substep:

```
for _ in 0..num_internal_pgs_iterations:
    contact_constraints.solve(...)        // uses rhs
for _ in 0..num_internal_stabilization_iterations:
    contact_constraints.solve_wo_bias(...) // uses rhs_wo_bias
```

The two-phase scheme is exactly what defuses the failure modes PLAN.md
documents (restitution × warm-start, Baumgarte K× amplification):

- Restitution is part of `rhs`, applied once during the bias phase. The
  no-bias stabilisation pass operates on `rhs_wo_bias` so the
  restitution impulse cannot be subtracted away by a later iteration
  seeing "separating velocity".
- Position correction is part of `rhs`, applied during the bias phase, and
  the no-bias pass cleans up the post-bias velocity contamination.
- Forces are integrated once per substep into `solver_vels_increment`
  outside the iteration loop, so the accumulator-timing issue doesn't
  arise (each substep gets the right share of the force).

Our `solver/solve_contacts.js:531` does:

```js
const lambda_n = -m_eff_n * (vn + bias_n);
const sum_n = j_n_accum + lambda_n;
const new_j_n = sum_n > 0 ? sum_n : 0;  // CLAMP
```

with `bias_n` blending Baumgarte position correction and restitution
(`solve_contacts.js:442-457`). On the very next iteration the same
contact sees a different `vn`, recomputes the full `(vn + bias_n)` target,
and can subtract whatever the previous iteration added. Restitution is
particularly fragile because it's a one-shot impulse that should be
applied during the contact's *first* solver pass and never adjusted
again, but our scheme keeps re-targeting it.

**This is the architectural lock that prevents TGS**, as `PLAN.md` documents.
The minimum fix is to **split `rhs` into `rhs_pos` and `rhs_restitution` +
`rhs_velocity_only`**, apply the position+restitution bias only on a
single dedicated iteration (or set of iterations), and then run the rest
of the solver on the velocity-only target. Even without TGS substepping,
this would defuse the warm-start × restitution interaction documented in
`solve_contacts.js:452-456`.

### 2.7 CCD

Rapier has full CCD (`ccd/ccd_solver.rs`):

```rust
pub struct CCDSolver;   // stateless
```

with three phases:
1. `update_ccd_active_flags()` based on velocity threshold.
2. `predict_impacts_at_next_positions()` — sweeps a body's collider
   against the broadphase, finds first TOI.
3. `clamp_motions()` — clamps motion to stop just before impact.

We have speculative-margin-via-fat-AABB only. PLAN.md flags this as a
backlog item; the falling-tower bench is the concrete failure case (180
of 1000 bodies tunnel through a 1 cm floor on a 1 km drop).

The shape-cast machinery already in `queries/shape_cast.js` provides the
TOI primitive. The missing piece is the per-body opt-in CCD path that
runs after broadphase but before solver: identify high-speed bodies
(`|v| · dt > min_extent`), sweep their colliders, clamp dt accordingly.
This is conceptually parallel to `__wake_pairs` — a stage between
broadphase and narrowphase.

### 2.8 Concave / triangle mesh

Both engines decompose a non-convex shape into triangles overlapping the
convex's AABB, then run a per-triangle algorithm:

- **meep** (`narrowphase_step.js:368-577`): generic per-triangle GJK+EPA
  with a one-sided face-normal rejection. The `Triangle3D` support
  function is degenerate along the face-normal axis (all 3 verts project
  to the same value), so EPA can't tighten near vertical contacts — the
  exact failure documented in PLAN.md's known limitations.

- **Parry** (`contact_manifolds_trimesh_shape.rs`): dispatches the
  per-triangle contact to the appropriate **closed-form** generator
  (`contact_manifolds_cuboid_triangle.rs`, `contact_manifolds_convex_ball.rs`,
  etc.). Triangle is treated as a polygonal feature with explicit face,
  3 edges, 3 vertices and **stable feature IDs** per sub-feature.

The actionable port is Parry's `contact_manifolds_cuboid_triangle.rs` —
SAT + face clipping — adapted to our `Triangle3D` flyweight and
emitting into our `(world_a, world_b, normal, depth)` stride. A
ball-vs-triangle closed form (Voronoi-region-based) is even simpler:
clamp the sphere centre to the triangle and dispatch on which feature
(face / edge / vertex) the clamp landed on.

### 2.9 Queries (raycast, shape-cast, overlap)

Parry's `QueryPipeline` mirrors what we have in `queries/`:

| meep | Parry equivalent | Notes |
|---|---|---|
| `raycast.js` | `query/ray/...` | meep is broadphase AABB-hit only; PLAN.md flags the narrowphase refinement as TODO |
| `shape_cast.js` | `shape_cast_support_map_support_map.rs` | meep uses **bisection on GJK overlap**; Parry uses **GJK directional_distance / minkowski_ray_cast** — conservative advancement |
| `overlap_shape.js` | `query/intersection_test/...` | both use broadphase + GJK |

**The shape_cast approach is genuinely different.** Our bisection is
robust and works, but is O(`log₂(tMax / tol)`) GJK calls per candidate.
Parry's GJK directional_distance is **continuous refinement** — it
advances the ray origin in the Minkowski difference each iteration and
tightens both bounds simultaneously. Typically converges in 5-15
iterations vs. our 32-step coarse linear search + up to 32 bisection
steps. This is a clean port target since we already have all the
support-function machinery.

### 2.10 Determinism

- **Rapier** advertises cross-platform determinism with f32 by avoiding
  transcendentals and relying on IEEE-754 round-to-nearest behaviour on
  the supported platforms. Their integration uses `mul_add`-free math
  where possible.
- **meep** is **same-runtime bit-exact** because `Math.sin/cos/exp/log` are
  ULP-correct but not bit-exact across V8 / SpiderMonkey / JSC. PLAN.md
  is explicit about this. The codebase's typed-array discipline (direct
  index writes, no `Vector3.set` on hot paths, min-heap free list, sorted
  awake-list iteration, canonical pair ordering) is the right
  infrastructure.

The action item to close the cross-runtime gap is documented in PLAN.md
(soft-float library for transcendentals). Not a gap in scope of this
review.

---

## 3. In-depth correctness comparison on six key touchpoints

### 3.1 Warm-start impulse correctness across reordered contacts

**meep** `solver/solve_contacts.js:467-476`:

```js
const j_n  = data[off + 10];
const j_t1 = data[off + 11];
const j_t2 = data[off + 12];
const Px = nx * j_n + t1x * j_t1 + t2x * j_t2;
// ... apply ...
```

The warm-start impulses live at fixed slot offsets `(slot, k)` in the
manifold cache. The new frame's narrowphase calls `manifolds.clear_contacts(slot)`
(at `narrowphase_step.js:687`), which **zeroes the data array including
`j_n`, `j_t1`, `j_t2`** (see `ManifoldStore.js:233-239`):

```js
clear_contacts(slot) {
    const meta_off = slot * SLOT_META_STRIDE;
    this.__meta[meta_off + 2] = this.__meta[meta_off + 2] & ~COUNT_MASK;
    const data_off = slot * SLOT_DATA_STRIDE;
    this.__data.fill(0, data_off, data_off + SLOT_DATA_STRIDE);
}
```

**Bug:** the docstring at `ManifoldStore.js:230` claims "narrowphase
determines the pair is separated this frame" but `narrowphase_step.js:687`
calls it **unconditionally** at the start of every narrowphase pass for
every pair, before the new contacts are computed. So warm-start
impulses are actively **destroyed on every frame**, every contact, every
pair. That's exactly the opposite of what warm-starting requires.

Walk-through:
- Frame N: pair has contact[0] with `j_n = 5.2`. End of frame: solver has
  applied impulses, data[10] = 5.2.
- Frame N+1: `acquire()` is a no-op (slot exists, just sets touched).
- Frame N+1 narrowphase: `clear_contacts(slot)` runs — **5.2 is wiped to 0**.
- Frame N+1 contacts written via `set_contact`, which **does not touch
  data[10..12]** (per its own docstring at `ManifoldStore.js:270` "warm-start
  impulses are preserved") — but they were already zeroed by `clear_contacts`.
- Solver pre-step at `solve_contacts.js:468-470` reads `j_n=0, j_t1=0, j_t2=0` →
  no warm-start impulse applied.

**The warm-start cache is effectively cold every frame.** This is a real
bug — the comment at `ManifoldStore.js:269` "warm-start impulses are
preserved across calls" describes intended behaviour that the
`clear_contacts → set_contact` sequence in narrowphase actively breaks.

Even if `clear_contacts` were fixed to preserve `j_n/j_t1/j_t2`, the
**ordering instability** from §2.5 is the deeper issue: contacts[0]
this frame is not contact[0] last frame for any non-trivial pair. Need
feature-ID or position matching.

**Improvement opportunity:**
1. Make `clear_contacts` either skip the j_n/j_t1/j_t2 stride elements, or
   accept a flag for "narrowphase contact replacement" vs. "pair separated".
2. Implement feature-id or position matching at the contact-write site
   (`narrowphase_step.js:691-700`) — when writing the new contact, find
   the most-similar previous contact in this slot and **inherit its
   warm-start impulses**.

### 3.2 Box-box manifold reduction non-determinism vs. Parry's clipping

**meep** `box_box_manifold.js:486-531`: reduce candidates to 4 by
"deepest, then max-distance-to-kept-set" — a perimeter-maximisation
heuristic. The ordering depends on numerical tiebreaks in
`d2 < min_d2` comparisons. For symmetric inputs (two axis-aligned
unit cubes at integer positions), this can produce different orderings
in two otherwise-identical PhysicsSystem instances if floating-point
arithmetic happens to differ by 1 ULP somewhere upstream.

**Parry** `contact_manifolds_cuboid_cuboid.rs`: clipping produces
**at most 4** candidates by construction (face × face Sutherland-Hodgman
on a rectangle gives ≤ 8 corners; the four "deepest" are kept by a
**feature-ID-based selection**, not a metric). Stable orderings.

Our reduction also doesn't guarantee that the **deepest 4** are kept —
it keeps deepest #1, then maximises spread. A cluster of three deep
contacts plus one shallow but spread-out contact will lose one of the
deep ones. For a flat box-on-floor with one corner slightly higher,
that's the right behaviour. For a tilted box where the corner contacts
matter, it can drop important contacts.

**Improvement opportunity:** mirror Parry's feature-ID-based 4-of-8 pick
(each corner of the clipped polygon carries the source edge and incident
corner id; pick the four corners that span the largest convex hull).

### 3.3 EPA on smooth supports — confirmed degeneration

**meep** `expanding_polytope_algorithm.js:255-263`:

```js
if (num_loose_edges >= EPA_MAX_NUM_LOOSE_EDGES) {
    // Polytope degenerated ...
    // Bail out and return the closest-face result accumulated so far ...
    break;
}
```

The fallback path returns the **last closest face's normal × `dot_p_search_dir`**
as the depth — but `dot_p_search_dir` was computed from a new support point
that the polytope-update loop just **discarded** (because it can't fit it
into the polytope without exceeding the cap). That's a mismatch: the depth
is the projection of a vertex that isn't on the polytope, while the
direction is the polytope's closest-face normal. The two should be consistent.

**Parry** `epa3.rs`: scale-adaptive tolerance + 100-iteration cap; when
it bails, it returns the closest face's barycentric-projected point, which
is by construction a Minkowski-difference point. Output is a true MTV.

**Improvement opportunity:**
1. On the EPA bail path, return `closest_face_normal * min_dist` (the
   stored `min_dist` for the chosen closest face), not
   `normal * dot_p_search_dir`. `min_dist` is the actual signed distance
   from origin to the closest face plane, which is the right MTV magnitude.
2. Scale `EPA_TOLERANCE` by `max(scale_of_coordinates, 1.0)` — same fix
   Parry applied for issue #415. The coordinate scale is the max of the
   incoming simplex's coordinate magnitudes.
3. Remove the leftover `debugger;` at `expanding_polytope_algorithm.js:136`.

### 3.4 Concave dispatch: face-normal rejection + concave-vs-concave

**meep** `narrowphase_step.js:519`:

```js
if (ex * fnx_w + ey * fny_w + ez * fnz_w <= 0) continue;
```

This is the "convex on outward side of triangle" test. **Correctness
concern:** for a sphere centred slightly above a flat heightmap, the
sphere-vs-triangle GJK+EPA may report an MTV that's near-perpendicular to
the face (good — `fnx_w · ex > 0`), but for cases where the sphere is
overlapping two adjacent triangles (one slanted, one horizontal), the
sphere-vs-slanted-triangle's EPA can return an MTV that points *into*
the slanted face. The rejection then skips that triangle, leaving only
the horizontal-face contact — which is correct here. But:

The face-normal rejection runs **after** sign-canonicalisation against the
triangle centroid → convex centre vector. For a thin shape (capsule
oriented along the slope), the centroid-to-centre vector might be near-
parallel to the face, and the sign canonicalisation flips inconsistently
between adjacent triangles, producing alternating contact directions
across the heightmap. The `compute_penetration.js` half-space pre-test
sidesteps this; the narrowphase doesn't.

**Improvement opportunity:** for primitive-vs-triangle, use the
half-space approach (project sphere centre / box corner / capsule
endpoints onto the triangle plane) like `compute_penetration.js` does.
This is roughly half the work of a closed-form ball-vs-triangle and
already validated in our codebase. The full closed-form (§2.8) is
strictly better — see backlog.

### 3.5 GJK iteration tolerance and convergence detection

**meep** `gjk.js:96-128`: 64-iter cap, **no progress check between
iterations**. The only termination is "origin enclosed in tetrahedron"
(return true) or "new support doesn't pass the origin" (return false).
Non-progressing inputs (degenerate Minkowski difference at exactly
touching, smooth-on-smooth tangent contact) run to 64 iterations and
return false.

**Parry** `gjk.rs`: progress check — if the new support's signed distance
to the previous closest-feature plane improves by less than `eps`, declare
convergence. Faster termination on near-touching pairs (sphere tangent to
sphere); same false return on actual non-overlap but with fewer
iterations.

**Improvement opportunity:** add a progress check on the new support
point's projection along the search direction. If `dot(new_support, dir)`
is within tolerance of the previous iteration's value, declare convergence.
On the practical level this affects performance (smooth-on-smooth) more
than correctness (we already return false on the iteration cap), but
falling out earlier on tangent contact is a real perf+stability win for
the broadphase-edge case.

### 3.6 Shape-cast bisection vs. GJK conservative advancement

**meep** `queries/shape_cast.js:267-297`: AABB slab interval narrowing +
32-step coarse linear search + up to 32 bisection steps + MPR normal
recovery.

**Parry** `shape_cast_support_map_support_map.rs` → `gjk::directional_distance`:
**continuous refinement** in Minkowski space, simultaneously tightening
both bounds. Converges in ~5-15 iterations on typical inputs because each
iteration uses geometric structure (the support hyperplane at the current
ray position) rather than blind bisection.

Trade-offs:
- Bisection is **simpler and dead-reliable** — no degenerate cases.
- Conservative advancement is **substantially fewer GJK calls** —
  Parry's typical TOI query is in the 10s of microseconds; ours is
  64-step bisection per candidate.

For our workload (kinematic character controllers — a few queries per
frame), the perf delta is small. **Not a high-priority change** but
worth knowing about. The normal recovery via MPR probe-step is fine
(`shape_cast.js:350-381`); Parry's TOI returns the contact normal as
part of the same conservative-advancement loop, so there's no separate
probe needed.

### 3.7 Friction: tangent disk vs. Coulomb pyramid

**meep** `solver/friction_cone.js` (called from `solve_contacts.js:569`):
two-tangent disk clamp — limits `sqrt(t1² + t2²) ≤ μ·j_n`. Coulomb cone
in the velocity-impulse plane.

**Parry**: looks similar in the `ContactConstraintTangentPart::solve` —
Vector2 impulse clamped against `limit = mu * normal_part.impulse`.
Functionally the same.

No actionable difference; mark as parity.

### 3.8 Body-centre application point fallback in GJK+EPA path

**meep** `narrowphase_step.js:640-644`:

```js
return append_contact(count,
    trA.position.x, trA.position.y, trA.position.z,
    trB.position.x, trB.position.y, trB.position.z,
    nx, ny, nz,
    depth);
```

The fallback contact application points are **body centres**, with the
detailed explanation in the inline comment (`narrowphase_step.js:623-639`)
about flat-face support being multi-valued. This is correct in spirit but
not in detail: for a tilted box on a flat floor with the GJK+EPA path
(e.g. a non-cube convex hull on a heightmap triangle), the contact
"point" sitting at the floor's centre rather than under the box produces
the wrong torque arm. Specifically `r × n` is large and in the wrong
direction. The Baumgarte position correction gets dampened by the large
effective mass denominator and the box separates slowly.

**Parry** computes contact points as **support-function witnesses**: for
each shape, the support point in the contact normal direction is the
contact application point. For flat-face shapes the witness is one of
the (possibly-multi-valued) corners, and Parry deals with the
multi-valued case by **picking a face when the support is multi-valued
in the support direction**. Their `support_face` returns a
`PolygonalFeature` rather than a point.

**Improvement opportunity:** in the GJK+EPA fallback path, compute the
contact point as `(posed_a.support(epa_dir) + posed_b.support(-epa_dir)) / 2`
**modulo a check for flat-face support**: if `|support_a - centre_a|` or
`|support_b - centre_b|` is suspiciously large relative to the shape's
characteristic length, fall back to body centres. This narrows the body-
centre fallback to its actually-correct domain (flat-faced shapes where
the support is multi-valued) instead of using it for everything.

---

## 4. Simplicity and uniformity

### 4.1 What's clean

- **`BodyStorage`** (`body/BodyStorage.js`): SoA, generation packing,
  deterministic free-list. Every primitive operation is one block of
  index arithmetic. Hard to make simpler without losing functionality.
- **`PairUint32Map`**: Robin Hood hashing in a single typed-array. No
  allocation in steady state.
- **`union_find.js`**: path halving in 8 lines. Minimal code.
- **`integrate_velocity.js`**: 80 lines, no surprises, well-commented on
  the implicit-decay damping recipe.
- **`compute_fat_world_aabb.js`**: 60 lines, single-purpose, all constants
  named.

### 4.2 What's specialised in ways that earn their keep

- **The two-BVH split** (`PhysicsSystem.staticBvh` + `dynamicBvh`): cheap
  cognitively, real perf win for the "mostly-static world" target. Static
  tree is built once and never touched.
- **Atomic-island sleep with circular doubly-linked sleep groups**: the
  alternative ("rebuild from scratch") would be much simpler but loses
  the property that a hit at the base of a 100-stack wakes the whole
  stack in one frame.
- **Per-island CSR layout from `IslandBuilder`**: enables solver,
  sleep-test, and event-dispatch to all iterate the same data structure.
  Genuine reuse.
- **`Triangle3D` flyweight + `triangle_buffer` reuse in the concave
  dispatch**: zero allocation per triangle. The alternative (allocating
  triangle instances) would be a hotspot.

### 4.3 What's specialised in ways that don't earn their keep

- **The narrowphase dispatcher** in `narrowphase_step.js:192-353` is a big
  if-else chain on `isUnitSphereShape3D`, `isBoxShape3D`, etc., with
  per-pair flipping of A/B identity (`isSphereA ? trA : trB` etc.). It's
  legible but doesn't scale gracefully — adding cylinder support is
  another ~50 lines of switch arms. Parry's approach is a **shape-type
  pair → dispatch function** lookup, which is one indirection but
  trivially extensible. A small registry table indexed by
  `(shape_a_type << 8) | shape_b_type` would replace the entire chain
  with one indirection. (NB: the type-tag identification via
  `isUnitSphereShape3D === true` works in our JS world but the registry
  generalises trivially.)
- **Quaternion conjugate-rotate-conjugate boilerplate** in 5+ files
  (`sphere_box_contact.js:48-57`, `capsule_contacts.js:39-71`,
  `narrowphase_step.js:467-475`, `PosedShape3D.js:67-93`). Each
  reimplements the same `v_world = q · v_local · q*` arithmetic inline.
  Extracting a single `quaternion_apply` and trusting V8 to inline it
  would clean the code at no cost. Parry has `Isometry` for exactly this.
- **EPA's `loose_edges` array** (`expanding_polytope_algorithm.js:41`):
  the `copyWithin` based edge removal scales poorly past ~32 edges and
  the wrapping logic is fragile. Parry's adjacency-graph EPA is harder
  to write but more robust. Worth keeping the current implementation
  until it bites.

### 4.4 Adding a new shape pair / constraint / query

- **New shape pair (e.g. cylinder-box):** add a closed-form handler in
  `narrowphase/cylinder_box_contact.js`, add an `isCylinderShape3D` type
  tag, add a branch in `narrowphase_step.js` dispatch. ~3 files,
  ~100-200 lines, mechanical. Parry's equivalent is also mechanical but
  more strongly typed.
- **New constraint (e.g. distance joint):** would need to thread `joints`
  through the island builder + solver. PLAN.md notes "the solver loop is
  already set up to iterate contacts ∪ joints; only constraint pre-step +
  warm-start hook is missing." This is structurally easier than Parry
  because we already iterate per-island.
- **New query (e.g. sphere-cast):** `queries/sphere_cast.js` next to
  `shape_cast.js` — but `shape_cast` is already generic, so this would
  be a thin wrapper. Parry has both `cast_ray` and `cast_shape` for the
  same reason.

### 4.5 Recommended structural simplifications

1. **Extract a `quaternion_apply(out, off, vx, vy, vz, qx, qy, qz, qw)`
   helper** in `core/geom/3d/quaternion/`. Replace every inline copy in
   the narrowphase.
2. **Replace the narrowphase dispatcher's if-else chain with a small
   typed dispatch table.** Adding shapes becomes a one-line entry.
3. **Move warm-start impulse persistence out of the
   `clear_contacts` path** (§3.1) — narrowphase calls a different method
   `replace_contacts(slot, new_count)` that leaves j_n/j_t1/j_t2 alone,
   and `clear_contacts` continues to be the "this pair has separated"
   reset.
4. **Centralise contact application-point selection** (§3.8) — a single
   `contact_point_for_pair(shapeA, shapeB, epa_dir)` that knows when to
   use witnesses vs. body centres, rather than scattering the heuristic
   across narrowphase paths.

---

## 5. Headline findings, ranked

1. **Warm-start cache is effectively cold every frame** (§3.1) due to
   `clear_contacts(slot)` running unconditionally at the start of every
   narrowphase pass. Real bug. Cheap fix.
2. **Contact persistence lacks feature-id matching** (§2.5). When the
   narrowphase emits manifold contacts in a different order this frame
   vs. last, warm-start impulses apply to wrong contact points. Parry's
   `TrackedContact { fid1, fid2, data }` is the model.
3. **Solver is structurally locked out of TGS by combined Baumgarte+
   restitution bias in `rhs`** (§2.6). The Rapier `rhs` vs `rhs_wo_bias`
   split + impulse_accumulator architecture defuses the three failure
   modes documented in PLAN.md. Significant work but unblocks the
   biggest performance roadmap item.
4. **Closed-form primitive-vs-triangle missing** (§2.8). PLAN.md flags as
   the single biggest accuracy gap. Parry's `contact_manifolds_cuboid_triangle.rs`
   + a Voronoi-region ball-vs-triangle are the port targets.
5. **EPA scale-adaptive tolerance and consistent fallback** (§3.3). The
   `EPA_TOLERANCE = 0.0001` is an absolute constant; should scale by
   coordinate magnitude (Parry issue #415's fix). On the EPA bail path,
   return `min_dist * face_normal`, not the inconsistent
   `dot_p_search_dir * face_normal`. Also drop the leftover `debugger;`.
6. **Concave-side per-triangle GJK+EPA cannot converge on degenerate
   `Triangle3D` support** (§3.4). Already documented in PLAN.md. The
   sphere-vs-triangle closed-form is the smallest first step (Voronoi
   region clamp) and removes a class of skipped tests.
7. **MPR is not wired into the narrowphase as an EPA fallback**.
   PLAN.md correctly identifies this — the swap is in `narrowphase_step.js:580-644`
   (replace the EPA call with `if (mpr(...)) ... else if (epa(...))` or
   prefer MPR on smooth-shape pairs). The code already exists.
8. **Box-box reduction can drop deep contacts in favour of spread**
   (§3.2). Mirror Parry's feature-ID-based 4-of-8 corner pick on the
   clipped polygon.
9. **GJK lacks progress-based convergence detection** (§3.5). Adds a
   single dot-product check per iteration; speeds up tangent contacts
   and provides earlier-out on degenerate Minkowski differences.
10. **CCD is speculative-margin-only**. Already in backlog. Rapier's
    `predict_impacts_at_next_positions → clamp_motions` two-phase TOI
    sweep is the architecture; our `shape_cast.js` already has the TOI
    primitive.

---

## Appendix A: file-by-file Rapier/Parry references

| meep file | Rapier/Parry counterpart |
|---|---|
| `ecs/PhysicsSystem.js` | `rapier/src/pipeline/physics_pipeline.rs` |
| `body/BodyStorage.js` | `rapier/src/dynamics/rigid_body_set.rs` |
| `broadphase/generate_pairs.js` | `rapier/src/geometry/broad_phase_bvh.rs` |
| `broadphase/compute_fat_world_aabb.js` | (inline in `broad_phase_bvh.rs` refit logic) |
| `contact/ManifoldStore.js` | `parry/src/query/contact_manifolds/contact_manifold.rs` |
| `events/diff_manifolds.js` | `rapier/src/pipeline/event_handler.rs` |
| `island/IslandBuilder.js` | `rapier/src/dynamics/island_manager/manager.rs` |
| `core/collection/union-find/union_find.js` | (Rapier uses DFS, not union-find) |
| `gjk/gjk.js` | `parry/src/query/gjk/gjk.rs` (`VoronoiSimplex` based) |
| `gjk/expanding_polytope_algorithm.js` | `parry/src/query/epa/epa3.rs` |
| `gjk/mpr.js` | `parry/src/query/mpr/...` |
| `narrowphase/sphere_sphere_contact.js` | `parry/src/query/contact/contact_ball_ball.rs` |
| `narrowphase/sphere_box_contact.js` | `parry/src/query/contact_manifolds/contact_manifolds_convex_ball.rs` |
| `narrowphase/capsule_contacts.js` | `parry/src/query/contact_manifolds/contact_manifolds_capsule_capsule.rs`, `..._cuboid_capsule.rs` |
| `narrowphase/box_box_manifold.js` | `parry/src/query/contact_manifolds/contact_manifolds_cuboid_cuboid.rs` |
| `narrowphase/narrowphase_step.js` (concave path) | `parry/src/query/contact_manifolds/contact_manifolds_trimesh_shape.rs` |
| `narrowphase/decomposition/heightmap_enumerate_triangles.js` | `parry/src/query/contact_manifolds/contact_manifolds_heightfield_shape.rs` |
| `narrowphase/compute_penetration.js` | `parry/src/query/contact/contact_shape_shape.rs` |
| `solver/solve_contacts.js` | `rapier/src/dynamics/solver/velocity_solver.rs` + `rapier/src/dynamics/solver/contact_constraint/contact_constraint_element.rs` |
| `solver/friction_cone.js` | (inline in `ContactConstraintTangentPart::solve`) |
| `integration/integrate_velocity.js` | `rapier/src/dynamics/solver/velocity_solver.rs` (between-substep integration) |
| `integration/integrate_position.js` | `rapier/src/dynamics/solver/velocity_solver.rs` `integrate_positions` |
| `inertia/world_inverse_inertia.js` | `rapier/src/dynamics/solver/...` (inline in constraint setup) |
| `queries/raycast.js` | `parry/src/query/ray/...` |
| `queries/shape_cast.js` | `parry/src/query/shape_cast/shape_cast_support_map_support_map.rs` (uses GJK conservative advancement) |
| `queries/overlap_shape.js` | `parry/src/query/intersection_test/...` |
