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.World instance (module-level world).
  • The singleton RAPIER.EventQueue (with contact events enabled).
  • The fixed-timestep accumulator (seconds of unsimulated time).
  • The initialized flag and lastStepCount debug counter.
  • The FIXED_DT constant (1 / 60) and MAX_ACCUMULATED cap (0.1 seconds, i.e. up to 6 steps per frame).
  • Lifecycle of all native handles — every world.free() and eventQueue.free() call goes through this module.

READS FROM

  • @dimforge/rapier2d-compat — the Rapier WASM module; RAPIER.init(), RAPIER.World, and RAPIER.EventQueue constructors.
  • Frame dt passed in by the caller of step(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 via getLastStepCount() for debug display.

DOES NOT

  • Does not create or own any RigidBody or Collider — terrain, ship, and enemies build their own bodies against getWorld().
  • 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 EventQueue for callers to drain.
  • Does not call its own init() lazily — step() returns 0 silently if world/eventQueue are null.
  • Does not persist or restore state — cleanup() and rebuild() 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 recent step() call (0 when dt was smaller than FIXED_DT, up to 6 when the accumulator hit MAX_ACCUMULATED).
  • isReady() returns true once init() has completed and the world has not been torn down.
  • getWorld() and getEventQueue() throw Error('RapierWorld not initialized — call init() first') / Error('RapierWorld not initialized') if called before init().

Entry points

  • init(): Promise<void> — awaits RAPIER.init(), constructs the world and event queue, resets the accumulator, sets initialized = 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 — adds dt to the accumulator, clamps to MAX_ACCUMULATED, drains the accumulator in FIXED_DT slices calling world.step(eventQueue) each iteration, returns accumulator / 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, clears initialized.
  • 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 of initialized and world !== 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; RapierWorld is 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 to MAX_ACCUMULATED = 0.1, then drained in FIXED_DT = 1/60 slices in a while loop. The cap bounds at most 6 physics steps per frame regardless of how large the incoming dt is.
  • 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() and rebuild() call eventQueue.free() then world.free(). Every RigidBody and Collider handle held outside this module becomes invalid the instant either runs — the file header explicitly calls this out for rebuild().
  • 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() and rebuild() construct new RAPIER.World({ x: 0, y: 0 }) — there is no gravity parameter on the public API.
  • Defensive throws on accessors, silent skip on step(). getWorld() and getEventQueue() throw with a “call init() first” message, but step() returns 0 and 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 after step().