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

> Use @netforge/engine-sim's createPhysics for Rapier rigid bodies, triggers, ray casts and a character controller on a fixed tick, with bit-exact snapshots and a state hash, so the server and every browser compute the same world.

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

Source: https://forge.netnet.capital/docs/guides/threejs-rapier-physics  
Last updated: 2026-10-23. Checked against @netforge/engine 0.2.0.

`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.

```sh
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

| 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

```ts title=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.

```ts title=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](https://rapier.rs/docs/user_guides/javascript/determinism).
- **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](https://forge.netnet.capital/docs/concepts/netcode-prediction.md) and [Run the same simulation on the server and in the browser](https://forge.netnet.capital/docs/guides/shared-client-server-simulation.md).

## Alternatives

- **[Rapier](https://rapier.rs/docs/)** 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](https://github.com/pmndrs/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](https://github.com/kripken/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.
