Vol. 07 · Dispatch2026-07-06

Rapier boots async, your engine boots sync — here's the seam

Rapier's wasm loader returns a Promise, but a three.js render loop starts on `new`. If you naively `await RAPIER.init()` in the constructor, you get a black screen while wasm downloads. If you fire off `.then(...)` and forget about it, your first ticks crash on `world.step` because the world doesn't exist yet. Tank Battle threads this needle with a `physReady` flag, a purely-cosmetic pre-physics render loop, and a check-and-wait "deploy" gate.

by MetaWorldOS Engineering
Three.jsRapierWebAssemblyTypeScriptGameDev

TL;DR — RAPIER.init() is async because Rapier is compiled to wasm and the wasm module has to be fetched, compiled, and instantiated. That doesn’t fit a game engine constructor that renders the first frame synchronously. In Tank Battle we handle this in three parts: fire RAPIER.init().then(...) off during the constructor, run a physics-free render loop while it resolves so the atmosphere renders during load, and gate the “deploy the tank” transition on a physReady flag plus a small minimum wait so the loading screen doesn’t flash-and-clear.

Code: src/features/gamecenter/games/tank-battle/engine.ts

Rapier ships as @dimforge/rapier3d-compat, a pure-JS wasm binding. To use it you have to call RAPIER.init() and wait for the returned promise — that’s what fetches, compiles, and instantiates the wasm module. If you skip it, the very first new RAPIER.World(...) throws because the wasm exports aren’t bound yet.

That’s fine when you’re writing an example that is itself async. It’s less fine when you’re building a game engine that goes:

const engine = new TankEngine(canvas)

and immediately begins rendering. new doesn’t await anything.

The three ways this breaks

Trying to await RAPIER.init() in the constructor. You can’t — constructors don’t return promises. You’d have to convert to a factory TankEngine.create(canvas) that returns Promise<TankEngine>, and either every caller starts awaiting or you take on top-level .then() in the mount code. It also blocks the entire construction on wasm download, so the canvas stays blank for the fetch duration.

Firing RAPIER.init().then(...) and using the world before it resolves. You get null-pointer errors from every world.step, createRigidBody, and castRay in your tick loop, because the world field is null while wasm is downloading. The messages are variously “Cannot read properties of null” or “wasm not initialized yet” depending on how far the promise got.

Waiting for RAPIER.init() before starting the render loop. The canvas stays black while wasm downloads. On a slow connection you get a full second of nothing before anything visual happens, and users think the page is broken.

The right shape is: start the visual side immediately, defer the physics side until wasm is up, gate anything that touches the world on a flag.

The physReady flag

The engine keeps one boolean:

private physReady = false

The constructor kicks off wasm loading and moves on:

constructor(canvas: HTMLCanvasElement) {
  // ...three.js scene, renderer, camera, listeners...

  RAPIER.init().then(() => {
    if (this.disposed) return
    this.initPhysics()
  })

  // Spin the render loop right away — atmosphere is nice to have
  // even before deploy.
  this.lastTs = performance.now()
  this.raf = requestAnimationFrame(this.tick)
}

Note the this.disposed guard inside the .then. If the user unmounts the game while wasm is still loading, we don’t want to spin up a physics world that will immediately be orphaned. This is the moral equivalent of AbortController but for a promise you don’t own the cancellation of.

initPhysics sets the flag last:

private initPhysics() {
  const world = new RAPIER.World({ x: 0, y: -GRAVITY, z: 0 })
  this.world = world
  this.eventQueue = new RAPIER.EventQueue(true)
  // ... createCollider for ground, createRigidBody for the player, etc ...
  this.physReady = true
  this.syncRapierWalls()
}

Setting physReady = true at the end (not the beginning) means everything downstream sees a fully-populated world. syncRapierWalls runs immediately after — it’s the code that reads the array of scene walls that were placed before physics was ready and creates static Rapier colliders for each. Any code path that also depends on physics gates itself on the flag:

private syncRapierWalls() {
  if (!this.physReady || !this.world) return
  // ...
}

updateBullets(dt: number) {
  if (this.physReady && this.world && this.eventQueue) {
    this.world.step(this.eventQueue)
    this.eventQueue.drainCollisionEvents(...)
  }
  // ... visual bullet updates that are safe with or without physics ...
}

The critical property is that the render loop itself never gates on physReady. It runs from the very first requestAnimationFrame — before physics exists. What it renders is: the scene lit and populated, particles animating, wind blowing dust, the player rig sitting at its spawn point but not physically simulated.

That’s what atmosphere-during-load buys you. The player sees the desert immediately. Sun, sand, palm trees, drifting particles. The tank is there, static. Then physics arrives, the world starts stepping, and the player’s deploy UI unlocks.

The “deploy” gate

deploy is what happens when the player clicks the button to start driving:

deploy() {
  if (this.snapshot.phase === 'playing' || this.snapshot.phase === 'loading') return
  this.snapshot.phase = 'loading'
  this.emitState()
  const t0 = performance.now()
  const check = () => {
    if (this.disposed) return
    const elapsed = performance.now() - t0
    if (this.physReady && elapsed >= 300) this.start()
    else setTimeout(check, 60)
  }
  check()
}

Two conditions: physReady is true, AND at least 300ms has passed since the button click. The 300ms is a UX floor — if wasm is already loaded (returning visitor, warm cache), physReady is already true when they click, and without the delay deploy() would skip straight into playing with a visible flash where the loading screen never gets to show. 300ms is enough for the transition to feel deliberate.

The wait is a poll (setTimeout(check, 60)) rather than a Promise chain because physReady becomes true asynchronously from a completely different path (the RAPIER.init().then at the top of the constructor). Building a Promise-based signal for it would mean threading a resolver through — extra machinery for the same behavior.

What renders before physics is ready

The scene isn’t dead during loading. It has:

  • Particles: sand-dust motes that drift on a synthetic wind field, no physics involved
  • The tank rig: geometry, materials, mesh; sitting at its spawn transform
  • Environment: skybox, sun, IBL from a PMREM cubemap, fog
  • Post-processing: bloom, FXAA — running on whatever the frame contains

None of these depend on Rapier. tick calls a renderVisual that draws whatever’s in the scene, updates particle positions from a per-frame sine-based wind, and moves the sun a tiny amount so the shadow angle drifts. The loading state and the playing state look nearly identical except the tank isn’t obeying the physics rig yet.

That’s a design decision, not a limitation. You could render an entirely different loading screen (spinner, progress bar) and swap in the real scene when physics arrives. That’s more code and it flashes more visibly on transition. The “same scene, static tank” approach is smoother and shorter.

What NOT to do

Don’t await inside requestAnimationFrame handlers. A tick handler is called synchronously by the browser at ~60Hz; if you put an await in it, the tick doesn’t complete until the promise resolves, and you drop frames. If you find yourself needing async work per-tick, the async work belongs off-tick — hand it to a worker or a promise chain that updates a flag the tick can read.

Don’t recreate the world on every restart. In Tank Battle, initPhysics runs once. backToConfig and subsequent deploy cycles reset the tank’s pose and clear bullets, but they don’t tear down and recreate world. Rebuilding the whole physics scene per game session would waste real time (Rapier’s scene setup is O(colliders), which is fine but not free) and would create a race window where physReady briefly goes false again. The world outlives individual games.

Don’t forget the this.disposed guard in the .then. If the component unmounts during wasm load — user navigates away, the tab freezes, whatever — the promise still resolves, and initPhysics will try to create a world attached to a Three.js renderer that’s already been disposed. That crashes noisily. if (this.disposed) return is one line and prevents the whole class of bug.

Where the flag stops mattering

Once physReady is true, it stays true for the life of the engine. Every physics-touching method could technically drop the guard at that point, but the guards are cheap and the code stays uniform if they’re always there. Only two places in the file don’t have the guard: the constructor (before init runs) and the dispose path (after teardown). Both are outside the tick loop; both are fine.

The pattern generalizes to any wasm-backed library that ships an async init. DRACOLoader in three.js has the same shape — the decoder wasm is fetched on first use, and if you try to parse a Draco-compressed GLB before the decoder resolves you get a null-deref. Ammo.js, Cannon-es, any of the “download a wasm blob before you can call new” physics engines. The specific shape — flag + guarded methods + immediate render loop — is the durable part.

— Read Next —

Recommended Dispatches

More engineering deep-dives into 3D rendering, physics simulation, and game architecture

Three.js

Three ways Rapier tells you two things touched, and when to use which

Rapier surfaces contact detection through three interfaces on top of the same underlying check — the raw event queue, R3F's `onCollisionEnter` prop, and sensor colliders with `onIntersectionEnter`. Same feature, three ergonomics, different tradeoffs. This post walks through each shape with real call sites from Tank Battle and 3D Race, and where each one belongs.

Read dispatch
Three.js

From a keyboard press to an anchor impulse — Tank Battle's throttle chain

A tank's throttle in Tank Battle is not "one number that becomes engine force". It's a chain — key or joystick input → gear index into GEAR_KMH → target left/right track speeds with a steer differential → slew-limited actual track speeds → per-anchor drive impulses. Auto-shift decides gear from commanded track speed, not from the physics body's velocity, which produces one deliberate quirk. Here's every step.

Read dispatch
Three.js

A four-anchor raycast suspension for a tank in raw Rapier

Tank Battle's chassis rides on four downward raycasts, each of which contributes a spring impulse, a track-drive impulse, and a lateral friction impulse. It's the standard raycast-vehicle pattern from Unity's Wheel Collider or BeamNG, written straight against @dimforge/rapier3d-compat with no vehicle plugin. Here's the whole loop, the constants, the front/rear drive split, and the lateral-friction cliff that makes it feel like a tank.

Read dispatch

احصل على بريد إلكتروني عند نشر مقال جديد — تعمقات هندسية مرة شهريًا تقريبًا.