Rapier Integration

Rapier2D (@dimforge/rapier2d-compat) is the collision and contact solver for the live arena. It owns the player ship’s dynamic body, all terrain colliders, sealed-arena walls, and per-enemy contact sensors. Bullets stay on the existing spatial grid and are NOT in Rapier.

World

The world is a singleton in engine/physics/rapier-world.ts.

  • Zero-gravity ({ x: 0, y: 0 }) — space, no fall.
  • One RAPIER.World plus one RAPIER.EventQueue (created with collision events enabled).
  • Async init() boots the WASM module, then constructs the world. Safe to call multiple times.
  • cleanup() frees both queue and world. rebuild() is the synchronous recovery path used to recover from the WASM “recursive use of an object” borrow-lock (Sentry STARSHIPSURVIVORS-C); WASM stays loaded so it doesn’t need to be async, but every previously-handed-out body/collider handle becomes invalid.

Fixed timestep

The step accumulator runs at FIXED_DT = 1/60 seconds.

  • Each frame, the renderer passes its dt to step(dt). The accumulator advances and Rapier steps in fixed 1/60s increments until the accumulator drains below one step.
  • The accumulator is capped at MAX_ACCUMULATED = 0.1 (six steps) to prevent spiral-of-death after long frames.
  • step() returns an interpolation alpha (0..1) representing the leftover fraction. Renderers use this to lerp between previous and current physics positions for smoothness.
  • resetAccumulator() is called on tab-background gaps over ~200ms so the world doesn’t burst-catch-up when focus returns.
  • getLastStepCount() exposes how many fixed steps ran in the latest frame for debug HUD.

Ship body

engine/physics/rapier-ship.ts owns the player’s RAPIER.RigidBody.

  • Dynamic body with rotations locked (lockRotations()) — the game still controls ship.angle via turnSpeed; Rapier only solves translation + collision response.
  • Convex hull collider built from hullVerts (unit-space [-1,1]) scaled by hullScale. If convexHull returns null (degenerate polygon), it falls back to a ball of radius hullScale * 0.5.
  • CCD enabled (setCcdEnabled(true)) to prevent tunneling at high boost speeds.
  • Linear damping = 0 — drag is computed by the game’s movement code, not Rapier.
  • Restitution default 0.09 with CoefficientCombineRule.Max (9% bounce — matches the legacy hand-rolled bounce response). Friction default 0.01.
  • Active events include COLLISION_EVENTS | CONTACT_FORCE_EVENTS with a contact-force threshold of 0.0 so every contact is reported.

Sync each frame

  1. Movement code (thrust, drag, heat-boost, speed-cap) computes velocity and stores it on game.ship.
  2. syncToRapier(vx, vy, angle) writes that velocity (setLinvel) and angle (setRotation) onto the Rapier body.
  3. RapierWorld.step(dt) runs.
  4. syncFromRapier(alpha) reads the body’s translation + linvel back, lerps prev → curr by alpha, and returns { x, y, vx, vy } for the game state.

Teleports

  • teleport(x, y) — sets position and zeros velocity (hub teleport, death reset, level transition).
  • teleportKeepVelocity(x, y) — sets position only (portal boundary, push-out).
  • setLinvel(vx, vy) — direct velocity override (speed bleed, speed boost).

Hot-swappable hull

updateCollider(hullVerts, hullScale, restitution, friction) rebuilds just the collider (preserving the body) when the ship hull polygon changes — used by the ship-playground swap path.

setRestitution, setFriction, setCcdEnabled are live-tuning hooks for the playground PhysicsTab.

Terrain colliders

engine/physics/rapier-terrain.ts owns the static side of the world. Three separate maps so teardowns can target one layer without touching others:

MapOwnerWhen
terrainBodiesbiome asteroids / pillars / wallsadded on chunk load, removed on chunk unload
floaterBodiesfloating destructiblessame lifecycle as terrain pieces, separate map
arenaWallBodiessealed-arena boss-room wallsadded when arena seals, cleared on teardown / level advance

All terrain bodies are RAPIER.RigidBodyDesc.fixed() (immovable). Their colliders are convex hulls built from each piece’s pixel-traced worldVerts (flattened {x,y} array → Float32Array). If convexHull rejects the polygon, the piece falls back to a ball(boundingRadius || radius || 30) collider.

Terrain shares the ship’s restitution / friction defaults (0.09 / 0.01) so collisions feel symmetric. Sealed-arena walls use restitution=0, friction=0 with CoefficientCombineRule.Min — walls absorb velocity rather than punting entities back through the player.

  • addPiece(piece) / removePiece(piece) for biome terrain.
  • addFloater(floater) / removeFloater(floater) for floaters.
  • addArenaWall(cx, cy, halfW, halfH) cuboid walls for sealed-arena boss rooms.
  • clear() removes terrain + floaters + arena walls in one pass (level transition).
  • clearArenaWalls() targeted teardown for just the arena layer.
  • setAllRestitution(v) / setAllFriction(v) walk every existing collider for live playground tuning.
  • setDefaults(restitution, friction) sets values for newly-added pieces.

Enemy sensors

engine/physics/rapier-enemies.ts is intersection-only — enemies never push the ship via Rapier. The existing push-apart logic in collision-resolver handles physical response; Rapier sensors just trigger the damage event.

  • Each alive enemy gets a kinematicPositionBased body with a single ball(radius) collider that has setSensor(true) — overlap is reported, no contact force is applied.
  • setActiveEvents(COLLISION_EVENTS) so overlap start/end fires through the world event queue.
  • Positions are updated each frame via setNextKinematicTranslation({ x: e.x, y: e.y }).
  • A reverse map handleToEnemy: number → enemy is maintained so the event-processing pass can look up which enemy a collider handle belongs to.

Filter rules

Enemies that don’t participate in a given frame are skipped:

  • !e.alive
  • e._spawnT > 0.15 — still inside spawn immunity
  • e._frozenForLag — frame-rate-aware freeze
  • e._dying — in death animation

If an enemy has a Rapier body but is no longer in the “alive this frame” set, the body and handle entry are removed at the end of the sync pass.

Collision groups

rapier-ship.ts exports three packed (membership | filter) masks:

GroupConstantMember bitFilter bits
ShipCOLLISION_GROUP_SHIP (0x00010006)0x00010x0006 (terrain + enemy)
TerrainCOLLISION_GROUP_TERRAIN (0x00020001)0x00020x0001 (ship)
EnemyCOLLISION_GROUP_ENEMY (0x00040001)0x00040x0001 (ship)

Net effect: ship collides with terrain and enemies; terrain and enemies do not collide with each other (or themselves). Bullets bypass Rapier entirely.

Debug render

engine/physics/rapier-debug-render.ts provides an opt-in overlay toggled from the ship playground’s PhysicsTab.

  • setEnabled(bool) flips global rendering.
  • Options: showColliders, showContacts, showVelocity — toggled independently.
  • render(ctx, camX, camY, camScale) draws collider outlines, contact points, and velocity vectors directly onto the camera-transformed game canvas in world space.
  • engine/physics/index.ts — barrel re-exports.
  • engine/physics/collision.ts — bullet vs terrain/enemy hit-test (spatial grid, not Rapier).
  • engine/physics/movement.ts — pre-Rapier thrust / drag / speed-cap computation.
  • gameplay/concepts/collision-modes.md — bullet collision behavior.
  • gameplay/concepts/sealed-arena-geometry.md — boss-room walls fed into Rapier via addArenaWall.