# Physics Engine Review: meep vs. cannon-es

A deep technical comparison of the meep in-house rigid-body engine
(`H:/git/moh/app/src/mir-engine/meep/src/engine/physics/`) against
**cannon-es** (https://github.com/pmndrs/cannon-es), the
community-maintained fork of Stefan Hedman's **cannon.js**
(https://github.com/schteppe/cannon.js).

Cannon is uniquely interesting as a reference because it shares every
hard constraint we work under: **pure JavaScript, single-threaded, no
SIMD, no SharedArrayBuffer, GC-sensitive hot paths**. Anywhere Cannon
discovered a JS-specific workaround, it translates directly to us.
Anywhere we beat Cannon, it's a fair fight on engineering merit — not
"they had SIMD intrinsics and we didn't".

This review respects PLAN.md's documented out-of-scope decisions (pure
JS, no SAB, no multi-threaded solver, action-log netcode). Cannon
citations use `src/<dir>/<File>.ts` paths; our citations use
`path:line` relative to the package root.

---

## 1. Overall Architecture

### Pipeline shape

| Stage | meep `PhysicsSystem.fixedUpdate` (`ecs/PhysicsSystem.js:1075`) | cannon-es `World.internalStep` (`src/world/World.ts`) |
|---|---|---|
| Apply gravity | Folded into Stage 1 `integrate_velocity` (`integration/integrate_velocity.js`) | Standalone "gravity" loop over all bodies |
| Forces from user constraints | n/a (no joints in v1) | `for (const c of constraints) c.update()` adds equations |
| Subsystem callbacks | n/a | `subsystems` user-extension hook |
| Velocity integration | Stage 1, awake-only | Last (after solver); leap-frog |
| Broadphase refit | Stage 2, awake-only, fat-AABB `compute_fat_world_aabb.js` | Per-body `updateAABB()` inside `broadphase.collisionPairs` |
| Pair generation | Stage 3, `generate_pairs.js` — leaf query into both BVHs, dedup via touched bit | `broadphase.collisionPairs(this, p1, p2)` |
| Wake propagation | Stage 4, explicit `__wake_pairs` over the pair list | Implicit — narrowphase sets `wakeUpAfterNarrowphase = true` when relative speed exceeds threshold |
| Narrowphase | Stage 5, `narrowphase_step.js` cascade `if (a_kind && b_kind) ...` | `Narrowphase.getContacts(p1, p2, ...)` giant switch dispatch |
| Island build | Stage 6, `IslandBuilder` union-find CSR | Lazy in `SplitSolver` (BFS over equation graph), or no partitioning if using bare `GSSolver` |
| Solver | Stage 7, `solve_contacts.js` per island, 10 iters | `solver.solve(dt, this)` over the flat equation list |
| Position integration | Stage 8 | Same as velocity (leap-frog `pos += velo * dt`) |
| Sleep | Stage 9, per-island atomic | Stage 14, per-body `body.sleepTick(time)` |
| Damping | Folded into integrators | Stage 11, explicit multiplicative decay |
| Event diff | Stage 10, `diff_manifolds` → Begin/Stay/End | `collisionMatrix` flip + `collide`/`endContact` events from narrowphase |
| End-of-step | Stage 11, `manifolds.advance_frame()` (roll touched, evict grace-expired) | `clearForces()` — accumulators wiped |

The single most consequential structural divergence is **what each step
iterates over**. Cannon's documented `internalStep` walks **every body**
on every pass (`for (let i = 0; i !== N; i++) { const bi = bodies[i] }`
appears repeatedly in `World.ts`), with sleep state being a property
checked inline rather than a filter. We instead iterate
`storage.awake_at(i)` for `i in [0, awake_count)` — the awake set is a
dense `Uint32Array` (`body/BodyStorage.js:101-110`) and grows / shrinks
via swap-with-last (`body/BodyStorage.js:330-337`). At million-body
scale where 99% of bodies are mostly sleeping (the design target in
PLAN.md), we pay O(awake) per stage while Cannon pays O(total). This
is **the** scalability story between the two engines.

### Data layout — body pool

| Aspect | meep `BodyStorage` | cannon-es `Body` |
|---|---|---|
| ID encoding | 24-bit index ǁ 8-bit generation (`body/BodyStorage.js:11-19`) | Bare object reference (`Body` instance) + monotonically increasing `id` |
| Slot reuse policy | Min-heap of free indices for deterministic reuse (`body/BodyStorage.js:154-176`, `383-426`) | No reuse — destroyed bodies are GC'd |
| Active list | Dense `Uint32Array __awake_list` + reverse map `__awake_pos` (`body/BodyStorage.js:101-103`) | None — bodies live in `World.bodies: Body[]` regardless of sleep state |
| Body state storage | SoA — `__entities`, `__generations`, `__kinds`, `__flags` as parallel typed arrays. Hot mutable state (velocity / accumulators) on `RigidBody` instances | AoS — every field on the `Body` class instance (`position: Vec3`, `velocity: Vec3`, `force: Vec3`, `torque: Vec3`, `sleepState: number`, etc.) |
| Iteration | `for (let i = 0; i < awake_count; i++) { const idx = awake_at(i); const rb = __bodies[idx]; ... }` | `for (let i = 0; i !== N; i++) { const bi = bodies[i]; if (bi.sleepState === Body.SLEEPING) continue; ... }` |
| Generation safety | Stale packed id detected via `is_valid()` mask + generation check (`body/BodyStorage.js:210-219`) | Stale references possible (caller is responsible) |

**Where we win:** SoA identity tables + awake-list filtering. Hot
streaming reads of cold flags / kinds / generations don't pull
unrelated body fields into cache, and disconnected sleeping bodies
contribute literally zero work per step. Cannon's per-body
`sleepState` check is cheap-but-not-free, and on a scene of 1M static
chairs in a town the difference is dramatic.

**Where Cannon wins (or rather, "is more idiomatic"):** Cannon's
`Body` is a single class with every field on it. V8 monomorphises
this beautifully — every `body.velocity` access is the same hidden
class lookup. There's no parallel-array index arithmetic, no
`body_id_index(packed)` mask-and-shift. Debugging is plain
object inspection. Our split (RigidBody for the JS-side hot state,
BodyStorage for identity SoA, Collider list as a separate per-body
array, transforms on a Transform component) costs a few extra
indirections per body in the solver inner loop — but those are array
indexed loads, not pointer-chases, so still cache-friendly.

The crucial nuance: **even Cannon's `Body` AoS is "AoS per body but
cache-unfriendly per pass"**. When the integrator wants only
`position`, `velocity`, and `force`, it still pulls all 25-ish other
fields of `Body` into cache. Pure SoA (one `Float64Array` per
attribute) would be better, but we don't go fully SoA on the hot
fields either — `RigidBody.linearVelocity` is a `Vector3` extending
`Float64Array`, accessed as `lv[0]/[1]/[2]` (`solver/solve_contacts.js:427`).
Our SoA win is therefore narrower than the layout suggests; the
honest comparison is "we got the identity / scheduling pieces into
SoA, the per-body kinematic state is still AoS like Cannon's, just
with typed-array storage". The Jolt/Bullet style of fully SoA kinematic
state remains a future optimisation.

### Broadphase

| Engine | Strategies | Default |
|---|---|---|
| meep | One: BVH × 2 (`staticBvh` + `dynamicBvh`), with fat-AABB refit (`broadphase/compute_fat_world_aabb.js`) | The only choice |
| cannon-es | Three: `NaiveBroadphase` (O(N²)), `GridBroadphase` (uniform grid, spheres+planes only), `SAPBroadphase` (single-axis sweep & prune) | `NaiveBroadphase` |

`NaiveBroadphase.collisionPairs` is literally:
```js
for (let i = 0; i !== n; i++) {
  for (let j = 0; j !== i; j++) {
    if (!this.needBroadphaseCollision(bi, bj)) continue;
    this.intersectionTest(bi, bj, pairs1, pairs2);
  }
}
```
The class docstring even concedes the algorithm's complexity is "N²
(which is bad)". Cannon's expectation is that users will swap in
`SAPBroadphase` for scenes with more than a few hundred bodies. SAP
uses insertion-sort on one axis (chosen via `autoDetectAxis()` —
variance maximisation), which handles small frame-to-frame motion in
~O(N) but degrades to O(N²) on sudden mass motion. Cannon makes no
attempt at a tree.

**Our BVH split with awake-list-driven pair generation is
unambiguously better at our target scale.** At 1M bodies the
NaiveBroadphase is 10¹² pair checks per frame — completely
unusable. Our `generate_pairs.js:50-99` does one BVH query per
awake-leaf into each of two BVHs and dedups via the manifold
touched flag, scaling O(awake · log(total)) at worst. This is a
*million-fold* difference at the design-target scale.

### Island structure

Cannon ships **`SplitSolver`** as an opt-in wrapper around `GSSolver`.
On each `solve()` call it builds a graph (every body is a node, every
equation an edge), runs BFS to find connected components, then calls
the inner solver per island. Each rebuild allocates fresh `nodes`,
`children`, and intermediate arrays — there's an internal pool but
graph-shape changes between frames defeat it. Cannon's island
construction is also coupled to the solver, not to the contact
generator, so it's purely a solver-side optimisation; sleep and
events use unrelated machinery (per-body sleep tick, per-pair
narrowphase-driven events).

We pull islands up to a first-class pipeline stage with `IslandBuilder`
(`island/IslandBuilder.js`) producing CSR-shaped output that the
solver and sleep test *both* consume. Bodies are sorted ascending
within an island and islands ascending by root index, giving us
**determinism by construction** without sorting at the solver
boundary. The PLAN.md atomic-sleep + atomic-wake story (sleep groups
threaded via `sleep_group_next/prev` so waking any member walks the
chain) is structurally impossible in Cannon's per-body model — see §3.

### Solver

Cannon's `GSSolver` is a beautifully general sequential-impulse
solver over a flat equation list (contacts, friction, joints all the
same shape: an `Equation` with `computeB`, `computeC`, lambda
bounds). It implements the **SPOOK formulation** (Lacoursière /
Erleben) of constraint regularisation: `a = 4/(h*(1+4*d))`, `b =
4d/(1+4d)`, `eps = 4/(h*h*k*(1+4*d))` parameterised by stiffness `k`
and relaxation `d` (`src/equations/Equation.ts`). Per-iteration:

```js
deltalambda = invC * (B - GWlambda - c.eps * lambdaj);
```
The `c.eps * lambdaj` term is what makes SPOOK different from naive
Baumgarte — it's **continuous regularisation**, applied at every
iteration to soften the constraint, rather than a one-shot velocity
bias. Defaults: `stiffness = 1e7, relaxation = 4, iterations = 10`.

Our `solve_contacts.js` is a more specialised contacts-only sequential
impulse solver: warm-start replay (`solver/solve_contacts.js:466-477`),
Baumgarte position correction folded into the velocity bias
(`solver/solve_contacts.js:442-450`), Coulomb friction with disk-clamp
(`solver/solve_contacts.js:566-571`), per-island iteration
(`solver/solve_contacts.js:308-313`). 10 iterations default
(matches Cannon). The split-impulse / split-position-pass story is
discussed in §3.

### Sleep system

Cannon's per-body sleep is the chatter-prone classic algorithm.
`Body.sleepTick(time)` (verbatim from `src/objects/Body.ts`):
```js
if (this.allowSleep) {
  const sleepState = this.sleepState;
  const speedSquared = velocity² + angularVelocity²;
  const speedLimitSquared = this.sleepSpeedLimit ** 2;
  if (sleepState === Body.AWAKE && speedSquared < speedLimitSquared) {
    this.sleepState = Body.SLEEPY;
    this.timeLastSleepy = time;
  } else if (sleepState === Body.SLEEPY && speedSquared > speedLimitSquared) {
    this.wakeUp();
  } else if (sleepState === Body.SLEEPY && time - this.timeLastSleepy > this.sleepTimeLimit) {
    this.sleep();
  }
}
```
This is the failure mode that **specifically motivates** our atomic
sleep: in a stack of 100 blocks, frame N the bottom block falls
asleep, frame N+1 a top block's micro-jitter wakes a middle block via
broadphase pair propagation, frame N+2 the wake cascades down,
frame N+3 the bottom re-wakes. The whole stack chatters indefinitely.
PLAN.md "Sleep + events" calls this out explicitly:

> "Per-island atomic sleep: an island sleeps when `max(|v|² + |ω|²)`
> across all members stays below the threshold long enough; the whole
> island sleeps in the same frame. Replaces the per-body chatter on
> weakly-connected piles."

Our `__sleep_test` (`ecs/PhysicsSystem.js:942-1006`) walks each
island, takes `max(v² + ω²)` over all members, increments every
member's timer if-and-only-if the **island as a whole** is below
threshold, and atomically sleeps all members the same frame
(`__atomic_sleep_island_range` at `:754-786`). The dual atomic-wake
walks the `sleep_group_next` circular DLL (`:705-737`).

Cannon has no equivalent. The `wakeUpAfterNarrowphase` flag is a
one-frame deferral, not an island-aware operation.

### Threading

Both single-threaded by deliberate design.

---

## 2. Specific Algorithms and Tradeoffs

### Body storage — class instances vs SoA + stable IDs

Cannon's `Body` declares ~30 mutable public fields including
`position`, `velocity`, `quaternion`, `angularVelocity`, `force`,
`torque`, `mass`, `linearDamping`, `angularDamping`, `linearFactor`,
`angularFactor`, `sleepState`, `allowSleep`, `collisionFilterGroup`,
`collisionFilterMask`, `collisionResponse`, `isTrigger`, `shapes`,
`shapeOffsets`, `shapeOrientations`, `aabb`, `boundingRadius`,
`inertia`, `invInertia`, `invInertiaWorld`. They're set in the
constructor with `||` default coalescing — typical Cannon idiom is
`this.mass = typeof options.mass === 'number' ? options.mass : 0`.
V8 monomorphises `Body` instances aggressively because the
initialisation order is fixed and every field gets a typed default.

Our split:
- `RigidBody` (`ecs/RigidBody.js`) — JS-side hot state (velocity,
  accumulators, damping, mass, gravity scale, sleep state). Class
  fields declared at class-body scope (`= new Vector3(0,0,0)` etc.),
  same V8 hidden-class win.
- `BodyStorage` — identity SoA (`__entities`, `__generations`,
  `__kinds`, `__flags`, `__alive` as parallel typed arrays) and
  scheduling (`__awake_list`).
- `Collider` (component, attached separately, possibly compound).
- `Transform` (independent, shared with rendering / IK / etc.).

**GC pressure.** Cannon allocates one `Body` per body, plus the
`Vec3` / `Quaternion` instance for each field. A scene of 100k bodies
is ~100k bodies × ~10 Vec3/Quat ≈ 1M small objects on the heap, all
of which the GC has to walk on every major collection. We allocate
~100k bodies × 4 Vector3s (linear/angular vel, accumulated
force/torque, inertia) ≈ 400k small objects — better, but still on
the heap. Where we materially win is the **per-step work**: our SoA
identity tables are TypedArray slabs that don't appear in GC
traversal at all, and we don't allocate per-step.

**Iteration speed.** For the solver inner loop the comparison is a
wash: both engines access `rb.linearVelocity[0..2]` (us, since
Vector3 extends Float64Array) or `body.velocity.x/y/z` (Cannon). V8
should compile both to the same machine code. The difference appears
at the broadphase / wake phase, where Cannon's per-body iteration
pulls the whole `Body` into cache for a `sleepState` check while ours
reads a single byte from `__alive` / `__awake_pos` and jumps over the
slot.

**Observability.** Cannon wins here — `console.log(world.bodies[42])`
shows you everything. Our `system.__bodies[idx]` shows RigidBody but
not its colliders, transform, or storage entry; you have to know to
look in three places. This is the cost of decomposition; it's a
trade we deliberately made for ECS clarity and we shouldn't undo it.
But adding a `dump(body_idx)` helper that aggregates the four
sources would be cheap and would close the gap.

### Broadphase

Already covered above; the headline is that **Cannon ships three
broadphases and defaults to the worst one**. The `NaiveBroadphase`
default exists for tutorial-grade simplicity ("first 50 bodies just
work, then read the docs"). For a real physics engine `SAPBroadphase`
is the standard choice and it's still single-axis SAP, which is
state-of-the-art-1990s.

A subtle point worth flagging: cannon-es's `SAPBroadphase` uses
event listeners on the World (`world.addEventListener('addBody',
...)`) to keep its `axisList` in sync. This is allocation-free per
step but has a hidden cost — the `addBody` / `removeBody` events fire
synchronously on dataset mutations, and adding / removing many bodies
mid-step is **explicitly fragile in Cannon**. We sidestep this
entirely because our broadphase reads `storage.awake_at(i)` directly
and the BVH allocates/frees its own nodes on
`attach_collider`/`detach_collider`. No event-listener indirection.

### Narrowphase pairs — SAT vs GJK/EPA + closed-form

This is the **deepest structural divergence**. Cannon has
**no GJK and no EPA**. Its entire narrowphase is built on:

1. Closed-form per-pair handlers (`sphereSphere`, `spherePlane`,
   `sphereBox`, `boxBox`, etc. — a large method-per-pair switch on
   `Narrowphase.getContacts`)
2. **SAT** for arbitrary `ConvexPolyhedron` shapes (face normals + edge
   crosses), followed by
3. **Sutherland-Hodgman clipping** of the incident face against the
   reference face's bounding planes to extract a multi-point manifold

Our cascade in `narrowphase/narrowphase_step.js:296-782`:

| Pair | Path | Cannon equivalent |
|---|---|---|
| sphere-sphere | `sphere_sphere_contact` closed-form (`:309-325`) | `Narrowphase.sphereSphere` |
| sphere-box | `sphere_box_contact` closed-form, handles centre-inside-box (`:328-359`) | `Narrowphase.sphereBox` |
| capsule-X | `capsule_*` closed-form family (`:397-483`) | Cannon has no capsule shape |
| box-box | SAT + Sutherland-Hodgman in `box_box_manifold.js` (`:362-394`) | `ConvexPolyhedron` via `Narrowphase.convexConvex` — same algorithm, more general implementation |
| convex × concave (heightmap/mesh) | Per-triangle GJK+EPA via decomposition dispatcher (`:498-713`) | `convexTrimesh` / `convexHeightfield` — Trimesh BVH-queries triangles, each treated as a `ConvexPolyhedron` for SAT |
| anything else convex | GJK + EPA fallback (`:715-781`) | **No fallback exists** — must be `ConvexPolyhedron` with explicit faces |

**The structural tradeoff:** SAT works only on shapes with explicit
face / edge lists. Cannon can't represent a smooth shape (sphere is
its own special case; capsule, cylinder, cone, ellipsoid, generic
convex-hull-from-point-cloud — all impossible without an explicit
polyhedral mesh). We can, via GJK on the support function. But our
EPA degenerates on smooth shapes (PLAN.md "Limitations / Known
caveats") so the *practical* shape coverage of the two engines is
closer than the algorithm choice suggests.

**Cannon's `ConvexPolyhedron.clipFaceAgainstHull`** is its quality
crown jewel for convex-convex contacts. The pipeline:

1. `findSeparatingAxis` iterates face normals of A, then face normals
   of B, then 9 (or `uniqueAxesA × uniqueAxesB`) edge-cross axes.
   Tracks the minimum penetration depth across all axes. Returns the
   normal of the deepest axis or `false` (no overlap).
2. Choose the witness face on B whose normal is most parallel to the
   separating normal — this is the *incident face*.
3. Clip the incident face polygon against every neighbouring face of
   A's witness face, treated as a half-space. After all clips, the
   surviving vertices that lie *behind* the witness face become
   contact points. Snippet (paraphrased from the source):

```js
for (let i = 0; i < numVerticesA; i++) {
  localPlaneNormal.copy(this.faceNormals[otherFace]);
  this.clipFaceAgainstPlane(pVtxIn, pVtxOut, planeNormalWS, planeEqWS);
  // swap buffers
}
// finally:
for (let i = 0; i < pVtxIn.length; i++) {
  let depth = planeNormalWS.dot(pVtxIn[i]) + planeEqWS;
  if (depth <= maxDist && depth <= 1e-6) {
    result.push({ point, normal, depth });
  }
}
```

Our `box_box_manifold.js` does exactly the same thing but specialised
to two boxes (3 face normals × 2 + 9 edge crosses = 15 axes, hard-coded).
The result is 0..4 contact points per box-box pair, same as Cannon.
**The major gap:** we have no general convex-hull shape, only `BoxShape3D`.
If we add `ConvexHullShape3D` (on PLAN.md backlog), we'll want
basically Cannon's `ConvexPolyhedron.clipFaceAgainstHull` ported. The
algorithm is already in our codebase as the inner clipping pass of
`box_box_manifold.js`; the missing piece is the SAT outer loop
that iterates an arbitrary face/edge list rather than the hard-coded 15.

### GJK / EPA — what each engine can actually handle

| Shape pair | meep | cannon-es |
|---|---|---|
| sphere–sphere | closed-form | closed-form |
| sphere–box | closed-form (interior-handling) | closed-form |
| sphere–plane | closed-form (via box flat path) | closed-form |
| sphere–convex polyhedron | GJK + EPA (works) | `sphereConvex` via vertex-iteration; works for explicit hulls |
| sphere–trimesh | per-triangle GJK + EPA via decomp | `sphereTrimesh` via BVH triangle query |
| capsule–anything | closed-form for cap-sphere / cap-cap / cap-box | n/a (no capsule shape) |
| convex–convex | GJK + EPA fallback (may degenerate on smooth) | SAT + clip via `ConvexPolyhedron` |
| convex–trimesh | per-triangle GJK + EPA (PLAN.md "drop and settle" tests skipped) | per-triangle SAT + clip (more reliable for polyhedral cases) |
| concave–concave | refused | not supported |
| anything smooth | GJK works for overlap test; EPA degenerates on depth recovery | impossible — needs polyhedral approximation |

The structural picture is: **Cannon trades expressivity for
robustness on its supported shape set**. We trade robustness on
smooth shapes for general support-function-based collision. The cost
of our choice is the EPA-on-smooth-shapes failure mode, which we
mitigate by routing all common pairs through closed-form handlers.
PLAN.md "Limitations / Known caveats" calls this out:

> "EPA on smooth shapes: degenerates (no flat face to converge on).
> Mitigated by closed-form paths for sphere/cube/capsule pairs; exotic
> convex shapes vs spheres can still fail."

### MPR (Minkowski Portal Refinement)

Cannon has nothing comparable. We have `gjk/mpr.js` (XenoCollide,
Snethen GDC 2009) but it's not yet wired into `narrowphase_step` —
PLAN.md "Alternative narrowphase: MPR" calls it a candidate for
falling back when EPA stalls. **Wiring it as the fallback for
EPA non-convergence is a clear next step** — concrete payoff: torus-knot
test (currently skipped), ball-on-curved-surface contact stability.

### Solver — SPOOK vs Baumgarte

Cannon's solver is built on the **SPOOK formulation** described in
Lacoursière's PhD thesis and Erleben's papers. The key equations
(verbatim from `src/equations/Equation.ts.setSpookParams`):

```js
a = 4.0 / (h * (1 + 4 * d));   // bias gain (analogous to Baumgarte β/h)
b = (4.0 * d) / (1 + 4 * d);   // velocity-error gain
eps = 4.0 / (h * h * k * (1 + 4 * d));  // regularisation
```

And the per-iteration update (`GSSolver.solve`):
```js
deltalambda = invC * (B - GWlambda - c.eps * lambdaj);
// then clamped to [minForce*h, maxForce*h]
```

The interesting term is **`c.eps * lambdaj`** — the regularisation
softens the constraint at every iteration in a continuous way, not as
a one-shot velocity bias. This is fundamentally different from
Baumgarte: instead of "if penetration > slop, add a corrective
velocity bias", SPOOK adds a per-iteration drag on the lambda that
makes the constraint behave like a stiff spring with controlled
damping. `eps` scales as `1 / (h² k)` — at our 60 Hz timestep
(`h ≈ 0.0167`) with `k = 1e7`, `eps ≈ 0.024` for `d = 4`. Cannon's
default produces a stable, soft constraint.

Our `solver/solve_contacts.js:442-450`:
```js
let bias = 0;
if (depth > PENETRATION_SLOP) {
  bias = -BAUMGARTE_BETA / dt * (depth - PENETRATION_SLOP);
  if (bias < -MAX_BAUMGARTE_BIAS) bias = -MAX_BAUMGARTE_BIAS;
}
```
This is classic Catto / Box2D-Lite Baumgarte: one-shot velocity bias
proportional to penetration depth minus slop, with a hard cap to stop
EPA-misreported deep penetrations from launching bodies. The cap is
itself an admission that pure Baumgarte is fragile against bad depth
readings — SPOOK's `eps` doesn't have this failure mode because it
doesn't try to fully correct in one tick.

**SPOOK is genuinely more principled and should be considered as a
swap-in.** Concretely:

1. Replace `bias = -BAUMGARTE_BETA / dt * (depth - PENETRATION_SLOP)`
   with `bias = -a * (depth - PENETRATION_SLOP)` where `a` comes from
   `setSpookParams(k, d, dt)`. Choose `k = 1e7` and `d = 4` as
   Cannon does (these are the field-tested defaults).
2. Add the regularisation term to the iteration update — currently
   `lambda_n = -m_eff_n * (vn + bias_n)`, becomes
   `lambda_n = -m_eff_n * (vn + bias_n + eps * j_n_accum)` and
   `m_eff_n = 1 / (k_n + eps)`.
3. The `MAX_BAUMGARTE_BIAS` cap can probably be removed — SPOOK's
   softness handles inflated-depth EPA outputs gracefully because
   the regularisation prevents lambda from running away.

This is the most concrete and highest-confidence improvement
opportunity in the review. **Estimated impact:** tall-stack
stability comparable to Cannon (which handles tens of bodies without
substepping using SPOOK + 10 iterations), removal of the inflated-EPA
launch failure mode, more robust feel without changing the public
API. **Risk:** SPOOK changes contact feel slightly — bodies "settle"
rather than "snap" to non-penetration. PLAN.md's note about TGS
substepping being blocked on split-impulse becomes much less urgent.

### Manifold caching — persistent vs per-frame rebuild

Cannon has **no persistent manifold cache**. Every step,
`Narrowphase.getContacts` rebuilds the contact list from scratch:

```
const oldcontacts = World_step_oldContacts;
for (i = 0; i !== NoldContacts; i++) { oldcontacts.push(contacts[i]); }
contacts.length = 0;
this.narrowphase.getContacts(p1, p2, ..., oldcontacts, ...);
```

The "oldcontacts" array is a **pool of allocated `ContactEquation`
objects** to recycle — pool-via-array-of-instances, not warm-start
information. The per-pair contact identity is rebuilt every frame,
which means **Cannon cannot do per-pair warm-start at all**. It can
warm-start within the solver iteration (lambda persists across
iterations within one frame's solve call) but not across frames.

Our `contact/ManifoldStore.js` is a real persistent manifold cache,
designed exactly like Bullet's `btPersistentManifold`:
- Keyed by canonical body pair via `PairUint32Map`
  (`ManifoldStore.js:114`).
- Up to 4 contacts per slot (`MAX_CONTACTS_PER_MANIFOLD = 4`,
  `ManifoldStore.js:10`).
- Per-contact stride includes `j_n`, `j_t1`, `j_t2` accumulated
  impulses (`ManifoldStore.js:13-33`) that survive across frames.
- Touched / prev_touched / grace flag scheme so transient one-frame
  separations don't evict the manifold (`ManifoldStore.js:57-69`,
  `416-445`).
- `begin_refill(slot)` explicitly preserves impulse fields while
  overwriting geometry (`ManifoldStore.js:265-268`).

And `narrowphase_step.js:837-931` implements **match-and-merge**:
candidates from this frame are matched to previous-frame contacts by
**feature_id first, position-fallback within 2 cm second**, with the
matched prev-contact's impulses copied to the corresponding new slot
index. This is a genuine warm-start that survives:
- Sphere-on-box sliding (feature_id is the box voronoi region,
  `narrowphase_step.js:226-242`, stable while the sphere stays on the
  same face/edge).
- Triangle-decomposition contacts (feature_id is the triangle index,
  stable per-triangle, `narrowphase_step.js:590`).
- Box-box (feature_id = 0, falls back to position matching within
  `MATCH_TOL_SQR = 0.0004 m²`).

**Status correction.** Prior reviews (Bullet, Jolt, Rapier) flagged
"warm-start impulses wiped every frame" — that was true at the time
of those reviews. Reading `ManifoldStore.js` and `narrowphase_step.js`
as they stand today, **warm-start is wired and working** on the
steady-state contact path. The only branches that wipe impulses are
`clear_contacts(slot)` calls (when narrowphase determines no contact
or empty collider lists), which is correct behaviour — separated
contacts should not warm-start. The `clear_impulses(slot, k)` for
unmatched-by-match-and-merge candidates is also correct: a fresh
contact at a slot index with stale data shouldn't inherit it.

This is **a substantial structural advantage over Cannon**. Cannon's
solver pays 10 iterations of convergence from a cold start every
frame; ours starts from the previous frame's solution and typically
converges in 2-3 iterations (with the remaining 7-8 absorbing
geometry changes). For tall stacks specifically, this is the
difference between PGS-with-warm-start being usable and needing TGS
substepping.

**Improvement opportunity:** consider lowering iteration count from
10 to e.g. 6 for steady-state, with a wake-event re-bump to 10 for
the first few frames after wake. The savings would scale with the
warm-start hit rate.

### Sleep — chatter vs atomic

Already covered above. Cannon's per-body sleep chatters on
weakly-connected piles; ours is atomic per island. The improvement
opportunity is in the **other** direction — Cannon's `sleepTick`
hooks `Body.dispatchEvent(Body.sleepyEvent)` /
`Body.dispatchEvent(Body.wakeupEvent)` so user code can subscribe.
We have `Signal onContactBegin/Stay/End` but no `onBodySleep` /
`onBodyWake` events. For game code (turn music off when player
stops, despawn enemies that have been asleep for N seconds), this is
a missing affordance.

### CCD (Continuous Collision Detection)

Cannon has no meaningful CCD — no per-body sweep, no speculative
margin. Fast-moving bodies tunnel.

We have **speculative margin** via the fat-AABB swept extent:
`compute_fat_world_aabb` pads the broadphase AABB by `linearVelocity
* dt`, so the broadphase catches imminent collisions and the
narrowphase sees the future-position pair early. PLAN.md
"Limitations / Known caveats" notes this is "CCD floor only" — the
falling-tower repro at 1 km drop onto 1 cm floor still tunnels 180
out of 1000 bodies because a single-step sweep isn't a true
swept-shape cast. PLAN.md backlog has "Per-body linear CCD
shape-cast" as the planned fix.

**We are categorically ahead of Cannon here.** Even our "CCD floor"
catches everything Cannon misses.

### Concave / triangle mesh

Cannon's `Trimesh` stores vertices in a `Float32Array`, indices in
`Int16Array`, normals in `Float32Array`. The narrowphase uses an
`Octree` to query triangles overlapping the convex's AABB. Each
returned triangle is **treated as a `ConvexPolyhedron`** for the
convex-convex SAT + clip path, which is correctness-wise solid for
polyhedral convex shapes but doesn't help with smooth shapes (which
Cannon doesn't have anyway).

Our `MeshShape3D` + `mesh_enumerate_triangles` does an O(N) linear
scan with tight per-triangle AABB filtering. No BVH per mesh.
**This is genuinely a regression compared to Cannon's Octree** for
large meshes (>10k triangles). PLAN.md doesn't list this as a
backlog item, but should — a per-mesh BVH built once at attach time
is exactly the same machinery as the global broadphase BVH, just
indexed by triangle. **Improvement opportunity: build a per-mesh
triangle BVH in `MeshShape3D` construction (or first-query), query
it instead of linear scanning.**

Both engines have boundary-edge correctness issues at internal
triangle junctions. Cannon's polyhedron-per-triangle treats each
triangle's edges as collision-active, producing the well-known
"phantom contacts on internal edges" bug. Ours has the same problem
plus the EPA-on-degenerate-triangle issue (PLAN.md "Limitations").
Neither engine implements Catto's "voronoi region triangle culling"
fix. **PLAN.md "Closed-form triangle-vs-primitive solvers"** is the
correct prescription here.

### Heightmaps

Cannon's `Heightfield` constructs an explicit convex polyhedron per
cell via `getConvexTrianglePillar(i, j, isUpper)` and caches them in
a `_cachedPillars` dictionary. Cache key is `"i_j_isUpper"` (string!
allocates on every lookup). Each pillar is a 5-faced extruded
triangle that goes from the upper surface down to a fixed depth —
giving the heightfield a "thickness" rather than the infinite-thin
surface our heightmap has.

Our `HeightMapShape3D` is a `Sampler2D`-backed surface; we sample
heights via `sampleChannelCatmullRomUV` and emit triangles
on-demand via `heightmap_enumerate_triangles`. No per-cell convex
caching, no extrusion — the heightmap is a one-sided surface and
"falling through" from below is undefined behaviour (we explicitly
reject contacts where the body is on the wrong side via the
one-sided face-normal check at `narrowphase_step.js:654`).

**Tradeoffs:**

| Aspect | meep heightmap | cannon-es Heightfield |
|---|---|---|
| Memory | O(1) — `Sampler2D` reference + orientation vector | O(cells × cached_pillars) — each pillar is a `ConvexPolyhedron` with 6 vertices, 5 faces |
| Per-step cost | Catmull-Rom sample × 4 per cell × triangles touched | Polyhedron support × triangle count touched (SAT) |
| Smoothness | Catmull-Rom interpolation matches the terrain renderer's geometry | Linear interpolation (raw heights) |
| Two-sidedness | One-sided (per design) | Two-sided (extruded pillar) |
| Allocation hot-path | None (rebound `Triangle3D` flyweight) | `_cachedPillars` string-key dict + Object spread on miss |

The per-step performance is roughly comparable. The Catmull-Rom
sampling we do is more expensive per-cell than Cannon's linear
read, but Cannon's polyhedron-per-cell SAT inflates the constant
factor on the narrowphase side. **Concrete win for us:** zero
allocation on the heightmap hot path; Cannon's string-key dict
allocation is a visible GC pressure source.

### Joints / Constraints

Cannon has a rich constraint library: `PointToPointConstraint`,
`DistanceConstraint`, `HingeConstraint`, `LockConstraint`,
`ConeTwistConstraint`. All are built on `Constraint` (base class)
that holds an array of `Equation` objects, plus a polymorphic
`update()` that recomputes the equations' Jacobians from the current
body poses. The solver iterates contacts AND joint equations
uniformly because they all share the SPOOK formulation.

We have **none**. PLAN.md "Backlog → Features → Joints" lists this
as planned ("distance, hinge, ball-socket, prismatic") and notes
"the solver loop is already set up to iterate contacts ∪ joints;
only constraint pre-step + warm-start hook is missing."

**Reading Cannon's `Constraint` API is the right move for the
joint implementation**. Specifically the SPOOK-equation pattern
makes it trivial to add new joints — each new joint type just
specifies how to compute G, what bounds the lambda has, and what `B`
should be at this pose. If we adopt SPOOK (see solver section) we
get this "joints and contacts are uniform" property for free.

### Queries

Cannon has `World.raycastClosest`, `raycastAny`, `raycastAll` — each
walks all bodies, then per-body iterates `shapes[i]`, then calls the
shape's `raycast` method. The shape-side `raycast` is implemented
per-shape (`Sphere.raycast`, `Box.raycast` via `ConvexPolyhedron`,
`Plane.raycast`, `Heightfield.raycast`, `Trimesh.raycast`). No BVH
traversal — the broadphase isn't consulted for raycasts at all.

Our `queries/raycast.js` traverses both BVHs by AABB-ray test and
reports the nearest hit. Currently the result is the leaf AABB hit,
not refined to the actual shape geometry — so a raycast against a
sphere reports a hit on the bounding cube. PLAN.md "Public queries"
notes this: "narrowphase refinement against the actual shape
geometry is a follow-up — for now `result.t` is the distance to the
leaf's inflated AABB". This is a **legitimate gap vs Cannon** —
ours is broadphase-fast but coarse, Cannon's is exact-shape but
linear-scan. **Improvement opportunity: add per-shape exact-raycast
refinement** after broadphase narrows candidates. The closed-form
sphere/box/capsule cases are trivial; the convex case can use
shape-cast machinery already in place.

`shapeCast` is something **Cannon does not have** in any form. Ours
sweeps a convex shape via swept-AABB broadphase + GJK bisection,
reporting time-of-impact and contact normal recovered via EPA. This
is what character controllers need; Cannon users have to roll their
own from raycasts.

`overlap` similarly has no Cannon equivalent. Ours filters
broadphase candidates by GJK and writes body ids into a caller-sized
`Uint32Array`. Useful for AOE selection, kinematic probes.

---

## 3. In-depth comparison of our code against Cannon

I'll pick eight touchpoints and walk through them.

### 3.1 Per-step body iteration — O(awake) vs O(total)

**Our code, `ecs/PhysicsSystem.js:1083`:**
```js
const count = this.storage.awake_count;
for (let i = 0; i < count; i++) {
  const idx = this.storage.awake_at(i);
  const rb = this.__bodies[idx];
  const tr = this.__transforms[idx];
  integrate_velocity(rb, tr, gx, gy, gz, dt);
}
```

**Cannon's pattern, `src/world/World.ts`:**
```js
for (let i = 0; i !== N; i++) {
  const bi = bodies[i];
  bi.sleepTick(this.time);
  // ... unconditional work happens here, sleep state checked inline
}
```

Cannon's loop visits all `N` bodies regardless of sleep state. The
per-iteration cost is dominated by the field access pattern (cache
miss on cold bodies) + sleep state branch. For a scene with 1M
bodies and 100 awake, Cannon does 1M unconditional loads + 1M
branches; we do 100 indexed loads.

This is **the single most important scalability difference between
the two engines**. At our design target of "millions of mostly
sleeping bodies" Cannon's per-step cost grows linearly with the
total body count, ours with the awake count. A 1000:1 ratio
(realistic for a populated town) is a 1000× speedup.

**Improvement on our side:** none — the structure is correct. **The
risk to keep an eye on:** the per-body collider list lives in
`this.__body_collider_lists[idx]`, a JS array. Each
`compute_fat_world_aabb` call inside Stage 2 indirects through
`list[k].collider.shape.compute_bounding_box(...)`. That's three
pointer-chases per leaf. For a fully-monomorphic hot loop we could
flatten the leaves into a per-body Float64Array of `(node_id,
shape_local_aabb)`. Probably not worth the structural cost until
benchmarks show it dominating.

### 3.2 SPOOK constraint formulation

Side-by-side comparison of the per-iteration update:

**Cannon `GSSolver`:**
```js
deltalambda = invC * (B - GWlambda - c.eps * lambdaj);
```
Where `invC = 1 / (G·M⁻¹·Gᵀ + eps)`, `B = -g*a - GW*b - h*GiMf`, and
`eps` is the SPOOK regularisation.

**Ours `solve_contacts.js:531`:**
```js
const lambda_n = -m_eff_n * (vn + bias_n);
```
Where `m_eff_n = 1 / k_n` (no eps), `bias_n = -BAUMGARTE_BETA/dt *
(depth - slop)` capped at `MAX_BAUMGARTE_BIAS = 3 m/s`, plus
restitution velocity-target if `vn_pre < -RESTITUTION_VELOCITY_THRESHOLD`.

**The qualitative difference:**

- **SPOOK is unconditional and continuous.** The `eps * lambdaj` term
  is present every iteration, regardless of depth. It acts like a
  spring damping. As lambda grows, the regularisation eats into it,
  preventing runaway.
- **Baumgarte is conditional and one-shot.** The bias kicks in only
  above the slop threshold, applies a velocity correction once per
  step, and the `MAX_BAUMGARTE_BIAS` cap is a panic guard against
  EPA returning bad depths.

**Concrete failure mode of our code:** when EPA misreports depth on a
torus knot (PLAN.md mentions this as one of the motivating cases for
MAX_BAUMGARTE_BIAS), our solver caps the bias at 3 m/s. That's
enough to stop the body launching but not enough to actually
resolve a 10cm phantom penetration — the body sinks. SPOOK doesn't
have this dichotomy: the regularisation moderates the response
naturally.

**Recommended action:** when joints land (they need SPOOK or SPOOK-like
structure anyway for the constraint/joint uniformity), refactor the
contact solver to use SPOOK. The pre-step changes:

```js
// In pre-step (where bias is currently computed):
const k_spook = 1e7;                 // stiffness
const d_spook = 4;                   // relaxation
const a_spook = 4 / (dt * (1 + 4 * d_spook));
const b_spook = (4 * d_spook) / (1 + 4 * d_spook);
const eps    = 4 / (dt * dt * k_spook * (1 + 4 * d_spook));
pre[pre_off + 12] = 1 / (k_n + eps); // includes regularisation
pre[pre_off + 15] = -a_spook * Math.max(0, depth - PENETRATION_SLOP);
```

(With a corresponding inner-loop change to subtract `eps * j_n_accum`
from the lambda before clamping.)

This single change probably resolves several existing pain points
(MAX_BAUMGARTE_BIAS cap, restitution-vs-warm-start interaction in the
TGS attempt, mesh-contact phantom-depth launching).

### 3.3 Friction model

**Cannon's `FrictionEquation`:** one-dimensional, along a single
tangent direction. The slipForce passes to `Equation` as bounds
`[-slipForce, slipForce]` where `slipForce = μ * F_normal`.
Cannon generates **two** FrictionEquations per contact (two tangent
directions) so the friction is effectively a polyhedral cone, not a
disk.

**Ours `solve_contacts.js:566-571`:**
```js
const want_t1 = j_t1_accum + lambda_t1;
const want_t2 = j_t2_accum + lambda_t2;
const max_friction = mus[ci] * new_j_n;
friction_cone_clamp(scratch_clamp, 0, want_t1, want_t2, max_friction);
```
Disk clamp in the (t1, t2) tangent plane. Mathematically a true
circular friction cone — strictly more accurate than Cannon's box
clamp.

**Win for us.** The box-vs-disk distinction matters for objects
sliding in directions not aligned with the tangent basis — Cannon
allows up to √2 × μ tangential force on a 45°-sliding object, ours
correctly clamps to μ. The difference is small but observable on
spinning objects on a flat surface.

### 3.4 Restitution + warm-start interaction

**Ours `solve_contacts.js:452-456`:**
```js
if (vn_pre < -RESTITUTION_VELOCITY_THRESHOLD) {
  bias += restitution_combined * vn_pre;
}
```
Restitution is **added to the velocity bias inside the iterative
loop**. Combined with the `j_n ≥ 0` clamp, this is what PLAN.md
identifies as one of three reasons the TGS substep attempt failed:

> "Restitution × warm-start clamp. The j_n ≥ 0 clamp lets substep 1+
> shrink the impulse applied in substep 0, so a restitution=0 ball
> that should stop instead picks up a phantom upward velocity."

**Cannon's `ContactEquation`:**
```js
GW = ePlusOne * vj.dot(n) - ePlusOne * vi.dot(n) + ...
B = -g * a - GW * b - h * GiMf
```
Restitution scales the **closing velocity** in `GW` before computing
the bias. Mathematically identical to ours in the simple case but
structurally different — Cannon's restitution is baked into
`computeB` and rebuilt every step, ours is added on top of
Baumgarte every step. Cannon's GS solver doesn't have the
warm-start-shrinkage problem because there's no warm-start.

**A genuine improvement opportunity:** the Box2D / Box2D-Lite
"split-impulse" pattern that PLAN.md mentions as a prerequisite for
TGS is also the right structure for clean restitution. Restitution
should be a **one-shot impulse at first contact**, not a per-iteration
bias. Bullet does this too. Cannon's approach is less correct (it
applies on every iteration, but starts cold each frame so the
warm-start interaction doesn't bite). When we add SPOOK, this is a
good time to switch to a real restitution model.

### 3.5 ConvexPolyhedron.clipFaceAgainstHull vs box_box_manifold

Both implement Sutherland-Hodgman polygon clipping against a series
of half-spaces. The structural difference:

Cannon's `clipFaceAgainstHull` is **N-gon → N-gon** (handles any
polygon size, output bounded by `numClipFaces × original_size`).
The clip buffer is a JavaScript `Array<Vec3>` that grows / shrinks
with `.push()` and `.shift()` — both allocate-and-copy.

Our `box_box_manifold.js` clips a quad against 4 edges, hard-coded.
Output buffer is a `Float64Array(8 * 3)` allocated once at module load
(`clip_in`, `clip_out` at `box_box_manifold.js:53-54`). Zero
allocation per call.

**Win for us on the box-box case.** For the future ConvexHullShape3D
we'll want to port Cannon's general case, but with our typed-array
allocation discipline rather than `Array.push/shift`. The
key insight from Cannon that we'd otherwise miss: **the inner clip
loop reuses two ping-pong buffers and a swap-on-output pattern**
that minimises the polygon-buffer reallocations to two fixed
`Vec3[]` arrays per clip pass. That pattern translates directly to
two fixed `Float64Array` slabs for us.

### 3.6 Sleep — atomic island vs per-body chatter

Already covered in §1 and §2. Concrete walk-through of the failure:

**Cannon's chatter (paraphrased trace):**
```
Frame N:     bottom block sleepState=SLEEPY, timeLastSleepy=N*dt
Frame N+1:   top block jitters; broadphase pair list includes block above it
             both blocks `wakeUpAfterNarrowphase=true`
             bottom wakes via velocity propagation
Frame N+2:   bottom resleeps (now SLEEPY again)
Frame N+3:   middle blocks similarly wake from a different jitter
...
```

A 100-block stack chatters at ~3-5 fps of sleep-state churn for
seconds before fully settling, if it ever does. The user-observable
effect is GC spikes from per-frame event dispatch plus visible
micro-jitter.

**Our atomic sleep (`ecs/PhysicsSystem.js:942-1006`):**
```js
if (max_v_sqr < threshold_sqr) {
  let min_timer = Infinity;
  for (let i = start; i < end; i++) {
    rb.sleep_timer += dt;
    if (rb.sleep_timer < min_timer) min_timer = rb.sleep_timer;
  }
  if (min_timer >= time_threshold) {
    this.__atomic_sleep_island_range(body_data, start, end);
  }
}
```
The whole island either accumulates time or doesn't. When it crosses
the threshold, every member sleeps the same step, and the
sleep-group circular DLL connects them so a single wake event
walks the chain.

**Verbatim from PLAN.md's "Done" section:** "Per-island atomic sleep:
... Replaces the per-body chatter on weakly-connected piles." This
is one of the engineering claims most clearly validated by the
Cannon comparison — the algorithm Cannon ships has the exact failure
mode we explicitly engineered around.

### 3.7 Per-pair narrowphase dispatch

Both engines look superficially similar — a cascade of pair-type
checks dispatching to per-pair contact methods. The differences:

**Cannon `Narrowphase.getContacts`:** dispatch table keyed by shape
type enum (Sphere=1, Box=4, Plane=8, etc.). Lookup pattern:
```js
const resolverIndex = (si.type | sj.type);
// uses bit-OR of shape type enums as table key
```
Method-per-pair on the `Narrowphase` class (`sphereSphere`,
`sphereBox`, `sphereConvex`, `convexConvex`, `planeBox`,
`planeConvex`, `planeTrimesh`, `sphereTrimesh`, `convexTrimesh`,
`sphereHeightfield`, `convexHeightfield`, `sphereParticle`,
`planeParticle`, `convexParticle`, `boxConvex`, ...).

**Ours `narrowphase_step.js:dispatch_pair`:** cascade of
`if (isSphereA && isSphereB)`, `if ((isSphereA && isBoxB) || ...)`,
etc. — handled as boolean checks on `shape.isXxx === true` flags.

**Tradeoff:**

- Cannon's table is O(1) dispatch but allocates an integer combination
  per call and has unmaintainable enum-OR collision risk. It also
  forces all narrowphase to live on one class instance.
- Ours is O(K) cascade where K = pair categories handled (currently
  ~7). At small K cascade is faster than table lookup; the
  isShapeType branch predicts well after the first hit. At large K
  the table wins.

**Both lose to a real double-dispatch pattern**, but neither is bad
enough to fix yet.

**Improvement opportunity for us:** the cascade in `dispatch_pair`
checks `isSphereA && isBoxB || isBoxA && isSphereB` which always
runs both halves of the `||` even after a hit. Rearranging by
expected frequency (sphere-sphere first if that's the dominant pair,
which it is for ball-based games) gets us closer to one-branch fast
path for the common case. Probably <1% real-world win; not urgent.

### 3.8 Allocation patterns

**Cannon's allocation hot-spots that cannon-es fixed vs original
cannon.js:**

cannon-es is the result of years of de-allocation work compared to
the original. Notable fixes documented in the changelog / source
comments:

- Module-level `tmpVec`, `tmpQuat`, `Body_applyForce_rotForce`,
  `Body_applyImpulse_velo` etc. scratch instances declared once per
  module, reused everywhere. Original cannon.js allocated these
  per-call.
- `World_step_oldContacts` and `World_step_oldFrictionEquations`
  arrays reused frame-to-frame, with `.length = 0` + push pattern.
- `SAPBroadphase` insertion-sort with persistent `axisList`.

**Where Cannon STILL allocates per-step** (and we don't):
- `Narrowphase.getContacts` uses `Array.push` extensively for the
  output contact list. Even with the `contactPointPool` recycling
  the `ContactEquation` instances, the outer array grows via `push`
  and resets via `.length = 0`. This is **the** cannon-es per-frame
  allocation hot path. Our equivalent — writing into a fixed
  `Float64Array` slab via `set_contact(slot, idx, ...)` — has zero
  steady-state allocation.
- `SplitSolver` BFS over equations builds a fresh `nodes` /
  `children` graph each call. Pool exists but graph-shape changes
  defeat it.
- `Heightfield._cachedPillars` uses string keys (`"i_j_isUpper"`) —
  every lookup allocates a string. Hot path on heightmap collision.

**JS-specific optimisation insights from cannon-es worth absorbing:**

1. **Module-level scratch instances declared in a uniform pattern.**
   The Cannon convention is `const Body_methodName_purpose = new
   Vec3()`. Naming makes the scratch's owner and intent obvious. We
   do the same with prefixed `scratch_*` names; cannon's discipline
   is more enforced. The convention is good — names like
   `scratch_inertia_a` / `scratch_inertia_b` in our solver follow
   the pattern correctly. A grep for `const scratch_` shows we're
   consistent.

2. **Body field initialisation order matters for V8 hidden classes.**
   Both engines initialise fields in the constructor (or as class
   fields) in a fixed order, ensuring all `RigidBody` / `Body`
   instances share the same hidden class. Our class-field syntax
   (`linearVelocity = new Vector3(0, 0, 0)` at class-body scope) is
   the modern equivalent of Cannon's constructor assignments. Both
   approaches achieve hidden-class consistency. **Risk:** if any
   call site does `body.somethingNew = value`, V8 transitions to a
   new hidden class. We should keep RigidBody field-set strictly
   closed; users wanting extra state should attach side components.

3. **Avoid Float32Array for hot constraint data.** cannon-es uses
   `Vec3` (3 numbers as object fields, not typed array). Vec3 access
   is faster than Float32Array indexing on V8 (no bounds check). But
   for **bulk solver state** that lives in slabs (our manifold
   `data_buffer`, our `scratch_pre` per-contact stride), Float64Array
   is correct — bulk reads / writes by offset compile to direct
   memory operations. The lesson is "use Vec3-like for individual
   vectors, TypedArray for bulk slabs". We follow this pattern
   correctly.

4. **Avoid object-spread / Object.assign on hot path.** Cannon has a
   couple of places (in Equation reset code) that do `Object.assign`
   on equation instances — measurable per-frame cost. We don't have
   any such patterns; worth a periodic audit.

5. **Use shift-by-N on bit-packed flags rather than separate
   booleans.** Our `ManifoldStore` packs `count | touched |
   prev_touched | grace` into one Uint32 word
   (`ManifoldStore.js:57-61`). Cannon uses separate boolean fields
   on `Body` (`allowSleep`, `wakeUpAfterNarrowphase`, etc.) — fine
   for code clarity but more memory per body. At million-body
   scale our bit-packing saves 4 bytes per slot × N slots.

### 3.9 Numerical stability — degenerate cases

**Cannon's degenerate-case handling:**

- `findSeparatingAxis` skips edge-cross axes where the cross is
  almost zero (`if (!Cross.almostZero())`). Prevents NaN normals on
  parallel edge configurations.
- `ConvexPolyhedron` constructor warns if a face normal points
  inward (`console.error` — doesn't fix).
- `Trimesh` validates index range when computing per-triangle
  normals.

**Ours:**

- `narrowphase_step.js:728`: `if (!(depth > 0) || !Number.isFinite(depth))
  return count;` — rejects NaN/Inf/zero depths from EPA. Good.
- `narrowphase_step.js:620`: same check inside the concave loop.
- `solve_contacts.js:151, 261`: `inv_mass_of` and
  `angular_jacobian_contribution` guard against zero mass /
  inertia. Good.
- **Missing:** no zero-area triangle guard in `mesh_enumerate_triangles`
  or `heightmap_enumerate_triangles`. A degenerate triangle (collinear
  vertices) would feed a zero face normal into GJK / EPA and the
  one-sided rejection at `narrowphase_step.js:654` would silently
  skip it. **Probably fine in practice** — degenerate triangles in a
  source mesh are usually filtered at import — but worth a paranoid
  guard.
- `solver/solve_contacts.js:418-420` guards K=0 with
  `k_n > 0 ? 1 / k_n : 0` — prevents NaN m_eff on two
  zero-mass bodies (kinematic-vs-kinematic, which the solver
  should never see, but defensively).

**Concrete improvement opportunity:** add `if (Math.abs(face_normal_sq) <
EPS) continue;` at `narrowphase_step.js:599` (after computing
`fnx_l/fny_l/fnz_l` in the triangle loop). This rejects degenerate
triangles before they reach EPA.

---

## 4. Simplicity & uniformity

### Code organisation

| Aspect | meep | cannon-es |
|---|---|---|
| Module layout | One concept per file, hierarchical directories (`body/`, `broadphase/`, `solver/`, `gjk/`, `narrowphase/`, ...) | Mostly one class per file, flat-ish (`src/objects/`, `src/collision/`, `src/equations/`, `src/solver/`, `src/shapes/`) |
| Class structure | Mostly free functions + a few classes (Storage, ManifoldStore, IslandBuilder, PhysicsSystem) | Class hierarchy everywhere (Body, Shape→Sphere/Box/..., Equation→ContactEquation/FrictionEquation, Constraint→PointToPoint/Distance/...) |
| Public API surface | Concentrated on `PhysicsSystem` | Spread across World + Body + Shape + Constraint subclasses |
| File count | ~50 files in `physics/` (excluding tests) | ~50 files in cannon-es `src/` |
| LOC | Roughly comparable; we have more in the broadphase + decomposition machinery |

Both engines are flat and avoid deep hierarchies (unlike Bullet's
`btCollisionObject < btRigidBody < btSoftBody` etc.). Cannon does
have one inheritance chain that matters: `Shape` → `Sphere`, `Box`
(which is a `ConvexPolyhedron` subclass), `Plane`, `Heightfield`,
`Particle`, `ConvexPolyhedron`, `Trimesh`. The `Shape` base class
exposes `volume()`, `boundingSphereRadius`, `updateBoundingSphereRadius()`,
`calculateLocalInertia()`, `calculateWorldAABB()` — virtual methods
that every shape overrides. Adding a new shape means subclassing
`Shape` and implementing 5 virtuals.

Our `AbstractShape3D` plays the equivalent role. New shape = same
contract.

### Adding a new shape pair — workflow

**Cannon:** add a new method on `Narrowphase`, register it in the
dispatch table. Add the shape class as a `Shape` subclass.

**Ours:** add a new closed-form file in `narrowphase/`, add a branch
to the `dispatch_pair` cascade in `narrowphase_step.js`. Add the
shape class as an `AbstractShape3D` implementation.

**Equivalent friction.** Adding capsule-mesh would be the same
amount of work in either engine.

### Adding a new constraint

**Cannon:** subclass `Constraint`, instantiate the Equations it
owns, implement `update()` to recompute Jacobians. SPOOK + GSSolver
handle the rest.

**Ours:** **not currently supported**. The solver knows about
contacts only; adding joints requires extending `solve_island` to
iterate a `joints` list parallel to contacts. PLAN.md says this is
planned but not done.

**Cannon's design is the right reference.** The "constraints and
contacts are both Equations" abstraction is what makes Cannon's
joint library so flexible. Adopting SPOOK structurally enables
copying this design.

### Adding a new query

**Cannon:** implement per-shape `raycast` method; `World.raycastClosest`
iterates all bodies + shapes.

**Ours:** add a query function in `queries/`. Use the BVH for
broadphase, dispatch per-shape narrowphase.

**We win on structure** — queries live in their own subsystem and
share the broadphase. Cannon's per-shape `raycast` is duplicated
across every shape class and bypasses the broadphase entirely.

### Where Cannon's `Body` god-class wins

Despite our decomposition argument, **Cannon's single-class `Body`
makes some things simpler**:
- One reference to pass around (`body`).
- `addEventListener('sleep', ...)` etc. for application code.
- `body.applyForce(force, worldPoint)` reads naturally.

Our `system.applyForceAt(rigidBody, transform, force, worldPoint)`
needs four arguments because force application needs the transform
(for the r = worldPoint - position calculation) and the system
(for the wake call), and the rigid body itself. The system-level
API is necessarily more verbose. **This is the unavoidable cost of
ECS decomposition** — we picked it for good reasons (transform is
shared with rendering, components are independently
serialisable / observable) and the price is API verbosity.

### Where Cannon's API is a cautionary tale

1. **`world.bodies` is a public mutable array.** Users mutate it
   directly, which violates every encapsulation invariant. We
   correctly hide `__bodies` and expose only `entityOf(packed_id)`.

2. **Body events fire via `dispatchEvent` on the Body itself.** This
   means every Body has its own listener list. At 100k bodies that's
   100k tiny listener arrays. We use Signal-per-system + per-entity
   event channel (`PhysicsEvents`), which scales better.

3. **`World.contacts` rebuilt every frame as a fresh array.** Visible
   GC pressure. Our `ManifoldStore` is persistent and slab-based.

4. **`Heightfield._cachedPillars` string-key dict.** Allocates per
   lookup. Our `HeightMapShape3D` has no equivalent caching layer —
   it samples and emits triangles on-demand into a flyweight, zero
   allocation.

5. **`ConvexPolyhedron.findSeparatingAxis` allocates `Vec3`
   intermediates** despite cannon-es's de-allocation pass. Our
   `box_box_manifold.js` is entirely typed-array, zero allocation.

### Where we could simplify further

- The cascade of `is*A && is*B || is*A && is*B` in `dispatch_pair`
  has each pair-type check duplicated for ordering. A small enum +
  pair-table would compress this without sacrificing inlining. But
  it's not urgent — the cascade is still readable at 7 pair types.

- `narrowphase_step.js` has the SAT/clip box-box code linked but the
  general convex-hull case TODO. When we add `ConvexHullShape3D`,
  the box-box code wants to be subsumed into the general path
  rather than maintained as a parallel implementation. Don't add
  ConvexHull without refactoring box-box at the same time, or we'll
  have two copies of essentially the same algorithm.

- `solve_contacts.js` has separate friction and normal blocks that
  could be unified under SPOOK's "every equation is the same shape"
  model. This is the joint-system prerequisite anyway.

---

## Top recommendations

In rough priority order:

1. **Adopt SPOOK constraint regularisation** in the solver
   (`solver/solve_contacts.js`). Removes the `MAX_BAUMGARTE_BIAS`
   hack, improves tall-stack stability comparable to Cannon, and is
   the structural prerequisite for clean joint implementation.
   Estimated effort: medium (one solver rewrite, no public API
   change). Estimated payoff: high.

2. **Wire MPR as the EPA-non-convergence fallback** in
   `narrowphase_step.js`. Code exists at `gjk/mpr.js` per PLAN.md;
   one branch on EPA's NaN/zero-depth path swaps it in. Unblocks the
   torus-knot test and the smooth-shape contact stability issue.
   Effort: low. Payoff: high for smooth-shape cases.

3. **Add per-shape exact raycast refinement** after BVH broadphase
   (`queries/raycast.js`). Currently returns leaf-AABB hit which
   underestimates `t` for non-AABB shapes. Cannon's per-shape
   raycast methods are the reference. Effort: medium. Payoff:
   makes the raycast query genuinely useful for character
   controllers / weapon hit detection.

4. **Build per-mesh triangle BVH for `MeshShape3D`**. Cannon's
   `Trimesh.tree` is the reference (Octree there; BVH for us since
   we have the infrastructure). Replace the O(N) linear scan in
   `mesh_enumerate_triangles`. Effort: low (BVH is in
   `core/bvh2/`). Payoff: linear-to-log mesh scaling, opens the door
   to genuinely large static-mesh worlds.

5. **Closed-form triangle-vs-primitive** narrowphase (already on
   PLAN.md backlog — re-emphasising because it's the gating issue
   for several skipped tests). Cannon's "treat triangle as
   ConvexPolyhedron and SAT it" works but has the same internal-edge
   phantom contact issue we have. Catto's voronoi triangle
   classification is the standard fix.

6. **Add `onBodySleep` / `onBodyWake` signals** on `PhysicsSystem`.
   Cannon's per-body events are the wrong scale (event listener per
   body), but a system-level signal carrying island membership is
   useful for game code. Effort: trivial. Payoff: gameplay-quality
   of life.

7. **Consider lowering default solver iterations from 10 to 6 once
   warm-start is verified stable in production.** Cannon defaults
   to 10 because it can't warm-start. Ours can; the savings scale
   with warm-start hit rate (typically >80% in steady-state
   contacts). Effort: trivial (one constant). Validation: medium
   (need a stack-stability benchmark sweep).

8. **Audit `mesh_enumerate_triangles` for zero-area triangle
   handling.** Add an `Math.abs(face_normal_sq) < EPS` skip. Cheap
   defensive guard. Effort: trivial.

---

## Summary

Cannon is our closest peer on engineering constraints (pure JS,
single-threaded, no SIMD) and the comparison is consistently
favourable. **Where we are clearly ahead:**
- Broadphase (BVH × 2 vs default NaiveBroadphase O(N²))
- Body iteration scaling (O(awake) vs O(total))
- Atomic island sleep + atomic wake (vs per-body chatter on weakly-connected piles)
- Persistent manifold cache with cross-frame warm-start (vs frame-rebuilt contact list)
- CCD floor via speculative margin (vs nothing)
- Disk-clamped friction cone (vs polyhedral box clamp)
- Zero-allocation hot paths in narrowphase + heightmap (vs string-key dict, Array.push)
- shapeCast + overlap queries (Cannon has neither)
- ECS decomposition + observability hooks

**Where Cannon is ahead or has the better idea:**
- SPOOK constraint formulation (vs our Baumgarte + cap hack)
- Joints / Constraints library (we have none)
- General convex polyhedron shape (we have only `BoxShape3D`)
- Per-shape exact raycast (vs our AABB-only result)
- Per-mesh BVH (Octree) for large triangle meshes (vs our O(N) linear scan)
- Per-body sleep/wake event listeners (we have signals at system level only)

**Headline structural insight:** Cannon is "what JS rigid-body
physics looked like in 2014, slowly de-allocated by community
maintenance to 2023." We are "what JS rigid-body physics could be in
2026 if you start from the modern reference architectures
(Jolt/Bullet/Box2D) and then constrain to pure JS." The
ecosystem-position is genuinely interesting — there's no other
pure-JS engine attempting our design ambitions.
