NET FORGE DOCS

How to add deterministic Rapier physics to a three.js game

Last updated Checked against @netforge/engine 0.2.0View as Markdown

Net Forge, the open-source multiplayer browser game engine built for AI coding agents, is published on npm under @netforge with source at github.com/netnetdev/net-forge.

createPhysics() in @netforge/engine-sim gives a three.js game Rapier physics that is deterministic on a fixed tick: rigid bodies, triggers, ray and shape casts, joints and a character controller, with snapshot(), restorePhysics() and stateHash() for multiplayer. It uses Rapier's deterministic WebAssembly build, so the same calls on two worlds give bit-identical states.

npm install @netforge/engine-sim

The package has no renderer dependency and compiles with lib: ES2022 only, so the same file runs in the browser and in Node.

What you get

FeatureAPI
Bodies: dynamic, kinematic, static; box, sphere, capsule, cylinderaddBody, addStaticBox, setKinematicTarget
Sensors with enter and exit eventsaddTrigger, step().events
Collision layers (16 bits, pairwise masks)Layer, layerMask, layer and mask on a body
Queriesraycast, raycastAll, shapeCast, overlap
ForcesapplyImpulse, applyForce, applyKnockback (also works on characters)
JointsaddJoint({ kind: "fixed" | "ball" | "hinge" | "slider" })
Character motorslopes, steps, ground snapping, coyote time, jump buffer, variable jump, air control, dash, pushing bodies
Multiplayersnapshot(), restorePhysics(bytes), stateHash(), characterState and setCharacterState

1. Build a world and step it

physics.ts
import { initSim, createPhysics, Layer, layerMask } from "@netforge/engine-sim";

await initSim(); // loads Rapier's deterministic WebAssembly build, once per process

const phys = createPhysics({ tickRate: 30, gravity: [0, -20, 0] });
phys.addStaticBox([30, 0.5, 30], [0, -0.5, 0]); // the floor, on Layer.Level
const crate = phys.addBody({ type: "dynamic", shape: { box: [0.4, 0.4, 0.4] }, position: [0, 1, 3], mass: 8, restitution: 0.2, layer: Layer.Prop });
const zone = phys.addTrigger({ box: [1, 2, 2] }, [6, 1, 0]); // a sensor: enter and exit events only
const hero = phys.addCharacter({ position: [0, 1, 0], maxSpeed: 7, jumpVelocity: 8, coyoteTicks: 5, jumpBufferTicks: 5 });

// Two seconds at 30 Hz: run along +x and jump once.
for (let t = 0; t < 60; t++) {
  const { events, characters } = phys.step(new Map([[hero, { moveX: 1, moveZ: 0, jump: t === 10, dash: false }]]));
  if (characters.find((c) => c.id === hero)?.jumped) console.log(`tick ${t}: jumped`);
  for (const e of events) if (e.type === "trigger.enter" && (e.a === zone || e.b === zone)) console.log(`tick ${t}: the hero entered the zone`);
}

phys.applyKnockback(crate, [0, 0.4, 0], 6, { lift: 2 }); // 6 m/s away from the origin, 2 m/s up
const hit = phys.raycast([0, 0.4, 0], [0, 0, 1], 50, { mask: layerMask("Prop") });
console.log(hit?.body === crate ? `ray hit the crate at ${hit.distance.toFixed(2)} m` : "ray missed");

Run it with node --experimental-strip-types physics.ts on Node 22. It prints tick 10: jumped, tick 24: the hero entered the zone and ray hit the crate at 2.61 m.

2. Draw it with three.js

step() never reads a clock, so drive it from a fixed-step accumulator and draw between ticks. FixedStepper from the same package turns frame deltas into a tick count and an interpolation alpha. Each frame, copy phys.position(id) and phys.rotation(id) onto the matching THREE.Mesh, blending the previous and current tick by alpha for smooth motion at any refresh rate.

3. Snapshot, restore and compare

This is what makes physics usable in multiplayer: a client can take the server's bytes, rebuild the same world and keep stepping in lockstep.

snapshot.ts
import { initSim, createPhysics, restorePhysics } from "@netforge/engine-sim";

await initSim();
const a = createPhysics({ tickRate: 30, gravity: [0, -20, 0] });
a.addStaticBox([30, 0.5, 30], [0, -0.5, 0]);
const ball = a.addBody({ type: "dynamic", shape: { sphere: 0.5 }, position: [0, 4, 0], restitution: 0.6 });

for (let t = 0; t < 20; t++) a.step(new Map());
const saved = a.snapshot(); // bytes: Rapier's world plus the wrapper's ids, characters and joints

const b = restorePhysics(saved); // a second world from the same bytes
a.applyImpulse(ball, [3, 0, 0]);
b.applyImpulse(ball, [3, 0, 0]);
for (let t = 0; t < 120; t++) {
  a.step(new Map());
  b.step(new Map());
}
console.log(a.stateHash() === b.stateHash() ? "identical worlds" : "diverged");

It prints identical worlds. stateHash() hashes the raw float bits of every pose, velocity and motor state in body order, so equal hashes mean identical worlds. Use it in tests and to detect a desync.

How it works

  • Deterministic build. The package uses @dimforge/rapier3d-deterministic-compat 0.21. Rapier documents that its WebAssembly and JavaScript version is cross-platform deterministic.
  • Stable order. Every body gets a BodyId in creation order that is never reused, every loop runs in that order, and events come back sorted by (a, b, type) with a < b.
  • Queries see the present. Rapier's scene queries only see colliders after a step, so the wrapper runs a zero-length step before a query when bodies were added, removed or teleported since the last one. That flag rides in snapshots too.
  • Fast objects. ccd: true adds a swept pass for bullets and thrown props. Rapier 0.21 already stops plain fast spheres on static walls (tested to 2,000 m/s), but a thin body spinning fast can still tunnel, so keep projectiles round.
  • Free memory. Call destroy() to release the WebAssembly memory when a world is done.

For the netcode around it, see Netcode and prediction and Run the same simulation on the server and in the browser.

Alternatives

  • Rapier directly, through its official JavaScript packages. Pick it when you want the full Rapier API and will handle ordering, snapshots and the character motor yourself.
  • cannon-es is a lightweight JavaScript physics engine maintained by pmndrs. Pick it for single-player three.js scenes where determinism across machines is not a requirement.
  • Ammo.js is a WebAssembly port of the Bullet physics engine. Pick it when you need Bullet's feature set in the browser.

Pick @netforge/engine-sim when the physics must give the same answer on the server and in every browser, with snapshots you can send over the network.

FAQ

Is Rapier deterministic in JavaScript?

Rapier states that its WebAssembly and JavaScript version is fully cross-platform deterministic. @netforge/engine-sim builds on that with a stable body order, sorted events and snapshots that restore bit-exact, and its tests check lockstep and replay determinism over 600 ticks.

Do I have to use the Net Forge engine to use this physics?

No. @netforge/engine-sim is its own MIT package with no renderer and no network code. Use createPhysics() in any three.js project.

How do I make a moving platform?

Make it a kinematic body and call setKinematicTarget(id, position, rotation) each tick. Kinematic bodies push dynamic bodies and carry characters.

Why does my query not see a body I just added?

Rapier queries only see colliders after a step. The wrapper handles this for you by running a zero-length step before a query when bodies changed since the last step.

How do I reconcile a predicted character?

Read the server's motor state with characterState(id), apply it with setCharacterState(id, s) on the client, then replay the inputs the server has not applied yet.