PURPOSE
Singleton wrapper around a Rapier2D physics world for player-ship collision. Owns the WASM-backed RAPIER.World plus its EventQueue, runs a fixed-timestep accumulator at 1/60s, and returns an interpolation alpha so rendering can blend between physics states. Built for a zero-gravity space setting — gravity vector is { x: 0, y: 0 }.
OWNS
- The singleton
RAPIER.Worldinstance (module-levelworld). - The singleton
RAPIER.EventQueue(with contact events enabled). - The fixed-timestep
accumulator(seconds of unsimulated time). - The
initializedflag andlastStepCountdebug counter. - The
FIXED_DTconstant (1 / 60) andMAX_ACCUMULATEDcap (0.1seconds, i.e. up to 6 steps per frame). - Lifecycle of all native handles — every
world.free()andeventQueue.free()call goes through this module.
READS FROM
@dimforge/rapier2d-compat— the Rapier WASM module;RAPIER.init(),RAPIER.World, andRAPIER.EventQueueconstructors.- Frame
dtpassed in by the caller ofstep(dt).
PUSHES TO
- The Rapier WASM world itself —
world.step(eventQueue)advances the simulation in fixed increments. - The returned interpolation alpha (
accumulator / FIXED_DT, in the range[0, 1)) — consumed by the caller for render-side interpolation between physics states. lastStepCount— exposed viagetLastStepCount()for debug display.
DOES NOT
- Does not create or own any
RigidBodyorCollider— terrain, ship, and enemies build their own bodies againstgetWorld(). - Does not simulate bullets — bullets remain on the legacy spatial grid (called out in the file header).
- Does not apply gravity — world is constructed with zero gravity.
- Does not handle collision callbacks itself — it exposes the
EventQueuefor callers to drain. - Does not call its own
init()lazily —step()returns0silently ifworld/eventQueueare null. - Does not persist or restore state —
cleanup()andrebuild()both invalidate every outstanding handle.
Signals
step(dt)returns the interpolation alpha as a number in[0, 1).getLastStepCount()returns how many fixed steps ran during the most recentstep()call (0 whendtwas smaller thanFIXED_DT, up to 6 when the accumulator hitMAX_ACCUMULATED).isReady()returns true onceinit()has completed and the world has not been torn down.getWorld()andgetEventQueue()throwError('RapierWorld not initialized — call init() first')/Error('RapierWorld not initialized')if called beforeinit().
Entry points
init(): Promise<void>— awaitsRAPIER.init(), constructs the world and event queue, resets the accumulator, setsinitialized = true. Idempotent: re-entry while already initialized is a no-op.getWorld(): RAPIER.World— accessor; throws if not initialized.getEventQueue(): RAPIER.EventQueue— accessor; throws if not initialized.step(dt: number): number— addsdtto the accumulator, clamps toMAX_ACCUMULATED, drains the accumulator inFIXED_DTslices callingworld.step(eventQueue)each iteration, returnsaccumulator / FIXED_DT.resetAccumulator(): void— zeros the accumulator. Intended for tab-resume after a >200ms gap to suppress a catch-up burst.getLastStepCount(): number— debug accessor for the most recent step batch size.cleanup(): void— frees event queue and world, zeros accumulator and step count, clearsinitialized.rebuild(): void— synchronous teardown-and-recreate of world + event queue, leaving the WASM module loaded. No-op when not initialized. Recovery path for a stuck “recursive use of an object” WASM borrow-lock (Sentry STARSHIPSURVIVORS-C; observed on the boss → portal → tier-advance pathway on mobile Chrome).isReady(): boolean— composite ofinitializedandworld !== null.FIXED_DT— re-exported numeric constant (1 / 60).
Pattern notes
- Singleton via module state. All mutable state (
world,eventQueue,accumulator,initialized,lastStepCount) lives at module scope;RapierWorldis a frozen-shape object literal of functions over that state. There is no class, no constructor, no instance to pass around. - Fixed-timestep accumulator with spiral-of-death cap.
accumulator += dt, clamped toMAX_ACCUMULATED = 0.1, then drained inFIXED_DT = 1/60slices in awhileloop. The cap bounds at most 6 physics steps per frame regardless of how large the incomingdtis. - Interpolation alpha as return value. The leftover fractional step (
accumulator / FIXED_DT) is returned for the caller to use when blending render transforms between the previous and next physics state. - Handle invalidation contract. Both
cleanup()andrebuild()calleventQueue.free()thenworld.free(). EveryRigidBodyandColliderhandle held outside this module becomes invalid the instant either runs — the file header explicitly calls this out forrebuild(). - Rebuild as borrow-lock recovery.
rebuild()exists specifically to recover from a Rapier WASM “recursive use of an object” panic without an async WASM reload — the module stays loaded, only the world and queue are recreated. Tagged to Sentry issue STARSHIPSURVIVORS-C. - Zero gravity is hard-coded. Both
init()andrebuild()constructnew RAPIER.World({ x: 0, y: 0 })— there is no gravity parameter on the public API. - Defensive throws on accessors, silent skip on
step().getWorld()andgetEventQueue()throw with a “call init() first” message, butstep()returns0and does nothing when uninitialized — matching the “crash internally, fall back at boundaries” project rule for a per-frame entry point. - Contact events enabled.
new RAPIER.EventQueue(true)constructs the queue with contact event reporting on, so collision listeners can drain it afterstep().