Rooms with your own physics
Set physics.engine to custom and give the room a world(room) factory. The object it returns
is your physics: it creates bodies, applies intents, steps, and reads and writes poses. irtio never
looks inside it.
physics: { engine: 'custom', world: (room) => makeStepper(room) } Everything the three built-in engines get from the engine-neutral machinery, a custom room gets
too: bodies reconciled from schema rows, mapped channels synced to clients every tick, client prediction with rebase and replay, and room.rewind for lag compensation. None of that needed a rigid-body solver, only
poses.
When to use it
Pick custom when your game’s motion is not a rigid-body simulation and forcing it into one costs
you more than writing it. A voxel shooter sweeping an axis-aligned box against a chunked grid, a
grid-based tactics game, a racer on a spline, a platformer with hand-tuned coyote time: each is a
page of arithmetic that you want to own exactly.
Pick 3D Rapier, 2D Rapier or matter.js when you want stacking, joints, friction, ray casts and a solver that already works. A custom stepper gives you none of those; it gives you the seams.
The stepper contract
import type { CustomPose, CustomStepper } from '@irtio/server'; | Member | Signature | Required | What it does |
|---|---|---|---|
createBody | (collection, id, record) => object \| undefined | Yes | Builds the body for one row. Return undefined when that row has no body; that is this engine’s replacement for a per-collection factory map |
removeBody | (body) => void | Yes | The row is gone, or the body is being rebuilt |
writePose | (body, pose) => void | Yes | Overwrite the body’s full state. Used to restore a row, to teleport, and on a predicting client to rebase onto the server |
readPose | (body, into) => void | Yes | Fill into with the body’s current state. Runs once per body per tick |
applyIntent | (collection, body, record) => void | No | Apply one row’s intent fields for the coming step. Runs once per body per step, on both sides |
step | (dt) => void | Yes | One fixed step, dt in seconds |
setKinematic | (body, on) => void | No | Marks a body as externally driven. Only a predicting client calls it, for proxies |
body is whatever createBody returned. irtio stores the reference and hands it straight back.
The pose
CustomPose is the thirteen canonical body channels, flat and keyed by channel name — the same
names the schema’s physics.body map uses.
| Field | Meaning | Unit |
|---|---|---|
x, y, z | Translation | World units |
qx, qy, qz, qw | Rotation quaternion | Identity is (0, 0, 0, 1) |
vx, vy, vz | Linear velocity | World units per second |
wx, wy, wz | Angular velocity | Radians per second |
Fill every field in readPose, including the ones your world does not use; the buffer is reused,
so a field you skip keeps the previous body’s number. Velocities are per second, which is what
sets the correction-suppression tolerance on a velocity channel to epsilon / timestep.
Channels your schema does not map are still carried through writePose and readPose; they just
never reach the wire.
The shared world module
The client cannot import your room file, so the factory lives in its own module and both sides
import it. By convention that file is irtio/world.ts.
// irtio/world.ts
import type { CustomStepper } from '@irtio/server';
export const TICK_RATE = 30;
// Say it, do not inherit it: WELCOME carries the tick interval as whole milliseconds, so a 60 Hz
// room arrives as 17 and every local step runs long.
export const timestep = 1 / TICK_RATE;
export const SPEED = 6;
export const GRAVITY = -20;
export const GROUND_Y = 0;
interface Runner {
x: number;
y: number;
vx: number;
vy: number;
}
export function world(): CustomStepper {
const live = new Set<Runner>();
return {
createBody(collection, _id, record) {
if (collection !== 'runners') return undefined;
const body: Runner = {
x: typeof record.x === 'number' ? record.x : 0,
y: typeof record.y === 'number' ? record.y : 0,
vx: 0,
vy: 0,
};
live.add(body);
return body;
},
removeBody(body) {
live.delete(body as Runner);
},
writePose(body, pose) {
const b = body as Runner;
b.x = pose.x;
b.y = pose.y;
b.vx = pose.vx;
b.vy = pose.vy;
},
readPose(body, into) {
const b = body as Runner;
into.x = b.x;
into.y = b.y;
into.z = 0;
into.qx = 0;
into.qy = 0;
into.qz = 0;
into.qw = 1;
into.vx = b.vx;
into.vy = b.vy;
into.vz = 0;
into.wx = 0;
into.wy = 0;
into.wz = 0;
},
applyIntent(_collection, body, record) {
const move = typeof record.move === 'number' ? record.move : 0;
(body as Runner).vx = Math.max(-1, Math.min(1, move)) * SPEED;
},
step(dt) {
for (const b of live) {
b.vy += GRAVITY * dt;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.y <= GROUND_Y) {
b.y = GROUND_Y;
b.vy = 0;
}
}
},
};
} Determinism
The factory and everything it closes over must be pure over synced inputs:
- No
Math.random(). Seed from synced state and pass the seed in. - No
Date.now(),performance.now()or any other clock. The only time is thedtyou are given. - No module-level mutable state. Every body lives on the instance the factory returned, because the server builds one instance and each predicting client builds its own.
- No reads of anything the server has not told the client about.
Two worlds built from the same inputs must step to the same numbers. When they do not, every tick is a misprediction on somebody’s screen.
The room config
// irtio/room.ts
import { defineRoom } from '@irtio/server';
import { schema } from './schema.js';
import * as physics from './world.js';
export default defineRoom(schema, {
mode: 'tick',
tickRate: physics.TICK_RATE,
physics: {
engine: 'custom',
timestep: physics.timestep,
world: () => physics.world(),
history: 20,
},
tick() {
// Nothing. The stepper's applyIntent is the steering, and the runtime runs it.
},
}); | key | what it does |
|---|---|
engine | 'custom'. |
world(room) | Builds the stepper. Runs once, as the room is constructed and on every wake. |
timestep | Seconds per step, passed to step(dt). Defaults to 1 / tickRate. |
history | Ticks of pose history to keep for lag compensation, 0 to 240. Takes { depth, channels } to record extra per-instance floats alongside the poses. |
defineRoom rejects gravity, setup, bodies and intents on a custom room by name: the
stepper is all four. It also rejects mode: 'event' with physics and a history outside 0 to 240.
Intents run in the runtime, not in tick
This is the one place a custom room behaves differently from the built-in engines. On Rapier and
matter.js, physics.intents is a declaration and your tick() calls the hooks. A stepper’s applyIntent is called by the runtime, over every tracked body, immediately before step() — the
same order a predicting client uses — so a room whose steering is a function of its intent fields
needs no pump at all.
To steer from tick() instead, leave applyIntent undefined and reach the stepper through room.physicsCustom.stepper.
Reading the world in handlers: room.physicsCustom
rpc: {
warp(_state, { id, x }, ctx) {
const api = ctx.room.physicsCustom;
const body = api.body('runners', id);
if (!body) return;
const pose = blankPose();
api.stepper.readPose(body, pose);
pose.x = x;
api.stepper.writePose(body, pose);
},
} | member | what it is |
|---|---|
stepper | The object your world() returned, at its own type. |
timestep | Seconds per step. |
body(collection, id) | The body createBody returned, or undefined. |
skip(collection, id, on?) | Destroys this entity’s body, or rebuilds it on the next tick. The row keeps syncing. |
skipped(collection, id) | Whether that entity’s body is currently off. |
It is a fourth accessor rather than a narrowing of room.physics. Reading room.physics, room.physics2d or room.physicsRapier2d in a custom room throws and names room.physicsCustom,
and the reverse throws too.
There is no unsynced registry here. A body irtio never sees the shape of is one your stepper is
already free to create and keep for itself.
Prediction
A custom room predicts through joinRoom({ physicsCustom }). There is no engine module to pass,
because there is no engine: the world factory is the whole option.
import * as physics from './irtio/world.js';
joinRoom(schema, {
physicsCustom: {
world: physics.world,
timestep: physics.timestep,
},
}); | option | default |
|---|---|
world | required, the same factory the room config uses |
timestep | the room’s tick interval from WELCOME, rounded to whole milliseconds |
maxPredictedBodies | 64 |
maxProxyBodies | 256 |
epsilon | 0.05 world units |
smoothingHalfLifeMs | 70, 0 disables |
smoothingSnapUnits | 4 |
physicsCustom is mutually exclusive with physics and physics2d; joinRoom refuses a join
that passes more than one. A room runs one world, and the client predicts with that one.
The loop, the cap, the proxies and room.prediction are the ones client prediction describes, and they read identically here. There is
no bodies map to pass: the client calls your createBody for every physics collection, and a
collection with no body is the one you return undefined for.
An entity the client does not simulate — past the prediction cap, or in a collection that is not predicted — gets a kinematic proxy. The client writes that proxy’s pose with writePose before
every step and calls setKinematic(body, true) once, if your stepper defines it. A stepper with no
notion of “kinematic” still behaves: the proxy simply goes where it is put.
Sleeping and waking
A custom room’s physics section carries no world state. Your bodies come back from the schema rows:
on every wake the runtime builds a fresh stepper with world(room) and rebuilds each body from its
row’s mapped channels. Anything your stepper kept that the schema does not name — an internal
contact list, a coyote-time counter, a cached chunk — does not survive.
Put state you need across a wake in the schema, either as body channels or as ordinary fields your createBody reads back.
Lag compensation
room.rewind(tick, fn) works on a custom room as soon as it declares history. past.custom hands back the stored poses:
room.rewind(shotTick, (past) => {
const target = past.custom?.pose('runners', targetId);
if (!target) return false;
return hitsVoxelTarget(origin, direction, target.x, target.y, target.z);
}); | member | what it is |
|---|---|
pose(collection, id) | That entity’s pose at the answered tick, or undefined when it was not tracked then. |
each(fn) | Every tracked body at that tick, in the room’s own sync order. |
There is no scratch world, because irtio does not know what one of your queries looks like. Your
static world — the voxel grid, the height field — is the same inside fn as outside it; what you
could not otherwise get is where everyone was, and that is what this hands over. The pose passed to each is a view into the history ring: read it inside fn, and copy it if you need to keep it.
past.tick, past.requested, past.clamped and past.channels(collection, id) mean exactly what
they mean on the other engines.
Testing
t.physics from @irtio/testing works on a custom room, through your stepper’s own readPose/writePose:
expect(t.physics.position('runners', id).x).toBeCloseTo(4);
t.physics.teleport('runners', id, { x: 10, y: 0 });
t.physics.impulse('runners', id, { x: 2, y: 0 }); impulse adds the vector to the body’s velocity directly. There is no mass to divide by, because
irtio does not know whether your bodies have one.
Bot simulations
irtio simulate reads physics.engine from your room config, so a custom room is recognised as
one. It loads world() from your shared world module (irtio/world.ts by convention) and hands it
to every bot as physicsCustom, which means the bots predict with your stepper the way a browser
does, and body corrections are judged as real mispredictions rather than counted as body sync.
Before any bot joins, it builds your stepper twice, steps both for a second against each physics collection’s default record, and compares every body’s thirteen channels. Two builds that disagree came from a factory that is not pure over synced inputs, and the run fails there rather than blaming the difference on netcode. The report names the engine it predicted with:
bots predicted custom physics from irtio/world.ts If the world module exports no world(), the run says so in one sentence and the bots interpolate
instead of predicting, which is the right answer for a room that is server-owned by design.
Your world module can also export epsilon, maxPredictedBodies and smoothingHalfLifeMs, and
simulate passes them through. epsilon is a distance in your world’s own units, so state it when
your world is not metre-scale.