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. record is the whole row, so server-set fields such as a stun timer can scale the steering. On a predicting client those fields are the last synced values, so a change to one reaches the client’s replay a round trip late |
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 |
readState | (body, into) => void | With physics.state | Fill into with the body’s non-spatial state, keyed by field name |
writeState | (body, state) => void | With physics.state | Overwrite that state. Runs right after writePose whenever a body is restored from its row |
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.
State that is not a pose
A body often has state that is not a position: a heading on a grid, boost ticks left, a cooldown,
a parked flag. It has to survive the same things the pose does — a rebase on a predicting client,
and a wake — but putting a counter on wz makes every tool that treats the pose as distances lie
about it. Declare it in the schema as physics.state instead:
// irtio/schema.ts
cycles: entity(
{
x: f32,
y: f32,
dir: u8,
boostLeft: u8,
cool: u8,
parked: u8,
want: u8,
},
{
physics: {
body: { x: 'x', y: 'y' },
intents: ['want'],
state: ['dir', 'boostLeft', 'cool', 'parked'],
},
},
), Then give the stepper the two hooks that move the state, beside readPose and writePose:
readState(body, into) {
const b = body as Cycle;
into.dir = b.dir;
into.boostLeft = b.boostLeft;
into.cool = b.cool;
into.parked = b.parked ? 1 : 0;
},
writeState(body, state) {
const b = body as Cycle;
b.dir = state.dir ?? b.dir;
b.boostLeft = state.boostLeft ?? b.boostLeft;
b.cool = state.cool ?? b.cool;
b.parked = (state.parked ?? (b.parked ? 1 : 0)) !== 0;
}, What that buys:
- The fields belong to the body. Like body channels, they are written by the step and are
read-only to room code (a compile error) and to clients. To change one from a handler, change it
on the body (
room.physicsCustom.body('cycles', id)); the next sync writes it to the row. - They are restored wherever the pose is. On the server,
createBodyis followed bywritePoseand thenwriteState, from the row, so a woken room gets its counters back. On a predicting client, every rebase writes authority’s pose and then authority’s state, and a kinematic proxy gets authority’s state before every step. - They are predicted and judged. A predicting client records them per tick with the pose, and a correction that touches one is judged against the prediction for that tick. Without a per-channel epsilon they must match exactly.
- They are not distances. The
irtio simulatemisprediction figure ignores them, and so does the pose historyroom.rewindreads.
physics.state takes numeric fields only (u8, u16, u32, i32, f32, f64). Store a flag as a u8. Each value is rounded and clamped to its field’s type (or frounded for an f32) on both
sides, so a stepper that hands back 2.5 for a u8 is judged against the 3 the wire carries. into starts out holding the row’s current values, so a key readState leaves alone stays as it
was. writeState is passed only the fields the row holds as finite numbers.
physics.state is part of the schema hash. defineRoom refuses it on the other three engines,
which have no hook to fill it. A custom room that declares it but whose stepper has no readState/writeState logs an error once, when the room is built.
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.
Steering server-owned rows
applyIntent runs for every tracked body, not only the ones a client owns. So a bot, an NPC or
any other row nobody owns is steered the same way a player is: tick() writes that row’s intent
fields, and the step that runs right after tick() applies them.
tick(state, _dt, room) {
for (const [, cycle] of state.cycles) {
if (!cycle.bot) continue;
cycle.want = chooseHeading(state, cycle); // an intent field, so room code may write it
}
} The bot’s motion then comes from the stepper you already share with the client, and nothing has
to call applyIntent by hand. Steer only rows nobody owns this way. Writing the intent field of a
row a client owns overrides that client’s input, and the change reaches the owner as a correction.
That is the right tool for clamping a cheat, and the wrong one for steering.
Results the step produced
Collision, scoring and other judgements often belong to the room rather than the stepper, because
they need state the stepper does not have. The room still needs to know what the step did, though:
which cells a cycle entered, whether a runner landed. The body object is the channel for that. room.physicsCustom.body(collection, id) returns the very object createBody returned. Have the
stepper record its results on that object, and have tick() read them:
// irtio/world.ts: inside step(), per body
b.entered.length = 0;
// ...each cell centre the body reaches this step:
b.entered.push(cellIndex(b.x, b.y)); // irtio/room.ts
tick(state, _dt, room) {
for (const [id, cycle] of state.cycles) {
const body = room.physicsCustom.body('cycles', id) as CycleBody | undefined;
for (const cell of body?.entered ?? []) judgeCell(state, cycle, cell);
}
} Mind the order. Each tick runs tick() first and the physics step after it, so tick() always
reads what the previous tick’s step recorded. Clear the list at the top of step(), not in tick(). Expect the results one tick behind the motion: a body that entered a wall during tick N
is judged in tick N + 1, and the row you change then leaves in that tick’s delta. Results are not
synced and do not survive a wake. On a predicting client the same lists are filled by local
re-steps, so treat them there as a guess, never as authority.
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, or a per-channel map (below) |
smoothingHalfLifeMs | 70, 0 disables |
smoothingSnapUnits | 4 |
renderLagTicks | 1.05 ticks, see Rendering |
epsilon may be a map instead of one number, keyed by pose channel or by physics.state field. default replaces the single number and keeps its rules:
epsilon: { default: 0.25, vx: 13, vy: 13, dir: 0 } When a channel or field has no key of its own, the default rules apply. A position or rotation
channel is compared against default, and a velocity channel against what default of position
is in one step (default / timestep). A physics.state field must match exactly. A keyed value is
used as given, in that channel’s own units: vx: 13 means 13 units a second, and the timestep
does not rescale it.
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: as body channels, as physics.state when the stepper owns it (the runtime restores it
through writeState), 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. When the stepper has a readState, what it reports is compared
too. 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, smoothingHalfLifeMs, smoothingSnapUnits and renderLagTicks, 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. It may be the per-channel map described under Prediction.
The misprediction figure the report prints is a position error: the distance between the
predicted and the corrected x/y/z. A correction that changed only velocities, rotation or physics.state still counts as a misprediction, with size 0.