Physics rooms

A physics room runs a Rapier world on the server and steps it once per tick. After every step the room writes each body’s position, rotation and velocity into ordinary schema fields, so bodies reach clients through the same sync path as every other field.

Use a physics room when the server has to decide where things are and every client has to agree: balls, pucks, projectiles, vehicles, crates a player can stand on or shove.

Skip it when movement is a grid step, a turn, or a tween. A card game does not need a world. Cosmetic motion (sparks, screen shake, a bouncing UI element) belongs in your render code, where it costs nothing and needs no agreement.

Physics needs mode: 'tick'. An event-mode room has no fixed timestep for the world to step on, and defineRoom refuses the pairing.

Choosing an engine

irtio blesses three engines and takes a fourth from you. A room declares one and gets the real engine, not a wrapper: your room code holds the actual World (or Engine) object and the actual namespace, so every tutorial for that engine applies unchanged.

physics.enginepackageuse it for
'rapier3d'@dimforge/rapier3d-compat3D games. The rest of this page.
'rapier2d'@dimforge/rapier2d-compat2D games. See 2D rooms with Rapier.
'matter2d'matter-js2D games already built on matter.js, or when you want no WASM. See matter2d.
'custom'noneMotion that is not a rigid-body simulation: swept boxes against a voxel grid, grid movement, a racer on a spline. You supply the stepper. See your own physics.

Use Rapier for a new game: rapier3d if it has depth, rapier2d if it is flat. Both Rapier builds serialize a whole world into a room’s snapshot, so a woken room is exactly the world that went to sleep, and they share one API family, so moving between them is a change of imports rather than of habits. matter2d stays supported for the rooms already on it and for anyone who wants the engine to be plain JavaScript.

Pick custom when your game’s motion is a page of arithmetic you want to own exactly, and you do not need stacking, joints, friction or ray casts. You still get bodies reconciled from rows, mapped channels synced every tick, client prediction, and room.rewind.

The four sit side by side rather than behind one abstraction: room.physics, room.physicsRapier2d, room.physics2d and room.physicsCustom are separate accessors, and reading the wrong one throws with the right name in the message.

This page is the rapier3d room. Almost all of it (body fields, intents, the tick order, bodies mapping to entities) is the same whichever engine you pick, so read it first and then read the engine’s own page for what differs.

Install the engine

npm install @dimforge/rapier3d-compat

Rapier is an optional peer dependency of irtio. A game with no physics never installs it and never pays for it.

@irtio/client ships the engines as subpath entry points, @irtio/client/rapier3d, @irtio/client/rapier2d and @irtio/client/matter, so a game that imports none of them carries no physics chunks in its build. Import the one your game uses and pass it as module on the physics option:

// main.ts
import { joinRoom } from '@irtio/client';
import rapier3d from '@irtio/client/rapier3d';

const room = await joinRoom(schema, {
  physics: { module: rapier3d, gravity, setup, bodies, intents },
});

Body fields and intents

A physics entity has two kinds of fields.

Body fields are owned by the simulation. The step writes them and nobody else may. Assigning one in room code is a compile error, and a client WRITE that touches one is reverted and corrected back.

Intent fields are ordinary owned fields. The client writes them, your validate judges them, and your tick() turns them into forces. On a physics entity, intents are the only thing a client may write. Any other field on that entity (a display name, a team) has to be set server-side.

Clamp float intents in validate. An f32 intent accepts any finite value up to about 3.4e38, and the schema has no range type to stop one. An intent that feeds a force or a velocity needs a clamp, or one client can push its own body to Infinity:

// irtio/room.ts, in the defineRoom config
validate: {
  balls: (_prev, next) => ({ ...next, ax: Math.max(-1, Math.min(1, next.ax)) }),
},

A clamped value comes back to the client as a correction of that field. Without the clamp the room survives (see Non-finite bodies), but the player’s body is reset and their input is thrown away.

// irtio/schema.ts
import { defineSchema, entity, f32, str } from '@irtio/schema';

export const schema = defineSchema({
  balls: entity(
    { x: f32, y: f32, z: f32, vx: f32, vy: f32, vz: f32, ax: f32, tag: str(16) },
    {
      physics: {
        body: { x: 'x', y: 'y', z: 'z', vx: 'vx', vy: 'vy', vz: 'vz' },
        intents: ['ax'],
      },
    },
  ),
});

body maps simulation channels to your field names:

channelmeaning
x y ztranslation
qx qy qz qwrotation, as a quaternion
vx vy vzlinear velocity
wx wy wzangular velocity

Every channel is optional, so map only what your game renders, with at least one channel mapped. The fields must be f32 or f64. Two channels may not map the same field, and a field may not be both a body field and an intent. defineSchema throws on all of those at import time.

Unlike interpolate, which is a client-side rendering choice, the physics option is part of the canonical schema and therefore part of the schema hash. A client and a room that disagree about which fields the simulation owns are refused at the handshake. See State and schema for what else the hash covers.

The physics: block

// irtio/room.ts
import { defineRoom } from '@irtio/server';
import { schema } from './schema.js';

export default defineRoom(schema, {
  mode: 'tick',
  tickRate: 30,

  physics: {
    engine: 'rapier3d',
    gravity: { x: 0, y: -9.81, z: 0 },

    // Static geometry is code, not config.
    setup(world, rapier) {
      const floor = world.createRigidBody(rapier.RigidBodyDesc.fixed().setTranslation(0, -1, 0));
      world.createCollider(rapier.ColliderDesc.cuboid(12, 1, 4), floor);
    },

    // How one instance becomes a rigid body.
    bodies: {
      balls: (rapier) => ({
        body: rapier.RigidBodyDesc.dynamic().lockRotations(),
        colliders: [rapier.ColliderDesc.ball(0.5).setRestitution(0.3)],
      }),
    },
  },

  // ...
});
keywhat it does
engine'rapier3d' here. 'rapier2d' and 'matter2d' are the other two.
gravity{ x, y, z }, in world units per second squared.
timestepSeconds per step. Defaults to 1 / tickRate. One step per tick, no substeps.
setup(world, rapier, room)Builds static geometry, joints and world tuning. Runs once per world.
bodies.<collection>(rapier, instance, id) => { body, colliders? }, one entry per physics collection.

defineRoom refuses these at import time:

  • a schema that declares physics with no physics: config, which would give you body fields that never move
  • a physics: config with no physics collection, which would step an empty world forever
  • mode: 'event' with physics
  • an engine that is not one of 'rapier3d', 'rapier2d', 'matter2d' or 'custom'
  • a non-finite gravity
  • a bodies map that is missing a collection, or names one that has no schema physics

irtio deploy also refuses a room that imports Rapier without declaring physics.

room.physics

Handlers turn intents into forces through room.physics:

memberwhat you get
room.physics.worldThe live Rapier World. Ray casts, joints, colliders added later.
room.physics.rapierThe Rapier namespace (descs, shapes, enums), so room code needs no import of its own.
room.physics.body(collection, id)The rigid body behind an instance, or undefined if the instance does not exist.
room.physics.timestepSeconds per step.
room.physics.skip(collection, id, on?)Switch one entity’s body off, or back on. See Skipping an entity.
room.physics.skipped(collection, id)Whether that entity’s body is currently off.
room.physics.unsyncedBodies that simulate and never reach the wire. See Bodies with no entity.

Reading room.physics in a room with no physics: config throws, and the error tells you what config to add.

The accessor per engine

One room runs one engine, and each engine has its own accessor. Reading the wrong one throws with a message naming the right one.

physics.engineAccessor
'rapier3d'room.physics
'rapier2d'room.physicsRapier2d
'matter2d'room.physics2d
'custom'room.physicsCustom

room.physics and room.physicsRapier2d have .rapier and .world. room.physics2d has .matter and .engine. room.physicsCustom has .stepper, the object your world() returned. See 2D rooms with Rapier, matter.js rooms and your own physics for each surface.

Bodies are addressable in the same tick

body(collection, id) creates the body on demand. After state.balls.add(id, ...) the call returns a live rigid body in the same handler, before the next reconcile, so you can build joints between bodies you just added:

// irtio/room.ts, inside tick() or a handler
state.links.add('a', { x: 0, y: 10, z: 0, vx: 0, vy: 0, vz: 0 });
state.links.add('b', { x: 0, y: 9, z: 0, vx: 0, vy: 0, vz: 0 });

const a = room.physics.body('links', 'a')!; // built now, not next tick
const b = room.physics.body('links', 'b')!;
room.physics.world.createImpulseJoint(
  room.physics.rapier.JointData.spherical({ x: 0, y: -0.5, z: 0 }, { x: 0, y: 0.5, z: 0 }),
  a,
  b,
  true,
);

room.physics2d.body() behaves the same way. Reconcile still runs later in the tick; it finds the body already there and leaves it alone. An id with no instance returns undefined.

Room code may import only a fixed list of packages, which is what the body factory and any shared geometry file can use. See what room code may import.

One tick, in order

inbound frames (intents land) → room timers → tick() → reconcile → step → sync → flush
  1. Inbound frames. Client writes for this tick are validated and applied, so the intents your handler reads are the newest ones the server has.
  2. Room timers fire.
  3. tick(state, dt, room) runs. This is where you read intents and apply forces. The world has not stepped yet, so a force you apply here is in for this step.
  4. Reconcile. Every physics-backed instance that has no body gets one from your bodies factory, and bodies whose instances are gone are destroyed.
  5. Step. One world.step().
  6. Sync. Each body’s mapped channels are written back into the schema, in a deterministic order. Values are rounded to f32 for f32 fields, so what room code reads next tick is exactly what the wire carried.
  7. Flush. Everything that changed leaves in one batch.

Sync writes through ordinary tracked state, so body movement leaves the room as a normal delta. For a body its owner holds, it also arrives at that owner as a correction: the server changed a record the client owns, and the server wins. Intents survive those corrections untouched, so positions obey the server while the player’s input keeps its own history. See Server authority for how corrections work in general.

Non-finite bodies

If a body’s pose or velocity is not finite after the step, sync does not write it. It resets the body to its last synced pose with zero velocity and no pending force, and logs a warning naming the entity. When a client owns the entity, its numeric intents are set to 0, the client gets a correction for them, and it gets a non-fatal E_WRITE_REJECTED error saying why. Other bodies in the room are not affected.

A body that goes non-finite three times is skipped, as if the room had called room.physics.skip(collection, id), and an error is logged. Call room.physics.skip(collection, id, false) to switch it back on. The count starts over when the entity is removed or replaced.

Sleeping bodies are skipped by sync after one final write on the tick they fall asleep. That last write captures the zeroed velocity Rapier gives a sleeping body, so a settled pile of crates costs nothing per tick and leaves no phantom velocity in the schema.

Bodies map to entities

There is no separate list of bodies to keep in step with your state. Adding an entity adds a body, at the next reconcile or at the first body() call for it, whichever comes first. Removing the entity removes the body at the next reconcile.

The body fields you pass to add() are the spawn pose. The factory describes shape and material, and the runtime then applies the position and velocity the schema already holds, so an entity added at y: 20 starts its fall from twenty units up.

state.balls.add(
  ctx.clientId,
  { x: 0, y: 20, z: 0, vx: 0, vy: 0, vz: 0, ax: 0, tag: ctx.name || 'anon' },
  { owner: ctx.clientId },
);

A complete example

Three files: the schema both sides import, the room, and a client that draws it. This is a one-ball-per-player drop board. Each player steers their own ball left and right while gravity does the rest.

// irtio/schema.ts
import { defineSchema, entity, f32, str } from '@irtio/schema';

export const schema = defineSchema({
  balls: entity(
    { x: f32, y: f32, z: f32, vx: f32, vy: f32, vz: f32, ax: f32, tag: str(16) },
    {
      physics: {
        body: { x: 'x', y: 'y', z: 'z', vx: 'vx', vy: 'vy', vz: 'vz' },
        intents: ['ax'],
      },
    },
  ),
});
// irtio/room.ts
import { defineRoom } from '@irtio/server';
import { schema } from './schema.js';

const BOARD_HALF_WIDTH = 8;
const DROP_HEIGHT = 20;
const NUDGE = 0.35;
const PEGS = [
  [-4, 14],
  [0, 14],
  [4, 14],
  [-2, 9],
  [2, 9],
  [0, 4],
] as const;

export default defineRoom(schema, {
  mode: 'tick',
  tickRate: 30,

  physics: {
    engine: 'rapier3d',
    gravity: { x: 0, y: -9.81, z: 0 },
    setup(world, rapier) {
      const floor = world.createRigidBody(rapier.RigidBodyDesc.fixed().setTranslation(0, -1, 0));
      world.createCollider(rapier.ColliderDesc.cuboid(BOARD_HALF_WIDTH + 2, 1, 4), floor);
      for (const [x, y] of PEGS) {
        const peg = world.createRigidBody(rapier.RigidBodyDesc.fixed().setTranslation(x, y, 0));
        world.createCollider(rapier.ColliderDesc.ball(0.4).setRestitution(0.4), peg);
      }
    },
    bodies: {
      balls: (rapier) => ({
        // Flat board: the third axis is locked, so the ball never leaves the plane. Round
        // colliders are safe with rotations locked too (see "Writing a 2D game" below).
        body: rapier.RigidBodyDesc.dynamic().enabledTranslations(true, true, false).lockRotations(),
        colliders: [rapier.ColliderDesc.ball(0.5).setRestitution(0.3)],
      }),
    },
  },

  onJoin(state, ctx) {
    if (ctx.reconnecting || state.balls.has(ctx.clientId)) return;
    state.balls.add(
      ctx.clientId,
      {
        x: ((state.balls.size % 5) - 2) * 3.5,
        y: DROP_HEIGHT,
        z: 0,
        vx: 0,
        vy: 0,
        vz: 0,
        ax: 0,
        // Not a body field and not an intent, so a client cannot write it. Set it here.
        tag: ctx.name || 'anon',
      },
      { owner: ctx.clientId },
    );
  },

  onLeave(state, ctx) {
    state.balls.remove(ctx.clientId);
  },

  validate: {
    // The only client write this entity accepts is the `ax` intent. Clamp it.
    balls: (_prev, next) => ({ ...next, ax: Math.max(-1, Math.min(1, next.ax)) }),
  },

  tick(state, _dt, room) {
    for (const [id, ball] of state.balls) {
      if (ball.ax === 0) continue;
      room.physics.body('balls', id)?.applyImpulse({ x: ball.ax * NUDGE, y: 0, z: 0 }, true);
    }
  },
});
// main.ts
import { joinRoom } from '@irtio/client';
import { schema } from './irtio/schema.js';

const room = await joinRoom(schema, { name: 'anon' });

// Arrow keys write the intent. Nothing else on this entity is writable.
let ax = 0;
function setIntent(next: number): void {
  if (next === ax) return;
  ax = next;
  const me = room.state.balls[room.me];
  if (me) me.ax = ax;
}
window.addEventListener('keydown', (e) => {
  if (e.key === 'ArrowLeft') setIntent(-1);
  if (e.key === 'ArrowRight') setIntent(1);
});
window.addEventListener('keyup', () => setIntent(0));

const canvas = document.getElementById('stage') as HTMLCanvasElement;
const ctx = canvas.getContext('2d')!;

function frame(): void {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  // `room.render` is the draw-loop read: interpolated, and predicted where prediction is on.
  for (const [id, ball] of room.render.balls) {
    ctx.beginPath();
    ctx.arc(canvas.width / 2 + ball.x * 20, canvas.height - ball.y * 20, 10, 0, Math.PI * 2);
    ctx.fillStyle = id === room.me ? '#e2b714' : '#6c7a89';
    ctx.fill();
  }
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

Read body state from room.render in the draw loop, not from room.state. room.render interpolates between server ticks, so a 30Hz simulation draws smoothly at 60fps, and it is the same read path that serves predicted values once you turn on client prediction. It is also where a predicting client puts the bodies it does not simulate: an instance past the prediction cap, or in a collection that is not predicted, gets a kinematic proxy in the local world at the pose this path draws, so a predicted body stands on it rather than falling through it.

Writing a 2D game

If the game is flat and staying flat, use rapier2d. It is Rapier compiled for the plane, so it keeps this page’s snapshot behaviour and API family, and none of the axis-locking below applies to it: there are no axes to lock, so the friction trap in this section cannot happen. matter2d is the other 2D option, kept for rooms already on it. All three engines predict on the client.

The rest of this section is the 2D-on-rapier3d recipe, which is still the right choice when you expect a third axis later.

The channel names are 3D because the engine is. A flat game maps x and y (plus qz/qw if things rotate, and wz for spin), locks the third axis on the body, and never thinks about it again. The schema surface does not pick a dimensionality, so a game that starts flat and grows a third axis changes its body map rather than its engine.

One sharp edge is worth knowing before you tune anything. Locking out-of-plane translation and both out-of-plane rotations together leaves a box-shaped body resting flat on a floor with no friction at all, in either tangent direction. Crates slide forever and nothing stacks, and it reads like a friction-coefficient problem when it is an axis-lock problem. Free rotation about X:

// irtio/world.ts
import type RAPIER from '@dimforge/rapier3d-compat';

/** A planar dynamic body that still has friction. */
function planar(desc: RAPIER.RigidBodyDesc): RAPIER.RigidBodyDesc {
  return desc.enabledTranslations(true, true, false).enabledRotations(true, false, true);
}

Rotation about X stays at zero in practice for shapes that are symmetric about Z, so qx never moves and your 2D map stays honest. Round shapes (balls, capsules) make contact at a point rather than a face and are unaffected, so a character capsule can keep lockRotations() and stay upright.

Bodies with no entity

Some bodies belong in the simulation and have nothing to say to a client: a rope’s ceiling anchor, a sensor volume that exists for one round, a dead-reckoned proxy for a player nobody predicts. They have no schema row, so they cost no wire bytes, and room.physics.unsynced is where the runtime keeps them for you.

physics: {
  engine: 'rapier3d',
  gravity: { x: 0, y: -9.81, z: 0 },
  setup(world, rapier, room) {
    room.physics.unsynced.create('rope-anchor', {
      body: rapier.RigidBodyDesc.fixed().setTranslation(0, 10, 0),
      colliders: [rapier.ColliderDesc.ball(0.1)],
    });
  },
  bodies: { /* ... */ },
},
memberwhat it does
create(key, spec)Creates the body, or returns the one this key already has. The spec is the same shape a body factory returns.
get(key) / has(key)The body, or whether there is one.
remove(key)Takes it out of the world. false when the key had no body.
size / keys()How many, and which. The registry is iterable as [key, body] pairs.

Keys are your own strings in their own namespace. An unsynced body is never addressed by collection and id, because it has neither. Everything in the registry is destroyed with the world when the room stops.

create returning the existing body for a live key is what makes calling it from setup safe on every engine. To replace a body, remove it first.

Create them in setup

Put unsynced bodies in physics.setup. That is the one place all three engines can bring them back after hibernation, and the reason is the difference between the engines’ snapshots:

engineafter a wake
rapier3d, rapier2dThe world snapshot carries the bodies themselves, so they come back exactly as they were, contacts included, and the registry comes back with them. setup does not run.
matter2dThere is no world snapshot: the world is rebuilt and setup runs again. A body comes back if setup creates it under the same key, and the runtime restores its saved pose and velocity onto it.

A matter2d room that creates an unsynced body outside setup gets one warning on the next wake naming the keys that did not come back. On the two Rapier engines it makes no difference where you create them.

Skipping an entity

Some entities exist and sync but must not simulate: hundreds of dormant spiders waiting to be woken, a level section nobody is in, a corpse that is scenery until it is looted.

room.physics.skip('spiders', id);        // dormant
room.physics.skip('spiders', id, false); // awake again
room.physics.skipped('spiders', id);     // true while it is off

A skipped entity keeps its row and goes on syncing as ordinary state. What it loses is its body: the body is destroyed when you skip it and rebuilt from the row on the next tick after you unskip it. That is why this costs nothing per tick, where a sleeping Rapier body still holds a broadphase entry and a sync slot and wakes on contact.

While an entity is skipped:

  • room.physics.body(collection, id) returns undefined and does not build one.
  • Nothing collides with it, because there is nothing in the world to collide with.
  • Assigning its fields moves it, like any other state. Unskipping resumes from wherever the row says it is.

What does not survive the round trip is what a body has and a row does not: contacts, and velocity beyond the f32 the row carries. A skipped entity resumes at rest at its row’s pose unless the row’s own velocity channels say otherwise.

The skip set is saved with the room, so a room full of dormant entities does not wake up simulating all of them. All three engines behave the same way.

Sleeping and waking

Rooms hibernate when they go idle and wake when someone joins. The Rapier world is saved into the same snapshot as the room state, so state and world always come back from the same bytes, and a ball that was mid-flight when the room went to sleep resumes its arc. See Hibernation for the room lifecycle around this.

Two cases rebuild the world instead of restoring it, and both are logged:

  1. A snapshot written before the room had physics. There is no world in those bytes.
  2. Any schema migration. The migration transform runs on schema state only, and the saved world is dropped on a version change.

In both cases the world is rebuilt from schema state and setup runs again. That is faithful for pose and velocity, because those are schema fields. Transient contact state is not in your schema: resting contacts and accumulated impulses are lost, so a stack of boxes may settle again with a small visible jolt. On an ordinary wake, setup does not run, because the static geometry came back inside the snapshot.

Two limits to plan around:

  • The world dominates the snapshot. For a 50-body world the physics section is roughly 55 KB of a roughly 56 KB snapshot. If your game hibernates often, the world is the thing to think about, not the state.
  • Determinism is per build and per machine. Save and restore round-trip a world bit-exactly on the same build on the same machine, which is what hibernation needs. Cross-platform determinism is a stronger claim, it needs Rapier’s enhanced-determinism build, and irtio does not make it.