Rooms

Client prediction

Make movement feel instant with a stepper shared by the room and the client.

The room is the authority, so a client that waits for the room to move its player feels a round trip of lag. Prediction runs the room’s own simulation step on the client for the local player, sends the input it used, and reconciles when the room’s state arrives. Everything else on screen is interpolated.

The stepper

A stepper is a simulation step the room and its clients share, in a module both import:

function defineStepper<I>(stepper: {
  step(world: World, inputs: ReadonlyMap<string, I>, dt: number, tick: number): void;
  parseInput?(raw: unknown): I;
  save?(world: World): unknown;
  load?(saved: unknown, world: World): void;
  release?(world: World): void;
}): Stepper<I>;

interface World {
  all<K extends Kind>(kind: K): Iterable<Entity<K>>;
  get(id: number): Entity | undefined;
  readonly predicted?: Entity;   // on a client's prediction world only
  readonly stateTick?: number;   // on a client's prediction world only
}
  • world is the room’s entities, read and written in place; on the client it’s the client’s view. world.get(id) is a map lookup; world.all(kind) visits every entity of the kind on each call, so reach static data, such as voxel chunks, by computed IDs; see Entity IDs and lookups. The shared state is in it on both sides, as world.get(SHARED_ID) (0) and in world.all(schema.state), so a stepper can read a race’s start tick from it. Private state isn’t: the room has one per player.
  • inputs maps player IDs to this tick’s input. On the room it holds every player that sent one for this tick; on the client only the local player’s.
  • dt is the fixed timestep in seconds, 1 / tickRate.
  • tick is the room tick being stepped: room.tick on the room, and on the client the tick of the input being stepped, which is the tick the room runs that input at. A rule that depends on time, such as holding cars on the grid until tick reaches the start, predicts exactly.
  • world.predicted is the predicted entity on a client and undefined on the room, for a stepper that simulates only that entity on the client.
  • world.stateTick is the room tick of the state the client holds, and undefined on the room.
  • parseInput runs on the room only. Inputs arrive as JSON from the client, or as the schema’s input kind’s fields (see Compact inputs); without parseInput the room passes the decoded input as is, so validate it here and throw to drop a bad one.
  • save and load are for caches of the stepper’s own, such as a physics engine’s world. They run on the room only, on hibernate, at checkpoints and on wake, never per tick. One stepper serves every room in a room host, so they get the room’s world, the object step gets; key caches by it. See Steppers.
  • release frees what a stepper keeps for a world that’s gone: on the room when the room leaves its room host, and on the client when the prediction stops. Only caches the garbage collector can’t free promptly need it, such as a physics engine’s WebAssembly memory.
  • For rigid bodies and characters, @irt/physics is a stepper built on Rapier.
// shared.ts
import { defineSchema, defineStepper, entity } from "@irt/room";

export const Runner = entity("runner", { owner: "string", x: "f32", y: "f32" });
export const schema = defineSchema({ entities: [Runner] });

export interface Input {
  dx: number;
  dy: number;
}

const SPEED = 6;

export const stepper = defineStepper<Input>({
  parseInput(raw) {
    const i = raw as Partial<Input>;
    if (typeof i.dx !== "number" || typeof i.dy !== "number" || Math.abs(i.dx) > 1 || Math.abs(i.dy) > 1) {
      throw new TypeError("input is { dx, dy } with each from -1 to 1");
    }
    return { dx: i.dx, dy: i.dy };
  },
  step(world, inputs, dt) {
    for (const r of world.all(Runner)) {
      const input = inputs.get(r.owner);
      if (!input) continue;
      r.x += input.dx * SPEED * dt;
      r.y += input.dy * SPEED * dt;
    }
  },
});

The room

// room.ts
import { defineRoom } from "@irt/room";
import { Runner, schema, stepper } from "./shared.js";

export default defineRoom({
  schema,
  tickRate: 30,
  stepper,
  onJoin(room, conn) {
    // After a crash or a wake the player's runner is still here, and onJoin runs again.
    const existing = [...room.entities.all(Runner)].find((r) => r.owner === conn.player);
    conn.data.runner = existing ?? room.entities.create(Runner, { owner: conn.player });
  },
  onTick(room) {
    // Rules only the room knows; the client reconciles with them.
    for (const r of room.entities.all(Runner)) r.x = Math.min(r.x, 50);
  },
  onPlayerLeave(room, player) {
    for (const r of room.entities.all(Runner)) if (r.owner === player) room.entities.delete(r);
  },
});

onJoin reuses the player’s runner rather than creating one each time: after a room host crash it runs again with no onLeave or onPlayerLeave first. The runner goes in onPlayerLeave, which runs 10 seconds after the player’s last connection closed with no new one, so a dropped network and a reconnect keep it, and before every hibernation; see State sync.

Each tick the room runs the stepper with each player’s input for that tick, then onTick, then sends the state update, then acknowledges the inputs it applied.

The client

// client.ts
import { connect } from "@irt/client";
import { type Input, Runner, schema, stepper } from "./shared.js";

const room = await connect({ router: "wss://eu.irt.io", project: "runners", room: "track-1", schema });

const keys = new Set<string>();
addEventListener("keydown", (e) => keys.add(e.key));
addEventListener("keyup", (e) => keys.delete(e.key));

const me = room.predict(stepper, {
  find: (r) => [...r.entities.all(Runner)].find((e) => e.owner === r.player),
  sample: (): Input => ({
    dx: (keys.has("ArrowRight") ? 1 : 0) - (keys.has("ArrowLeft") ? 1 : 0),
    dy: (keys.has("ArrowDown") ? 1 : 0) - (keys.has("ArrowUp") ? 1 : 0),
  }),
});
const others = room.interpolate(Runner, ["x", "y"]);

function frame() {
  const local = me.render(["x", "y"]);
  for (const r of room.entities.all(Runner)) {
    const at = r.owner === room.player ? local : others.get(r);
    console.log(r.owner, at.x, at.y);
  }
  requestAnimationFrame(frame);
}
frame();

room.predict(stepper, { find, sample }) returns a Prediction, typed by the kind find returns:

Member Meaning
entity The predicted entity: the room’s last state plus the inputs it hasn’t acknowledged, or undefined until find finds it. Use it for game logic.
render(fields, opts?) The predicted numeric fields plus a decaying share of recent mispredictions, as Record<field, number>. Before the first input it’s the room’s state of the entity, and NaN for each field while find finds nothing. Use it for drawing. With { snapDistance }, a misprediction of these fields longer than that is dropped instead of smoothed; see Respawns and teleports.
snap() Drops every misprediction render is smoothing, so it draws entity as it is
pending Ticks stepped ahead of the room’s state: inputs sent and not yet acknowledged, and idle ticks not sent. With sample this stays at about 2 plus half the round trip in ticks, never 0, because inputs are sent ahead of the room’s clock
settled true when the prediction agrees with the room: entity equals the room’s last state, so the pending inputs move nothing. render may still be smoothing away an earlier misprediction
mispredictions How many replays found the room’s state different from the prediction in a field the stepper writes
onMispredict(fn) Calls fn({ tick, fields, delta }) after each such replay: the room tick it replayed from, the fields that differed, and for numeric ones the prediction’s value less the replayed one, which render smooths away. Returns an unsubscribe
push(input) Sends an input for the next tick and steps the prediction. Only without sample.
stop() Stops sampling

How it works:

  1. Once per room tick, by the client’s estimate of the room’s clock, the prediction calls sample(), steps the predicted entity with the input and sends it tagged with the room tick it is for. sample receives the Prediction it belongs to, so it can steer by entity, which is set before each call whenever find finds the entity. It’s undefined only while find finds nothing: before the entity enters the client’s view, while the room has deleted it, and after a reconnect until the full state arrives, so read it as p.entity?.x ?? 0. The first call is at the next room tick after predict returns, never during it. After the page was paused it calls sample up to 4 times at once to catch up and skips the rest. It aims 2 ticks ahead of the room, plus half the round trip, so the input arrives in time. Each message repeats the last 4 inputs, so a lost one costs nothing; see Compact inputs for what that costs.
  2. The room holds each input until the tick it names and applies it then. An input for a tick that already ran is dropped, and so is one more than 32 ticks ahead (INPUT_WINDOW). One input per player per tick: a later one for the same tick replaces the earlier.
  3. After each tick’s state update the room sends an ack with the highest input it applied or dropped. The client resets the predicted entity to the room’s state and replays the inputs after the ack. A field that a step has written and that came out different is a misprediction, and the difference goes into what render smooths away, 20% per tick. A field no step writes, such as a lap counter only onTick sets, is the room’s: its changes are taken as they come, never counted or smoothed.
  4. On a reconnect or a new room generation, prediction state is dropped and starts again from the room’s state.

Compact inputs

Inputs are JSON unless the schema declares their fields. Each message repeats the last 4 inputs, so JSON inputs can cost more upstream than the state a client receives: a shooter’s {"mx":1,"my":0,"aim":0.1234567,"fire":true,"rocket":false} is 200 to 300 bytes a message, 6 to 9 KB/s at 30 ticks a second. Declare the input as a kind, with entity, and pass it to defineSchema as input; inputs then go as binary, each field encoded as in a state update, with the repeats’ ticks as differences:

// compact.ts
import { defineSchema, defineStepper, entity, type ValuesOf } from "@irt/room";

export const Ship = entity("ship", { owner: "string", x: "f32", aim: "f32" });
export const ShipInput = entity("input", { mx: "i8", aim: "f32", fire: "bool" });
export type ShipInput = ValuesOf<typeof ShipInput>;
export const compactSchema = defineSchema({ entities: [Ship], input: ShipInput });

export const shipStepper = defineStepper<ShipInput>({
  parseInput(raw) {
    const i = raw as ShipInput;
    if (Math.abs(i.mx) > 1) throw new RangeError("mx is -1, 0 or 1");
    return i;
  },
  step(world, inputs, dt) {
    for (const s of world.all(Ship)) {
      const input = inputs.get(s.owner);
      if (!input) continue;
      s.x += input.mx * 100 * dt;
      s.aim = input.aim;
    }
  },
});

That shooter’s input is then 48 bytes a message, about 1.4 KB/s. sample returns the same object as before. Fields it returns that the kind doesn’t declare are dropped, on the client as on the room. An input that isn’t an object, or with a field missing or of the wrong type, throws a TypeError naming the field and stops the prediction, as stop() does; call predict again once sample is fixed. f32 fields are rounded with Math.fround before the client steps with them, as the room will see them, so the prediction stays exact. parseInput gets an object of the kind’s fields with their types, so check ranges and leave types to the encoding. The input kind is part of the schema hash, so a site built with a different input layout fails to connect with 4004, as for any other schema change. room.stats.sent shows what inputs cost, under typedInput for these and input for JSON ones.

Idle inputs

A predicting client sends an input message every tick, even standing still. Pass idle to say which inputs mean the same as none:

const me = room.predict(stepper, { find, sample, idle: (i) => i.dx === 0 && i.dy === 0 });

Once the room has acknowledged every input that wasn’t idle, an idle input isn’t sent, and both sides step that tick with no input: the room because none arrived, the client because it skipped it. That holds only if the stepper treats a missing input as the idle one, as the runner’s if (!input) continue does. While an earlier input awaits its ack, idle inputs are still sent, so the repeats in each message carry the inputs from just before the player stopped past a lost message. A standing player then sends only pings.

Respawns and teleports

A respawn or a teleport that room code does in onTick is a rule the stepper can’t predict, so it arrives as a misprediction, and render would slide the player from the old spot to the new one over half a second. Pass a snapDistance to render, longer than any misprediction of ordinary play, and one longer than that is drawn at once:

const local = me.render(["x", "y"], { snapDistance: 64 });

Where the entity itself says it jumped, call me.snap() instead; it drops whatever render is smoothing. The misprediction lands when the room’s ack arrives, after the tick’s state update and any message the room sent in that tick, so watch a field the room sets with the jump, such as a spawns counter, and snap in the frame where me.entity shows the new value:

if (me.entity && me.entity.spawns !== lastSpawns) {
  lastSpawns = me.entity.spawns;
  me.snap();
}

The prediction world

On the client the stepper runs on a prediction world. The predicted entity is writable. Every other entity is a copy whose writes last until the prediction next replays from the room’s state, at the next ack, so a step’s effect on another entity holds exactly as long as the input that caused it is unacknowledged; once the room acks it, the room’s state has the effect, or doesn’t if the room rejected it. Don’t mutate a bytes value in place in a stepper; on the client it’s the client’s own copy of room state. Use entity.patch(field, offset, bytes), which works on both sides.

The prediction world is one object for the life of the Prediction, so a cache a stepper keys by world lasts across its steps and replays. A replay starts from the room’s state, but such a cache doesn’t rewind with it, so keep only data derived from static entities in it, such as a voxel chunk’s collision shapes, never state a step advances. @irt/physics keeps its Rapier world this way, and makes a body’s colliders again when its kind’s shapeKey changes.

Predicting edits

An edit the player makes next to its own entity, such as digging the block under its feet, mispredicts if it goes as a message: the room applies it when it arrives, while the prediction has already stepped past with the old blocks. Send it as fields of the input instead, and apply it in the stepper:

const DigInput = entity("input", { dx: "i8", edit: "u8", ex: "i16", ey: "u8", ez: "i16", eb: "u8" });

step(world, inputs) {
  for (const p of world.all(Player)) {
    const i = inputs.get(p.owner);
    if (i?.edit && inReach(p, i) && p.editsLeft > 0) {
      chunkAt(world, i.ex, i.ez)?.patch("blocks", blockIndex(i.ex, i.ey, i.ez), new Uint8Array([i.eb]));
      p.editsLeft--;
    }
    // ...movement, which now sees the edited block.
  }
}

The room applies the edit at the input’s tick and the client predicts it at the same tick, so an edit the room accepts mispredicts nothing. Checks the room makes, such as reach and a rate limit, go in the stepper too, with the rate limit’s state in an entity field, as editsLeft is, so the client predicts it.

Inputs are best-effort, unlike messages. The room drops an input that arrives after its tick has run or more than 32 ticks ahead, and acknowledges it as handled; each message repeats only the last 4 inputs; and the client keeps at most 64 unacknowledged ones. Under lag or loss an edit sent as an input can be lost without an error, and the player sees the block come back when the prediction replays. A message arrives in order or the connection drops. Use inputs for edits a player makes while moving next to them, where a lost edit costs a retry, and messages or calls for edits that must land: rare or costly ones, ones not tied to the player’s movement, and anything the game records, such as a build placed from an inventory.

Shooting and lag compensation

A player aims at remote players drawn by interpolate, which shows them a little in the past, while the room resolves the shot at its own current tick. Checking the shot against where targets are now misses anything that moves. Rewind the targets to what the shooter saw, as Lag compensation shows with defineHistory and room.rewind. The ticks involved:

  • Values at tick T are an entity’s fields at the end of tick T, after the stepper and onTick: what the state update for tick T carries, and what room.rewind(history, T) returns.
  • interpolate draws remote entities at render time serverTick() - max(2 ticks, 100 ms), between the updates for the tick before that time and the tick after it.
  • conn.viewTick is that render time, rounded down to a tick and never past the last update the client has, when the client sampled the input the room applies this tick. During the stepper and onTick it’s the view tick of this tick’s input, or of the last one applied, and at most MAX_REWIND_MS (250 ms) behind the room. The shooter saw targets between their values at viewTick and viewTick + 1, so allow a tick of their movement in the hit test.
  • Don’t rewind the shooter. Its input moved it in this tick’s step, as its prediction did on its screen.
  • On a link slower one way than the other, the client’s estimate of the room’s tick is off by half the difference, so viewTick is too. See how accurate serverTick() is.

Testing it

TestClient.predict samples once per room tick as the test moves the clock. Since pending never reaches 0 while sampling, wait for settled to know the prediction has caught up with the room:

// prediction.test.ts
import { testRoom } from "@irt/testing";
import { expect, test } from "vitest";
import room from "./room.js";
import { Runner, stepper } from "./shared.js";

test("the client's prediction converges on the room's wall", async () => {
  const t = testRoom(room);
  const alice = await t.connect({ player: "alice" });
  let dx = 1;
  const me = alice.predict(stepper, {
    find: (r) => [...r.entities.all(Runner)].find((e) => e.owner === r.player),
    sample: () => ({ dx, dy: 0 }),
  });
  await t.host.tick(300);
  expect(me.settled).toBe(false);
  dx = 0;
  await t.host.tick(30);
  expect(me.mispredictions).toBeGreaterThan(0);
  expect(me.settled).toBe(true);
  expect(me.entity!.x).toBe(50);
  // render is still smoothing the last misprediction away, by 20% a tick.
  expect(me.render(["x"]).x).toBeCloseTo(50, 1);
});

test("sample can steer by the prediction it belongs to", async () => {
  const t = testRoom(room);
  const alice = await t.connect({ player: "alice" });
  const me = alice.predict(stepper, {
    find: (r) => [...r.entities.all(Runner)].find((e) => e.owner === r.player),
    sample: (p) => ({ dx: (p.entity?.x ?? 0) < 10 ? 1 : 0, dy: 0 }),
  });
  await t.host.tick(120);
  expect(me.settled).toBe(true);
  expect(me.entity!.x).toBeGreaterThanOrEqual(10);
});

Common mistakes

  • Steppers that depend on anything but world, inputs, dt and tick. Math.random(), Date.now() or module state make the client’s replay differ from the room, and every tick becomes a misprediction.
  • Moving the predicted entity outside the stepper. Room code in onTick that moves it produces a misprediction each tick; put movement in the stepper and keep onTick for rules the client can’t predict.
  • Calling push and passing sample. With sample the prediction pushes for you; pushing as well sends two inputs per tick.
  • Waiting for pending to reach 0 in a test. With sample it doesn’t; assert on settled.
  • Drawing entity instead of render(...). entity jumps on a misprediction; render smooths it.
  • Trusting inputs in the room. Without parseInput, step receives whatever the client sent: any JSON, or with an input kind any value its field types hold.
  • Predicting other players. Interpolate them; only the local player’s entity is predicted. Others in the prediction world are where the client last saw them, or with @irt/physics, dynamic bodies are extrapolated to the tick being predicted.

View this page as Markdown