Client prediction

Client prediction simulates player input locally while the server controls the shared world. Use it when a player directly steers a physics body. Draw from room.render to show the predicted motion.

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 physics 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, along with the engine itself: each engine ships as its own subpath entry point of @irtio/client, so a game that imports none of them carries no physics chunks in its build.

// main.ts
import { joinRoom } from '@irtio/client';
import rapier3d from '@irtio/client/rapier3d';
import { schema } from './irtio/schema.js';
import * as world from './irtio/world.js';

const room = await joinRoom(schema, {
  name: 'anon',
  physics: {
    module: rapier3d,
    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.

optionmeaning
moduleRequired. The engine itself, the default export of @irtio/client/rapier3d.
gravityRequired. Must equal the room’s gravity, which is why it lives in the shared module.
setupThe shared static-geometry builder.
bodiesThe shared shape factories.
intentsThe shared intent-to-force hooks, applied before every predicted step for bodies you own.
timestepSeconds per step. Defaults to the room’s tick interval, taken from the welcome frame.
maxPredictedBodiesCap on non-owned predicted bodies. Default 64 (MAX_PREDICTED_BODIES).
maxProxyBodiesCap on kinematic proxies: the bodies this client keeps solid without simulating them. Default 256 (MAX_PROXY_BODIES).
epsilonCorrection suppression tolerance in world units. Default 0.05 (PREDICTION_EPSILON).
smoothingHalfLifeMsHow fast a re-simulation’s jump eases out of the drawn position. Default 70 (SMOOTHING_HALF_LIFE_MS); 0 disables.
smoothingSnapUnitsPast this the offset is dropped rather than eased. Default 4 (SMOOTHING_SNAP_UNITS).

A 2D room passes physics2d instead, and a room running your own stepper passes physicsCustom with a world factory and no engine module. The three options are mutually exclusive: a room runs one world, and the client predicts with that one.

Rapier reaches the client through the @irtio/client/rapier3d entry point you import, so a game that imports no engine ships none of it, and joining never waits on the WASM. Until the engine is ready, body-backed entities interpolate like any other entity and then prediction takes over.

Rendering

Draw predicted bodies from room.render. It blends recent poses at the display frame rate and smooths corrections. Continue reading the render view each frame to advance local prediction.

Correction smoothing

Corrections normally ease into the displayed pose. Tune smoothingHalfLifeMs and smoothingSnapUnits in the client physics options for your world’s scale. Large changes, such as respawns, snap directly.

Rules that keep the two worlds one world

  • The builder must be pure over synced inputs. No Math.random(), no Date.now(), no module level mutable state. Seeds and level parameters belong in synced state, where both sides read the same value. irtio simulate builds 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 both irtio dev and irtio deploy warn 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/vz can 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.

Prediction and proxy budgets

Clients predict their own bodies and up to 64 other bodies marked predicted: true. Raise maxPredictedBodies for objects players need to push locally. Selection follows collection order, then insertion order.

Other bodies use kinematic proxies: they collide locally and follow their rendered positions, but local forces do not move them. Up to 256 proxies are included by default; configure maxProxyBodies to change that number.

Set proxy: false on a collection for decoration that needs no local collision. Bodies beyond the proxy budget are also absent locally. Watch room.prediction.stats.absent for missing collision bodies and lastResimMicros for prediction cost.

Choosing bodies

Predict objects players steer or push. Use proxies for objects they stand on or collide with. A moving proxy follows the render view, so evaluate fast projectiles with server-side hit tests and lag compensation.

Player collisions can cause prediction corrections even when every input is valid. Each client predicts the collision locally, and the server’s result can differ. Frequent collisions may trigger a correction-storm in simulation reports. If your game does not need players to block each other, configure Rapier collision groups so players collide with the level but not with one another.

Reconciliation

The render loop advances local physics on a fixed timestep. When server state arrives, the client restores those values and replays buffered intents to catch up. Corrections are compared with the prediction for the same tick.

The replay window is 20 ticks. A correction beyond that window snaps to server state and increments stats.snaps. Keep shared world builders deterministic and map velocity channels so the client can reconstruct the simulation.

Correction types

Flags on room.on('correct', ...)Meaning
simulation: trueAuthoritative body state for a body not predicted here
suppressed: truePrediction matched within epsilon
Both falseA prediction disagreed with the server

The default epsilon is 0.05 world units. Choose a value appropriate to your world scale; velocity comparisons account for the timestep.

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} · proxies ${p.stats.proxies} · absent ${p.stats.absent}`
    : 'interpolating';
}
membermeaning
activeThe engine is loaded and the local world exists.
predicts(collection, id)Is this instance simulated in the local world right now?
proxied(collection, id)Does this instance have a kinematic proxy, a collider at the drawn pose? Never true at the same time as predicts.
stats.suppressedCorrections that matched the prediction. In healthy play this climbs steadily.
stats.snapsRebases whose lead outran the resim depth. Should be zero at playable latency.
stats.overCapNon-owned predicted instances past maxPredictedBodies. They are proxied, so they are solid; they are not simulated, so nothing local pushes them.
stats.proxiesKinematic proxies in the local world right now.
stats.absentInstances with no body in the local world at all: past maxProxyBodies, proxy: false, or no body factory. Any non-zero value on a collection anything stands on is a bug.
stats.rebases, stats.freeSteps, stats.resimStepsStep and rebase counters.
stats.lastResimMicrosMicroseconds spent in the last rebase. This is the number to budget against.
stats.smoothingThe largest render-error offset currently being eased away, in world units. Rises on a correction, falls back. A value that stays up means corrections are arriving faster than they can be absorbed.
stats.stampGapTicks between where your writes are stamped and where the server applied them. Should sit at 0 or 1; a larger number means the lead is guessing further ahead than the link needs, and release transitions will show it.

What healthy looks like: absent at zero, snaps at zero, and the large majority of per-tick corrections suppressed. What unhealthy looks like: a non-zero absent (raise maxProxyBodies, or set proxy: false on the collections nothing collides with so the budget goes where it matters), non-zero snaps (the connection is worse than the resim window, and the body is riding authority), or a stream of loud corrections (usually a player shoving something that is proxied rather than predicted, or missing velocity channels). A non-zero overCap is worth a look rather than an alarm: those bodies are solid, they are just not yours to push.

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.