Client prediction
Without prediction, a player’s own body answers the stick one round trip late. They press right, the intent travels to the server, the server steps the world, and the new position comes back. At 150 ms that is a wait a player can feel.
With prediction, the client builds its own Rapier world from the same code the room uses, runs it
ahead of the server, and serves the result from room.render. Input feels immediate. Every
authoritative tick the local world is rebased onto the server’s values and re-stepped, so the
server stays the authority and the client stays honest about where things really are.
Use this when
Turn prediction on when the player directly steers a body and the game is about how it moves: platformers, racers, arena games, anything with a held input. Leave it off when nothing is player steered (a marble run, a spectator view), when latency is not in the loop (a local party game), or when the bodies are decoration. Interpolated bodies already look smooth, and prediction adds a Rapier world to every client.
One world builder, both sides
The client cannot import your room file, because room code is server-only. So the parts of the
world both sides need live in their own module, and both sides import it. By convention that file
is irtio/world.ts, which is what irtio init --physics scaffolds. The name is a convention, not
a mechanism: the builder can live anywhere both sides can import from.
It exports four things.
// irtio/world.ts (imported by irtio/room.ts AND by your client entry)
import type RAPIER from '@dimforge/rapier3d-compat';
type Rapier = typeof RAPIER;
export const gravity = { x: 0, y: -9.81, z: 0 } as const;
export const ARENA_HALF = 16;
export const BALL_RADIUS = 0.6;
export const NUDGE = 0.55;
/** Static geometry. Takes nothing but `world` and `rapier`, so both sides build it identically. */
export function setup(world: RAPIER.World, rapier: Rapier): void {
const floor = world.createRigidBody(rapier.RigidBodyDesc.fixed().setTranslation(0, -0.5, 0));
world.createCollider(rapier.ColliderDesc.cuboid(ARENA_HALF + 2, 0.5, ARENA_HALF + 2), floor);
}
/** Shape factories, one per physics collection. */
export const bodies = {
players: (rapier: Rapier) => ({
body: rapier.RigidBodyDesc.dynamic().lockRotations(),
colliders: [rapier.ColliderDesc.ball(BALL_RADIUS).setRestitution(0.35).setFriction(0.4)],
}),
};
/** Intent to force, applied once per step. Both simulations call this exact function. */
export const intents = {
players: (body: RAPIER.RigidBody, player: { ax: number; az: number }): void => {
if (player.ax === 0 && player.az === 0) return;
body.applyImpulse({ x: player.ax * NUDGE, y: 0, z: player.az * NUDGE }, true);
},
}; The room config consumes gravity, setup and bodies directly, and tick() calls the intent
hook per instance:
// irtio/room.ts
import { defineRoom } from '@irtio/server';
import { schema } from './schema.js';
import * as world from './world.js';
export default defineRoom(schema, {
mode: 'tick',
tickRate: 30,
physics: {
engine: 'rapier3d',
gravity: { ...world.gravity },
setup(rapierWorld, rapier) {
world.setup(rapierWorld, rapier);
},
bodies: world.bodies,
},
validate: {
players: (_prev, next) => ({
...next,
ax: Math.max(-1, Math.min(1, next.ax)),
az: Math.max(-1, Math.min(1, next.az)),
}),
},
tick(state, _dt, room) {
// The shared hook. The client re-steps its own ball with this same function.
for (const [id, player] of state.players) {
const body = room.physics.body('players', id);
if (body) world.intents.players(body, player);
}
},
}); The intent hook is the part that is easy to miss. Static geometry alone is not enough for a client to re-step a body: it also has to apply the same forces the server applied, in the same way. Keep the mapping in the shared module and both simulations stay the same simulation.
Turn it on
Pass the same exports to joinRoom:
// main.ts
import { joinRoom } from '@irtio/client';
import { schema } from './irtio/schema.js';
import * as world from './irtio/world.js';
const room = await joinRoom(schema, {
name: 'anon',
physics: {
gravity: world.gravity,
setup: world.setup,
bodies: world.bodies,
intents: world.intents,
},
});
// Input writes the intent fields, exactly as it would without prediction.
const held = new Set<string>();
function applyIntent(): void {
const me = room.state.players[room.me];
if (!me) return;
me.ax = (held.has('ArrowRight') ? 1 : 0) - (held.has('ArrowLeft') ? 1 : 0);
me.az = (held.has('ArrowDown') ? 1 : 0) - (held.has('ArrowUp') ? 1 : 0);
}
window.addEventListener('keydown', (e) => {
held.add(e.key);
applyIntent();
});
window.addEventListener('keyup', (e) => {
held.delete(e.key);
applyIntent();
});
// A key released while the tab is backgrounded never fires `keyup`.
window.addEventListener('blur', () => {
held.clear();
applyIntent();
});
function frame(): void {
// Same read as always. `room.render` now serves predicted values for predicted bodies.
for (const [id, player] of room.render.players) draw(id, player);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame); Nothing about your input code changes. You still write intent fields, the server still validates
them, and the draw loop still reads room.render. What changes is where the numbers for your own
body come from.
| option | meaning |
|---|---|
gravity | Required. Must equal the room’s gravity, which is why it lives in the shared module. |
setup | The shared static-geometry builder. |
bodies | The shared shape factories. |
intents | The shared intent-to-force hooks, applied before every predicted step for bodies you own. |
timestep | Seconds per step. Defaults to the room’s tick interval, taken from the welcome frame. |
maxPredictedBodies | Cap on non-owned predicted bodies. Default 64 (MAX_PREDICTED_BODIES). |
epsilon | Correction suppression tolerance in world units. Default 0.05 (PREDICTION_EPSILON). |
Rapier loads on the client through a dynamic import(), so a game without physics ships none of
it, and joining never waits on the WASM. Until the engine lands, body-backed entities interpolate
like any other entity and then prediction takes over.
Rules that keep the two worlds one world
- The builder must be pure over synced inputs. No
Math.random(), noDate.now(), no module level mutable state. Seeds and level parameters belong in synced state, where both sides read the same value.irtio simulatebuilds the world twice and compares Rapier’s snapshots byte for byte, so a nondeterministic builder fails the run rather than drifting in production. - Client code must never import the room file. Declare your client entry in
irtio.json("client": "main.ts") and bothirtio devandirtio deploywarn when the client’s import graph reaches the room. - Map the velocity channels. A correction can only rebase what the schema carries. A predicted
body whose schema maps positions but not
vx/vy/vzcan never have its velocity corrected, so ordinary timing noise accumulates forever and every tick reads as a misprediction. The client warns once when it sees this.
What gets predicted
Bodies this client owns are predicted whenever you pass physics. Their intents are the only
thing this client writes, and those buffered intent frames are what the re-step replays.
Collections marked predicted: true are simulated ahead too, even though this client owns
none of them:
// irtio/schema.ts
pucks: entity(
{ x: f32, y: f32, z: f32, vx: f32, vy: f32, vz: f32 },
{ physics: { body: { x: 'x', y: 'y', z: 'z', vx: 'vx', vy: 'vy', vz: 'vz' } }, predicted: true },
), predicted: true requires physics on the same entity. Like interpolate, it is client behaviour
and stays out of the schema hash.
The cap, and what happens over it
A client simulates at most 64 non-owned predicted bodies, the value of MAX_PREDICTED_BODIES.
Bodies you own are always predicted and do not count against it. Override with maxPredictedBodies.
Over-cap instances are absent from the client’s local world. They are not simulated there and they are not approximated there. They still render, because the renderer reads interpolated authoritative state and never asks the local world about them, so nothing looks missing. The simulation is the half that differs: a predicted body that collides with one of them on the server has nothing to collide with locally, so it passes straight through and the next correction snaps it back.
How much that matters depends on what fell over the edge.
- A decoration (debris, a rolling barrel nobody touches) is fine. It looks right, and no predicted body reaches for it.
- A floor, a wall, or a crate a player stands on is not fine. The player falls through something
that is solid on the server and gets yanked back a half round trip later. That is a gameplay bug,
and it is what the over-cap console warning and
stats.overCapare telling you about.
Which instances fall over the cap is deterministic (collection order, then insertion order), but it
is not chosen by relevance: the ninth crate spawned is dropped even when it is the one under your
feet. Order your collections so the cutoff lands on decoration (put players and terrain first),
raise the cap past your worst case count of active bodies, and keep that count bounded by design:
one area live at a time, despawn what is out of play.
The cap is not there to save CPU. Body count is not what makes prediction expensive: 41 dynamic
bodies in contact step in 0.005 to 0.007 ms, and a rebase costs one world step per lead tick per
authoritative tick, roughly 0.5 ms per second of play at 30Hz with a three-tick lead. Budget
against stats.lastResimMicros, not against the body count.
Predict both, or interpolate both
The local world contains predicted bodies and static geometry. Nothing else. Non-predicted dynamic bodies are not in it, and neither are over-cap instances.
So if two collections interact, predict both (predicted: true, with a cap above their combined
count) or let both interpolate. A crate a player shoves has to be in the local world, or the shove
mispredicts every time.
Contact with another player’s body mispredicts either way. You cannot know their input before the server does. The correction that follows is bounded and smooth rather than a teleport, but it is there, and no setting removes it.
How reconciliation works
The local world free-runs on a fixed-timestep accumulator driven by your render reads. The draw loop is the clock, so there is no separate timer, and a backgrounded tab banks at most five steps before it resumes at authority rather than fast-forwarding through minutes of simulation.
When authoritative body state arrives (a correction for a body you own, a delta for a non-owned predicted one), the client rebases:
- Every predicted body snaps to the server’s values. Body fields are never client-written, so authoritative state holds exactly what the server said.
- The world re-steps the client’s lead, which is
ceil(rtt / 2 / tick) + 1ticks from the measured round trip time inroom.rtt. - Each re-step applies the buffered intent frames that the server has not judged yet, oldest first, falling back to your current intent values past the end of the buffer.
If the lead is longer than the shared resimulation depth of 20 ticks (RESIM_DEPTH, about 1.3
seconds of round trip at 30Hz), there is no re-step: the body snaps to authority and the snap is
counted in stats.snaps.
After every step, each owned body’s channels are recorded against the tick that step predicted. A correction for tick T is then judged against the prediction for tick T, not against the current head of the simulation, which legitimately leads authority by the one-way latency. That is what makes the misprediction numbers mean “how wrong was the model” rather than “how far ahead are we”.
Corrections come in three kinds
A server-authoritative body reaches its owner as one correction per tick. Whether that is a signal
depends on whether this client predicted the field. room.on('correct', ...) gives you a Correction with two flags:
| kind | flags | what it means |
|---|---|---|
| Simulation | simulation: true | Not predicted here. Just body state arriving, once per tick, by design. Not a disagreement. |
| Confirmation | suppressed: true | Predicted, and every value matched within epsilon. Authority still applies. Steady state is mostly these. |
| Misprediction | both false | A real disagreement. previous holds what this client predicted for that server tick. |
Positions compare against epsilon world units directly. Velocity channels compare against epsilon / timestep, because a velocity disagreement matters by how far it moves a body in one
tick. The default epsilon of 0.05 assumes a world at roughly human scale, so a game at a very
different scale should set its own.
Chromium browsers and the server’s V8 run the same WASM bit for bit, so honest play suppresses nearly everything. Safari and Firefox can differ in the last bit of a float, which shows up as tiny persistent corrections well inside epsilon.
Is prediction helping?
room.prediction is present when the join passed physics and the schema has bodies to predict.
const hud = document.getElementById('hud')!;
function renderHud(): void {
const p = room.prediction;
hud.textContent = p?.active
? `${room.rtt} ms · suppressed ${p.stats.suppressed} · snaps ${p.stats.snaps} · over cap ${p.stats.overCap}`
: 'interpolating';
} | member | meaning |
|---|---|
active | The engine is loaded and the local world exists. |
predicts(collection, id) | Is this instance in the local world right now? |
stats.suppressed | Corrections that matched the prediction. In healthy play this climbs steadily. |
stats.snaps | Rebases whose lead outran the resim depth. Should be zero at playable latency. |
stats.overCap | Non-owned predicted instances with no local body. Any non-zero value on a collection anything stands on is a bug. |
stats.rebases, stats.freeSteps, stats.resimSteps | Step and rebase counters. |
stats.lastResimMicros | Microseconds spent in the last rebase. This is the number to budget against. |
What healthy looks like: overCap at zero, snaps at zero, and the large majority of per-tick
corrections suppressed. What unhealthy looks like: a rising overCap (raise the cap or predict
fewer things), non-zero snaps (the connection is worse than the resim window, and the body is
riding authority), or a stream of loud corrections (usually contact with something the local world
does not have, or missing velocity channels).
irtio simulate measures the same thing headlessly. When the project has a shared world builder,
its bots predict the way a browser would, and the run reports misprediction magnitude in world
units, snap counts and suppressed confirmations, after running the build-twice determinism check.
What prediction does not do
- Prediction is not rollback netcode. The client re-steps predicted bodies. It does not roll the whole room back and replay it. Everything the room did other than physics stands.
- Over-cap bodies are absent from the local world rather than approximated in it, as described above. Order your collections so the cutoff lands on decoration.
Next steps
- Physics rooms for the server half and the tick order.
- Client reference for
joinRoom,room.renderand corrections. - Server authority for what the server does with your intents.