Authoritative shooter
When position decides who wins, the server has to be the one simulating it. Clients stop writing where they are and start writing what they are trying to do, the room steps a fixed-rate world from those inputs, and hit detection happens somewhere no client can reach. The client then runs the same world locally so your own movement still answers the key immediately.
examples/rapier-arena is this shape end to end: owned balls steered by intents, server-spawned
pucks nobody owns, and one RPC that applies an impulse. examples/versus-stack is the
competitive shape without physics, which is a different set of tradeoffs and is covered at the
end.
Use this when
Use a server tick loop when a player could win by lying about position, velocity or a hit. For everything else, co-op movement is cheaper to build and feels the same. Do not reach for this because it sounds more correct: an authoritative world is more code, more server CPU, and it introduces the reconciliation problems the rest of this page is about.
Inputs, not positions
On a physics entity the schema splits the fields three ways. Body-mapped fields are simulation output, read-only to every client. Declared intents are the only fields a client may write. Anything else is server-written and refused from a client outright.
// irtio/schema.ts
import { defineSchema, entity, f32, str } from '@irtio/schema';
import { rpc } from './rpc.js';
const BALL_BODY = { body: { x: 'x', y: 'y', z: 'z', vx: 'vx', vy: 'vy', vz: 'vz' } } as const;
export const schema = defineSchema(
{
players: entity(
{ x: f32, y: f32, z: f32, vx: f32, vy: f32, vz: f32, ax: f32, az: f32, name: str(24) },
{ physics: { ...BALL_BODY, intents: ['ax', 'az'] } },
),
// Nobody owns a puck, but every client simulates it locally and ahead. Without
// `predicted: true` a puck would not exist in the local world at all, and a predicted
// ball would pass straight through it.
pucks: entity(
{ x: f32, y: f32, z: f32, vx: f32, vy: f32, vz: f32 },
{ physics: BALL_BODY, predicted: true },
),
},
{ project: 'p_4a91e4cafe000002', roles: ['player'] as const, rpc },
); name is not body-mapped and not an intent, so a client cannot set it. The room writes it once
in onJoin from the join-time name.
The shared world
Both simulations are built from the same module, by convention irtio/world.ts. It must not
import @irtio/server or the room file (the room file must never reach a browser bundle), and
every function in it has to be pure over synced inputs. No Math.random(), no clock, nothing
read from outside its parameters. Anything that varies has to vary by state that is itself
synced, or the two worlds quietly stop being the same world.
// irtio/world.ts
import type RAPIER from '@dimforge/rapier3d-compat';
type Rapier = typeof RAPIER;
type Body = RAPIER.RigidBody;
export const gravity = { x: 0, y: -9.81, z: 0 } as const;
export const ACCEL = 26;
/** Static geometry. Runs once per world, on both sides. */
export function setup(world: RAPIER.World, rapier: Rapier): void {
world.createCollider(rapier.ColliderDesc.cuboid(20, 0.5, 20).setTranslation(0, -0.5, 0));
}
export const bodies = {
players: (rapier: Rapier) => ({
body: rapier.RigidBodyDesc.dynamic(),
colliders: [rapier.ColliderDesc.ball(0.5).setRestitution(0.4).setDensity(1)],
}),
pucks: (rapier: Rapier) => ({
body: rapier.RigidBodyDesc.dynamic(),
colliders: [rapier.ColliderDesc.ball(0.35).setRestitution(0.7).setDensity(0.4)],
}),
};
export const intents = {
/** One step of steering. The server's tick calls this, and so does the client's predictor. */
players: (body: Body, player: { ax: number; az: number }): void => {
body.applyImpulse({ x: player.ax * ACCEL, y: 0, z: player.az * ACCEL }, true);
},
}; The tick loop
// 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,
maxClients: 8,
physics: {
engine: 'rapier3d',
gravity: { ...world.gravity },
setup(rapierWorld, rapier) {
world.setup(rapierWorld, rapier);
},
bodies: world.bodies,
},
onCreate(state) {
for (let i = 0; i < 4; i++) {
state.pucks.add(`puck${i}`, { x: i * 2 - 3, y: 1, z: 0, vx: 0, vy: 0, vz: 0 });
}
},
onJoin(state, ctx) {
if (ctx.reconnecting || state.players.has(ctx.clientId)) return;
state.players.add(
ctx.clientId,
{ x: 0, y: 1, z: 0, vx: 0, vy: 0, vz: 0, ax: 0, az: 0, name: ctx.name || 'anon' },
{ owner: ctx.clientId },
);
},
onLeave(state, ctx) {
state.players.remove(ctx.clientId);
},
validate: {
// Intents are the only client writes that reach a physics entity. Clamp them to the
// range honest input produces, so a modified client gains nothing by sending 40.
players: (_prev, next) => ({
...next,
ax: Math.max(-1, Math.min(1, next.ax)),
az: Math.max(-1, Math.min(1, next.az)),
}),
},
rpc: {
// A discrete action, as opposed to a held input. Void: the effect shows up as
// authoritative body state, which every client already observes.
shove(_state, { id, ix, iy, iz }, ctx) {
ctx.room.physics.body('players', id)?.applyImpulse({ x: ix, y: iy, z: iz }, true);
},
},
tick(state, _dt, room) {
for (const [id, player] of state.players) {
const body = room.physics.body('players', id);
if (body) world.intents.players(body, player);
}
},
}); tick runs at a fixed rate with the writes and calls for that tick already applied, then the
world steps once. There are no substeps: one step per tick, timestep defaulting to the tick
interval.
Held movement is an intent. Discrete actions (firing, reloading, using a thing) are RPCs, because they happen once at a moment rather than continuously.
Hit detection
There is no collision event queue on the room API. Contact and line-of-sight questions are
queries against the live world, which room.physics exposes directly:
rpc: {
fire(state, { dx, dy, dz }, ctx) {
const shooter = ctx.room.physics.body('players', ctx.clientId);
if (!shooter) return;
const { rapier, world: w } = ctx.room.physics;
const from = shooter.translation();
const ray = new rapier.Ray(from, { x: dx, y: dy, z: dz });
// The last argument excludes the shooter's own body from the cast.
const hit = w.castRay(ray, 100, true, undefined, undefined, undefined, shooter);
if (hit) applyDamage(state, hit);
},
}, As written, the shot is resolved from the server’s world at the server’s tick, not from what the shooter’s screen showed. At 200 ms against a target crossing six units a tick, that is a miss of about eighteen units on a shot the player saw land, and the only way to hit is to lead.
Judging the shot the shooter took
Declare a history on the physics config and wrap the same query in room.rewind, and the shot is
answered against the world the shooter was looking at:
physics: {
engine: 'rapier3d',
gravity: { x: 0, y: -9.81, z: 0 },
history: 12, // ticks of body poses to keep
bodies: { … },
},
rpc: {
fire(state, { dx, dy, dz }, ctx) {
const shooter = ctx.room.physics.body('players', ctx.clientId);
if (!shooter) return;
const { rapier } = ctx.room.physics; // the RAPIER namespace: Ray, shapes, filters
const from = shooter.translation();
// `ctx.clientTick` is the newest tick this client had applied when it fired.
return ctx.room.rewind(ctx.clientTick ?? ctx.tick, (past) => {
const ray = new rapier.Ray(from, { x: dx, y: dy, z: dz });
const found = past.rapier?.world.castRay(ray, 100, true);
const who = found ? past.rapier?.who(found.collider) : undefined;
if (who) applyDamage(state, who);
return { hit: who !== undefined };
});
},
}, Same query, same engine, one wrapper. Nothing is re-simulated and the live world is not touched: rewind poses a scratch world from stored poses and hands your query to it. The same measurement
that reports the eighteen-unit miss above reports zero with this in place.
The history is off until you declare it and costs heap per tick when you do, the stamp is a number the client chose, and a rewind further back than the buffer clamps rather than failing. All three are worth reading about before you ship it: lag compensation.
What prediction actually does
Pass physics to joinRoom and the client builds a local Rapier world from the same functions
the room uses:
// 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, {
role: 'player',
name: 'you',
physics: {
module: rapier3d,
gravity: world.gravity,
setup: world.setup,
bodies: world.bodies,
intents: world.intents,
maxPredictedBodies: 64,
},
});
// Input writes intents, and nothing else.
function onStick(x: number, z: number): void {
const me = room.state.players[room.me];
if (!me) return;
me.ax = Math.max(-1, Math.min(1, x));
me.az = Math.max(-1, Math.min(1, z));
}
// `room.render` reads predicted bodies at their local values and everything else
// interpolated, so one read path covers both.
function frame(): void {
for (const [id, p] of room.render.players) drawBall(p, id === room.me);
for (const [, p] of room.render.pucks) drawPuck(p);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame); Here is the loop it runs, because the details matter when you are debugging feel.
The local world free-runs on a fixed timestep. Each step applies the owner’s current intent
values through the same intents hook the server calls, then steps.
When authoritative body state arrives, every predicted body is rebased to the server’s values and the world re-steps the client’s lead, one step per tick of estimated one-way latency, replaying the buffered unjudged input frames.
A correction whose every value is within a tolerance of the local prediction is suppressed: the server’s authority still applies, but it is not counted as a misprediction, so a healthy body’s steady state stays quiet. The tolerance defaults to 0.05 world units for positions, and to the velocity that moves something 0.05 units in one tick for velocity channels.
If the lead outruns the resimulation window (20 flushed writes), the body snaps to authority instead, and the snap is counted.
room.prediction reports on all of it: active once the engine has loaded, predicts(coll, id) for whether one instance is in the local world, and stats with snaps, suppressed, overCap, freeSteps, resimSteps and the last resim’s cost in microseconds. Putting those in
a debug HUD during development is worth the twenty lines.
The engine is loaded with a dynamic import, so a game that passes no physics option ships none
of it.
The limits to design around
Prediction is physics. There is no general prediction for non-physics collections. An owned write to an ordinary entity is applied locally the moment you make it, and reconciled when the server judges it; that is local echo plus correction, not simulation.
The predicted-body cap decides what a player can push, not what they can touch. A client
simulates the bodies it owns plus predicted: true bodies up to maxPredictedBodies, 64 by
default. Everything else with a body factory gets a kinematic proxy: the same collider, moved
every step to the pose the renderer draws, so a player stands on it and takes cover behind it
either way. What an unsimulated body will not do is move when you shove it locally: the shove waits
for the server. So predict the things players push, and let the rest be proxied.
Past maxProxyBodies there is nothing there. The proxy cap is 256 by default, and an instance
over it has no local body at all: a predicted body passes straight through it and is snapped back
by the next correction. That is what room.prediction.stats.absent counts, and it is the one
number here that is a bug above zero.
Intents are held values, not press edges. The intent hook is stateless by contract, and an intent field keeps its value on the server until the next write replaces it. A flag that stays at 1 therefore keeps acting every step. If you need a single press, carry a counter field on the entity and compare it.
One engine, one step per tick. A room declares one of rapier3d, rapier2d or matter2d,
and steps it once. tickRate is capped
at 240, and physics requires tick mode: an event-mode room has no timestep to step on.
Competitive without physics
Not every competitive game needs a simulated world. examples/versus-stack runs gravity and
piece movement entirely client-side and only sends discrete events, and it is still hard to
cheat at, because of how the state is split:
- Each player’s own board is an instance-owned entity with a validator that rejects a malformed board and clamps score, lines and level to be monotonic.
- Attacks are a
serverOwnedentity holding a cumulative counter per attack type, and only the room writes them. A counter is idempotent, so a client that reconnects mid-round applies the difference between the counter and what it already applied, and needs no acknowledgements. - Match lifecycle is a
serverOwnedsingleton, including the shared piece-bag seed, so both clients derive the same sequence without either one choosing it.
That is a smaller and cheaper design than an authoritative world, and for a game whose simulation is deterministic from a seed it gives up very little. See Server authority for how to pick.
Related
- Prediction for the reconciliation detail
- Physics overview