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.Worldplus oneRAPIER.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
dttostep(dt). The accumulator advances and Rapier steps in fixed1/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 controlsship.angleviaturnSpeed; Rapier only solves translation + collision response. - Convex hull collider built from
hullVerts(unit-space[-1,1]) scaled byhullScale. IfconvexHullreturns null (degenerate polygon), it falls back to a ball of radiushullScale * 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_EVENTSwith a contact-force threshold of0.0so every contact is reported.
Sync each frame
- Movement code (thrust, drag, heat-boost, speed-cap) computes velocity and stores it on
game.ship. syncToRapier(vx, vy, angle)writes that velocity (setLinvel) and angle (setRotation) onto the Rapier body.RapierWorld.step(dt)runs.syncFromRapier(alpha)reads the body’s translation + linvel back, lerpsprev → currbyalpha, 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:
| Map | Owner | When |
|---|---|---|
terrainBodies | biome asteroids / pillars / walls | added on chunk load, removed on chunk unload |
floaterBodies | floating destructibles | same lifecycle as terrain pieces, separate map |
arenaWallBodies | sealed-arena boss-room walls | added 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
kinematicPositionBasedbody with a singleball(radius)collider that hassetSensor(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 → enemyis 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.alivee._spawnT > 0.15— still inside spawn immunitye._frozenForLag— frame-rate-aware freezee._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:
| Group | Constant | Member bit | Filter bits |
|---|---|---|---|
| Ship | COLLISION_GROUP_SHIP (0x00010006) | 0x0001 | 0x0006 (terrain + enemy) |
| Terrain | COLLISION_GROUP_TERRAIN (0x00020001) | 0x0002 | 0x0001 (ship) |
| Enemy | COLLISION_GROUP_ENEMY (0x00040001) | 0x0004 | 0x0001 (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.
Related
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 viaaddArenaWall.