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
}
worldis 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, asworld.get(SHARED_ID)(0) and inworld.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.inputsmaps 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.dtis the fixed timestep in seconds,1 / tickRate.tickis the room tick being stepped:room.tickon 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 untiltickreaches the start, predicts exactly.world.predictedis the predicted entity on a client and undefined on the room, for a stepper that simulates only that entity on the client.world.stateTickis the room tick of the state the client holds, and undefined on the room.parseInputruns on the room only. Inputs arrive as JSON from the client, or as the schema’s input kind’s fields (see Compact inputs); withoutparseInputthe room passes the decoded input as is, so validate it here and throw to drop a bad one.saveandloadare 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’sworld, the objectstepgets; key caches by it. See Steppers.releasefrees what a stepper keeps for aworldthat’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/physicsis 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:
- 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.samplereceives thePredictionit belongs to, so it can steer byentity, which is set before each call wheneverfindfinds the entity. It’s undefined only whilefindfinds 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 asp.entity?.x ?? 0. The first call is at the next room tick afterpredictreturns, never during it. After the page was paused it callssampleup 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. - 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. - 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
rendersmooths away, 20% per tick. A field no step writes, such as a lap counter onlyonTicksets, is the room’s: its changes are taken as they come, never counted or smoothed. - 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 whatroom.rewind(history, T)returns. interpolatedraws remote entities at render timeserverTick() - max(2 ticks, 100 ms), between the updates for the tick before that time and the tick after it.conn.viewTickis 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 andonTickit’s the view tick of this tick’s input, or of the last one applied, and at mostMAX_REWIND_MS(250 ms) behind the room. The shooter saw targets between their values atviewTickandviewTick + 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
viewTickis too. See how accurateserverTick()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,dtandtick.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
onTickthat moves it produces a misprediction each tick; put movement in the stepper and keeponTickfor rules the client can’t predict. - Calling
pushand passingsample. Withsamplethe prediction pushes for you; pushing as well sends two inputs per tick. - Waiting for
pendingto reach 0 in a test. Withsampleit doesn’t; assert onsettled. - Drawing
entityinstead ofrender(...).entityjumps on a misprediction;rendersmooths it. - Trusting inputs in the room. Without
parseInput,stepreceives 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.