How to add deterministic Rapier physics to a three.js game
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-simThe 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
| Feature | API |
|---|---|
| Bodies: dynamic, kinematic, static; box, sphere, capsule, cylinder | addBody, addStaticBox, setKinematicTarget |
| Sensors with enter and exit events | addTrigger, step().events |
| Collision layers (16 bits, pairwise masks) | Layer, layerMask, layer and mask on a body |
| Queries | raycast, raycastAll, shapeCast, overlap |
| Forces | applyImpulse, applyForce, applyKnockback (also works on characters) |
| Joints | addJoint({ kind: "fixed" | "ball" | "hinge" | "slider" }) |
| Character motor | slopes, steps, ground snapping, coyote time, jump buffer, variable jump, air control, dash, pushing bodies |
| Multiplayer | snapshot(), restorePhysics(bytes), stateHash(), characterState and setCharacterState |
1. Build a world and step it
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.
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-compat0.21. Rapier documents that its WebAssembly and JavaScript version is cross-platform deterministic. - Stable order. Every body gets a
BodyIdin creation order that is never reused, every loop runs in that order, and events come back sorted by(a, b, type)witha < 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: trueadds 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.