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
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

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.

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.

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
smoothingHalfLifeMs70, 0 disables
smoothingSnapUnits4

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);
});
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. 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.