Rooms
Physics
Rigid bodies and characters with Rapier 2D or 3D, stepped by the room and predicted on the client.
@irt/physics runs Rapier as a
stepper: the room steps a Rapier
world each tick and writes each body’s pose into its entity’s fields,
and the client runs the same stepper to predict the local player. The
entity fields are the synced state. The Rapier world is a cache the
stepper rebuilds from them, or restores from room state on wake.
Install the package and the one engine your game uses:
npm install @irt/physics @dimforge/rapier2d-compat # or @dimforge/rapier3d-compat
Import @irt/physics/rapier2d or @irt/physics/rapier3d. Each loads
only its own engine, and games that don’t import either ship none of it.
Rapier is compiled to WebAssembly and inlined in the package, so it works
in any bundler and in the room host with no setup, but it adds over a
megabyte, gzipped, to a client that imports it. The import finishes
loading Rapier before your code runs.
Bodies
physics({ gravity, bodies }) returns a stepper. Each body(kind, ...)
gives entities of a kind a body, posed by fields with fixed names:
| 2D | 3D | |
|---|---|---|
| Position, required | x, y |
x, y, z |
| Rotation | angle |
qx, qy, qz, qw |
| Velocity, dynamic bodies only | vx, vy |
vx, vy, vz |
| Angular velocity, dynamic bodies only | spin |
wx, wy, wz |
The fields must be f32 or f64. A kind without rotation fields gets a
body that doesn’t rotate. A dynamic body’s velocity is kept in the
Rapier world and saved with it, so declare velocity fields only when
clients need them, such as to predict a dynamic body; they add bytes to
every state update while the body moves. On other body types those
names are free for game state, such as a character’s vertical speed.
Body types:
dynamic: moved by gravity, collisions, forces and impulses.fixed: never moves; walls, floors and terrain.kinematic: moved only by yourcontrol, withbody.setNextKinematicTranslation; moving platforms.character: a kinematic body moved by Rapier’s character controller throughmove, which slides along walls and slopes and says whether it ends on the ground.
colliders(entity) returns the shapes attached to an entity’s body,
made when the body is. A shape that changes, such as a voxel chunk’s,
needs a shapeKey; see shapes that change.
body(desc, entity) adjusts the body before it’s made: damping, CCD,
gravity scale.
A 2D example
// shared.ts
import { defineSchema, entity } from "@irt/room";
import { body, physics, RAPIER } from "@irt/physics/rapier2d";
export const Crate = entity("crate", { x: "f32", y: "f32", angle: "f32" });
export const Platform = entity("platform", { x: "f32", y: "f32", w: "f32", h: "f32" });
export const Hero = entity("hero", { owner: "string", x: "f32", y: "f32", vy: "f32", grounded: "bool" });
export const schema = defineSchema({ entities: [Crate, Platform, Hero] });
export interface Input {
dx: number;
jump: boolean;
}
export const stepper = physics<Input>({
gravity: { x: 0, y: -20 },
parseInput(raw) {
const i = raw as Partial<Input>;
if (typeof i.dx !== "number" || Math.abs(i.dx) > 1 || typeof i.jump !== "boolean") {
throw new TypeError("input is { dx, jump }");
}
return { dx: i.dx, jump: i.jump };
},
bodies: [
body(Crate, { type: "dynamic", colliders: () => RAPIER.ColliderDesc.cuboid(0.5, 0.5) }),
body(Platform, { type: "fixed", colliders: (p) => RAPIER.ColliderDesc.cuboid(p.w / 2, p.h / 2) }),
body(Hero, {
type: "character",
colliders: () => RAPIER.ColliderDesc.capsule(0.3, 0.4),
player: (h) => h.owner,
character: (c) => c.enableSnapToGround(0.1),
control({ entity: hero, move }, input, dt) {
if (!input) return;
hero.vy = input.jump && hero.grounded ? 9 : hero.vy - 20 * dt;
hero.grounded = move(input.dx * 6 * dt, hero.vy * dt);
if (hero.grounded && hero.vy < 0) hero.vy = 0;
},
}),
],
});
// room.ts
import { defineRoom } from "@irt/room";
import { Crate, Hero, Platform, schema, stepper } from "./shared.js";
export default defineRoom({
schema,
tickRate: 30,
stepper,
onCreate(room) {
room.entities.create(Platform, { x: 0, y: -0.5, w: 60, h: 1 });
for (let i = 0; i < 5; i++) room.entities.create(Crate, { x: 8, y: 1 + i * 1.2 });
},
onJoin(room, conn) {
conn.data.hero = [...room.entities.all(Hero)].find((e) => e.owner === conn.player) ?? room.entities.create(Hero, { owner: conn.player, x: 0, y: 1 });
},
onPlayerLeave(room, player) {
for (const e of room.entities.all(Hero)) if (e.owner === player) room.entities.delete(e);
},
});
// client.ts
import { connect } from "@irt/client";
import { Crate, Hero, type Input, schema, stepper } from "./shared.js";
const room = await connect({ router: "wss://eu.irt.io", project: "crates", room: "yard-1", schema });
const keys = new Set<string>();
addEventListener("keydown", (e) => keys.add(e.key));
addEventListener("keyup", (e) => keys.delete(e.key));
const me = room.predict(stepper, {
find: (r) => [...r.entities.all(Hero)].find((h) => h.owner === r.player),
sample: (): Input => ({
dx: (keys.has("ArrowRight") ? 1 : 0) - (keys.has("ArrowLeft") ? 1 : 0),
jump: keys.has(" "),
}),
});
const heroes = room.interpolate(Hero, ["x", "y"]);
const crates = room.interpolate(Crate, ["x", "y", "angle"]);
function frame() {
const local = me.render(["x", "y"]);
for (const h of room.entities.all(Hero)) {
const at = h.owner === room.player ? local : heroes.get(h);
console.log("hero", h.owner, at.x, at.y);
}
for (const c of room.entities.all(Crate)) {
const at = crates.get(c);
console.log("crate", c.id, at.x, at.y, at.angle);
}
requestAnimationFrame(frame);
}
frame();
// physics.test.ts
import { testRoom } from "@irt/testing";
import { expect, test } from "vitest";
import room from "./room.js";
import { Crate, Hero, stepper } from "./shared.js";
test("crates fall and stack, and the hero's prediction agrees with the room", async () => {
const t = testRoom(room);
const alice = await t.connect({ player: "alice" });
let jumps = 0;
const me = alice.predict(stepper, {
find: (r) => [...r.entities.all(Hero)].find((h) => h.owner === r.player),
sample: () => ({ dx: 0, jump: jumps++ < 3 }),
});
await t.host.tick(150);
const ys = [...t.live.entities.all(Crate)].map((c) => c.y).sort((a, b) => a - b);
expect(ys[0]).toBeCloseTo(0.5, 1);
expect(ys[4]).toBeCloseTo(4.5, 0);
expect(me.mispredictions).toBe(0);
expect(me.settled).toBe(true);
});
How a tick runs
Each tick, on the room, the stepper:
- Makes a body for each new entity of a declared kind, removes the
bodies of deleted ones, and moves each body whose fields were written
by something else, such as
onJoinoronTick. Writingxin room code teleports the body; that’s how to respawn or place something. It updates the colliders of each body whoseshapeKeychanged. - Runs
controlfor each body of a kind that has one, with its player’s input for the tick.inputis undefined when the player sent none or the kind has noplayer. - Steps the Rapier world, unless every body sleeps and nothing was moved or woken, so a settled pile of crates costs almost nothing.
- Writes the pose, and the velocity fields if declared, of every body that moved into its entity. Writing a field its current value records nothing, so sleeping bodies send nothing.
onTick runs after the stepper, so it sees this tick’s poses. For ray
casts and other queries, stepper.rapierWorld(room.entities) returns
the room’s Rapier world. stepper.entityOf(room.entities, hit.collider)
says which entity a ray hit, and stepper.bodyOf(room.entities, entity)
returns an entity’s body. A body is made again when, for example, its
entity leaves a client’s view and comes back, so look it up each time
rather than keeping it. To check a shot against where the shooter saw
its target, cast against rewound poses with castRay; see
Lag compensation.
Shapes that change
Colliders are made from colliders(entity) when a body is made, and
changing the fields it reads changes nothing by itself. Give the kind a
shapeKey: a value that changes whenever colliders would return
different shapes, such as an edit count. Each tick, on the room and in
every client’s prediction, the stepper compares it with the value the
colliders were made with, and when it differs it makes them again.
Bodies resting on the changed shape wake up, so a crate on a removed
block falls.
Making a collider again costs about as much as making it the first
time, roughly 0.4 ms for a 16 x 32 x 16 voxel chunk. Where an edit
changes little, reshape updates the colliders in place instead. It
gets the body’s colliders, the entity, and before, a copy of the
entity’s values when its colliders were last made or reshaped, so it
can find what changed:
import { defineSchema, entity } from "@irt/room";
import { body, physics, RAPIER } from "@irt/physics/rapier3d";
const SIZE = 16;
// Blocks by x, then z, then y, one byte each; 0 is air.
export const Chunk = entity("chunk", { x: "f32", y: "f32", z: "f32", rev: "u32", blocks: "bytes" });
export const schema = defineSchema({ entities: [Chunk] });
const voxel = (i: number): [number, number, number] => [i % SIZE, Math.floor(i / (SIZE * SIZE)), Math.floor(i / SIZE) % SIZE];
function voxels(blocks: Uint8Array): RAPIER.ColliderDesc {
const coords: number[] = [];
for (let i = 0; i < blocks.length; i++) if (blocks[i]) coords.push(...voxel(i));
return RAPIER.ColliderDesc.voxels(new Int32Array(coords), { x: 1, y: 1, z: 1 });
}
export const stepper = physics({
gravity: { x: 0, y: -28, z: 0 },
bodies: [
body(Chunk, {
type: "fixed",
colliders: (c) => voxels(c.blocks),
// The room adds 1 to rev with every edit to blocks.
shapeKey: (c) => c.rev,
reshape([collider], c, before) {
const n = Math.max(c.blocks.length, before.blocks.length);
for (let i = 0; i < n; i++) {
const solid = (c.blocks[i] ?? 0) !== 0;
if (solid !== ((before.blocks[i] ?? 0) !== 0)) collider!.setVoxel(...voxel(i), solid);
}
},
}),
],
});
reshape mustn’t add or remove colliders. Colliders restored from a
saved world are made again if their key isn’t a number, string or
boolean, since what they were made from isn’t known then.
Two costs come with changing shapes. With reshape, the stepper keeps a
copy of each body’s entity, bytes fields included, from when its
colliders were made: about 2 MB for a world of 256 voxel chunks, on the
room and again in every client’s prediction. And after a fixed body’s
colliders are made or changed, the stepper takes a step of no time before
control runs, so the character controller sees the new shape that
tick. That step moves nothing, but it runs Rapier’s contact solver, so a
predicted dynamic body already in contact can come out of it with a
slightly different velocity than the room’s, and get a small
correction.
Players: inputs and prediction
Drive players with inputs, as above: control reads the player’s input
and moves a character with move, or pushes a dynamic body with
body.applyImpulse. The room validates each input with parseInput, and
the client predicts its own body with
room.predict, so movement responds at once.
On the client the stepper keeps a Rapier world for the prediction:
- The predicted entity gets its real body type and runs
control. - Every other body of a
dynamickind is a dynamic proxy, posed before each step where it would be at the tick being predicted: its last pose the client received, carried on at the velocity between its last two poses, and at rest when its last update didn’t move it. In 2D its angle is extrapolated too; in 3D its rotation isn’t. A hit shares momentum as on the room, and what the step does to the proxy is discarded. - Every other
characterorkinematicbody is a kinematic proxy at the pose the client last received, moved there over a step when a new one arrives, so the solver sees its velocity. Fixed bodies stay fixed. - When the room’s state arrives and the prediction replays, the predicted body is moved back to the room’s pose first.
controlgetstick, the room tick being stepped, the same on the room and in a replay.
This is prediction of the local player against everything else, not
rollback of the whole world. Contact with other dynamic bodies agrees
with the room as long as they keep their course; a rival’s steering, or
a hit on a third body, shows up as a misprediction. Other characters
and kinematic bodies are where the client last saw them, a few ticks
behind, which costs little at walking speed. A predicted dynamic body
agrees exactly while it touches nothing, rotation included: after each
step the stepper rebuilds a body’s rotation from its fields, as a replay
does. Around a dynamic body’s impacts with a wall, expect small
mispredictions, a few centimetres: Rapier’s contact solver starts each
step from the impulses of the last one, which live in the Rapier world,
and a replay from the room’s pose doesn’t have the room’s. Assert a
bound on mispredictions in such tests rather than settled. A predicted
dynamic body needs velocity fields, or the replay starts from a stale
velocity.
Ownership instead of inputs
A kind can declare a writable field, such as
a thrust or an aim angle, and control can read it from entity instead
of reading input. It works, and the room applies owner writes before
the stepper, but it isn’t tied to ticks the way inputs are, so the client
can’t replay it exactly. Use inputs for movement, and writable fields for
values the body only follows, such as where a turret points. Pose fields
can’t be writable; physics throws if one is.
Saving across hibernation
When the world has dynamic bodies, save returns it as bytes, which the
room state keeps; it runs on hibernate and at checkpoints, never per
tick. On wake, load restores the world, so bodies keep their
velocities, contacts and sleep state. Then it reconciles the world with
the entities, which are the truth: a body whose entity is gone is
removed, an entity with no body gets one, and a body whose entity’s
fields differ, as after a crash restart from changes newer than the last
checkpoint, is moved to them.
A world without dynamic bodies, such as characters walking on fixed
terrain, keeps all its state in its entities, so save returns nothing
and load makes every body from its entity. That keeps colliders out of
room state, where a voxel chunk’s takes about 4.5 KB, and costs the time
to make them on wake: about 100 ms for 256 voxel chunks of 16 x 32 x 16
on one core. A saved world includes the colliders of fixed bodies too,
so a voxel world with even one dynamic body saves them all; their shape
keys are saved with them, so a wake keeps the colliders whose key still
matches.
A restored world keeps the colliders of the build that saved it. A
build that changes a kind’s shapes can drop the saved world in a
migration, with
stepper: undefined, and the stepper rebuilds every body from its
entity, losing only velocities.
Memory
Rapier’s WebAssembly memory grows as worlds need it and never shrinks.
When a room hibernates or stops, the room host calls the stepper’s
release, which frees the room’s world for the next room to reuse, but
the room host keeps the memory of its busiest moment until it exits. A
room with thousands of bodies raises the memory of every room in its
room host for that room host’s lifetime.
3D
The 3D entry is the same API with a third axis and quaternions:
// arena.ts
import { defineRoom, defineSchema, entity } from "@irt/room";
import { body, physics, RAPIER } from "@irt/physics/rapier3d";
export const Ball = entity("ball", {
x: "f32", y: "f32", z: "f32",
qx: "f32", qy: "f32", qz: "f32", qw: "f32",
});
export const Floor = entity("floor", { x: "f32", y: "f32", z: "f32" });
export const Walker = entity("walker", { owner: "string", x: "f32", y: "f32", z: "f32", vy: "f32" });
export const stepper = physics<{ dx: number; dz: number }>({
gravity: { x: 0, y: -9.81, z: 0 },
parseInput(raw) {
const { dx, dz } = raw as { dx: unknown; dz: unknown };
if (typeof dx !== "number" || typeof dz !== "number" || Math.hypot(dx, dz) > 1) throw new TypeError("input is { dx, dz }");
return { dx, dz };
},
bodies: [
body(Ball, {
type: "dynamic",
colliders: () => RAPIER.ColliderDesc.ball(0.5).setRestitution(0.6),
body: (desc) => desc.setLinearDamping(0.2),
}),
body(Floor, { type: "fixed", colliders: () => RAPIER.ColliderDesc.cuboid(50, 0.5, 50) }),
body(Walker, {
type: "character",
colliders: () => RAPIER.ColliderDesc.capsule(0.5, 0.3),
player: (w) => w.owner,
control({ entity: w, move }, input, dt) {
if (!input) return;
w.vy -= 9.81 * dt;
if (move(input.dx * 4 * dt, w.vy * dt, input.dz * 4 * dt) && w.vy < 0) w.vy = 0;
},
}),
],
});
export default defineRoom({
schema: defineSchema({ entities: [Ball, Floor, Walker] }),
tickRate: 30,
stepper,
onCreate(room) {
room.entities.create(Floor, { y: -0.5 });
for (let i = 0; i < 10; i++) room.entities.create(Ball, { x: i - 5, y: 3 + i, qw: 1 });
},
onJoin(room, conn) {
room.entities.create(Walker, { owner: conn.player, y: 1.5 });
},
onTick(room) {
for (const b of room.entities.all(Ball)) {
// Writing a pose teleports the body: balls that roll off the floor drop in again.
if (b.y < -10) {
b.x = 0;
b.y = 5;
b.z = 0;
}
}
},
});
Common mistakes
- Writable pose fields. The stepper writes them every tick, so the
owner would be corrected every tick;
physicsthrows. - Spawning a character touching the ground. Rapier’s character controller can slip through a collider it starts exactly in contact with. Spawn characters a little above the ground.
- Changing a shape without a
shapeKey. Colliders are made once fromcolliders, so editing the fields it reads does nothing in Rapier, on the room or in prediction. - Keeping a body or collider across ticks. Bodies are made again,
as when an entity leaves a client’s view and comes back, and an old
handle then points at nothing. Look bodies up with
bodyOf. - Randomness or clocks in
control. As with any stepper, the client’s replay then differs from the room. - Moving the predicted body in
onTick. It teleports the body and makes a correction every tick; move it incontrol. - Expecting the client to simulate other bodies. They’re proxies at
their last received pose; draw them with
room.interpolate.