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 your control, with body.setNextKinematicTranslation; moving platforms.
  • character: a kinematic body moved by Rapier’s character controller through move, 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:

  1. 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 onJoin or onTick. Writing x in room code teleports the body; that’s how to respawn or place something. It updates the colliders of each body whose shapeKey changed.
  2. Runs control for each body of a kind that has one, with its player’s input for the tick. input is undefined when the player sent none or the kind has no player.
  3. Steps the Rapier world, unless every body sleeps and nothing was moved or woken, so a settled pile of crates costs almost nothing.
  4. 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 dynamic kind 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 character or kinematic body 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.
  • control gets tick, 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; physics throws.
  • 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 from colliders, 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 in control.
  • Expecting the client to simulate other bodies. They’re proxies at their last received pose; draw them with room.interpolate.

View this page as Markdown