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';
MemberSignatureRequiredWhat it does
createBody(collection, id, record) => object \| undefinedYesBuilds 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) => voidYesThe row is gone, or the body is being rebuilt
writePose(body, pose) => voidYesOverwrite 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) => voidYesFill into with the body’s current state. Runs once per body per tick
applyIntent(collection, body, record) => voidNoApply 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) => voidYesOne fixed step, dt in seconds
setKinematic(body, on) => voidNoMarks a body as externally driven. Only a predicting client calls it, for proxies
readState(body, into) => voidWith physics.stateFill into with the body’s non-spatial state, keyed by field name
writeState(body, state) => voidWith physics.stateOverwrite 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.

FieldMeaningUnit
x, y, zTranslationWorld units
qx, qy, qz, qwRotation quaternionIdentity is (0, 0, 0, 1)
vx, vy, vzLinear velocityWorld units per second
wx, wy, wzAngular velocityRadians 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, createBody is followed by writePose and then writeState, 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 simulate misprediction figure ignores them, and so does the pose history room.rewind reads.

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 the dt you 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.
  },
});
keywhat it does
engine'custom'.
world(room)Builds the stepper. Runs once, as the room is constructed and on every wake.
timestepSeconds per step, passed to step(dt). Defaults to 1 / tickRate.
historyTicks 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);
  },
}
memberwhat it is
stepperThe object your world() returned, at its own type.
timestepSeconds 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,
  },
});
optiondefault
worldrequired, the same factory the room config uses
timestepthe room’s tick interval from WELCOME, rounded to whole milliseconds
maxPredictedBodies64
maxProxyBodies256
epsilon0.05 world units, or a per-channel map (below)
smoothingHalfLifeMs70, 0 disables
smoothingSnapUnits4
renderLagTicks1.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);
});
memberwhat 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.