Physics rooms
A physics room runs a Rapier world on the server and steps it once per tick. The bodies live inside the room. After every step the simulation writes each body’s position, rotation and velocity into ordinary schema fields, so body movement reaches clients through the same sync path as every other field. There is no separate physics channel and no new wire concept to learn.
The engine is @dimforge/rapier3d-compat, and your room code gets the real World object and the
real Rapier namespace. Every Rapier tutorial and every Rapier API applies unchanged. irtio does not
invent a schema syntax for a capsule.
Use this when
Reach for 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, anything with gravity and collisions.
Skip it when movement is a grid step, a turn, or a tween. A card game does not need a world. Purely 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.
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.
Body fields and intents
A physics entity has two kinds of fields, and the split is the whole idea.
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.
// 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:
| channel | meaning |
|---|---|
x y z | translation |
qx qy qz qw | rotation, as a quaternion |
vx vy vz | linear velocity |
wx wy wz | angular 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 disagree about the world, and that is refused at the handshake
rather than debugged later. 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)],
}),
},
},
// ...
}); | key | what it does |
|---|---|
engine | 'rapier3d'. The only engine there is. |
gravity | { x, y, z }, in world units per second squared. |
timestep | Seconds 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, at import time, a schema that declares physics with no physics: config
(body fields that would never move), a physics: config with no physics collection (an empty world
stepping forever), mode: 'event' with physics, an engine other than 'rapier3d', a non-finite
gravity, and 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:
| member | what you get |
|---|---|
room.physics.world | The live Rapier World. Ray casts, joints, colliders added later. |
room.physics.rapier | The 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.timestep | Seconds per step. |
Bodies are created on demand, so an entity you added earlier in the same handler already has one.
Reading room.physics in a room with no physics: config throws, and the error tells you what
config to add.
One tick, in order
inbound frames (intents land) → room timers → tick() → reconcile → step → sync → flush - Inbound frames. Client writes for this tick are validated and applied, so the intents your handler reads are the newest ones the server has.
- Room timers fire.
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.- Reconcile. Every physics-backed instance that has no body gets one from your
bodiesfactory, and bodies whose instances are gone are destroyed. - Step. One
world.step(). - Sync. Each body’s mapped channels are written back into the schema, in a deterministic
order. Values are rounded to
f32forf32fields, so what room code reads next tick is exactly what the wire carried. - Flush. Everything that changed leaves in one batch.
Because sync writes through ordinary tracked state, 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.
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, and removing the entity removes the body.
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.
Writing a 2D game
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.
Sleeping and waking
Rooms hibernate when they go idle and wake when someone joins. The Rapier world is serialized into the same snapshot blob as the room state, so a wake is atomic: 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:
- A snapshot written before the room had physics. There is no world in those bytes.
- Any schema migration. The migration transform runs on schema state only, and the serialized 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, but 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 blob. If your game hibernates often, the world is the thing to think about, not the state.
- Determinism is per build and per machine. Snapshot 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-determinismbuild, and irtio does not make it.
Next steps
- Client prediction so a player’s own body answers the input instantly.
- Room reference for the rest of the room config.
- Server authority for how corrections reach an owner.