Co-op movement
Each player’s character is an entity that player owns. The client writes it directly, so a player’s own movement has no latency. One server-side validator on that collection stops a bug or a modified client teleporting across the level.
Use client-owned movement when the player is reporting a fact about themselves (where I am, which way I am facing, what I am holding) and losing to a cheater costs nothing. Reach for server-authoritative simulation instead when position decides who wins, and for RPCs when the game decides something rather than the player.
Dive, a co-op physics platformer, and the 240-player pellet arena example are both built this way.
Without physics
One entity per player, owned by that player. Start here even if the game will need physics later.
// irtio/schema.ts
import { defineSchema, entity, f32, str } from '@irtio/schema';
export const schema = defineSchema(
{
players: entity({ x: f32, y: f32, facing: f32, name: str(24) }),
},
{ project: 'p_c0ffee1234abcd56', roles: ['player'] as const },
); // irtio/room.ts
import { defineRoom } from '@irtio/server';
import { schema } from './schema.js';
const WORLD = 1024;
/** Top speed times the flush window, with headroom. Anything faster than this is not walking. */
const MAX_STEP = 48;
export default defineRoom(schema, {
mode: 'tick',
tickRate: 20,
onJoin(state, ctx) {
// A reconnect keeps the character it already has. A fresh spawn would teleport a player
// who just hit a rough patch of wifi.
if (ctx.reconnecting || state.players.has(ctx.clientId)) return;
state.players.add(
ctx.clientId,
{ x: WORLD / 2, y: WORLD / 2, facing: 0, name: ctx.name || 'anon' },
{ owner: ctx.clientId },
);
},
onLeave(state, ctx) {
state.players.remove(ctx.clientId);
},
validate: {
players(prev, next) {
// One write moved further than a walk could. Reject the whole write: the owner is
// corrected back to `prev`.
if (Math.hypot(next.x - prev.x, next.y - prev.y) > MAX_STEP) return prev;
// Otherwise accept it, clamped into the world.
return {
...next,
x: Math.min(WORLD, Math.max(0, next.x)),
y: Math.min(WORLD, Math.max(0, next.y)),
};
},
},
tick() {},
}); The client moves its own character the way it always did, then writes the result:
// main.ts
import { joinRoom } from '@irtio/client';
import { schema } from './irtio/schema.js';
const room = await joinRoom(schema, { role: 'player', name: 'you' });
const SPEED = 220;
function frame(dt: number): void {
const me = room.state.players[room.me];
if (me) {
me.x += input.x * SPEED * dt;
me.y += input.y * SPEED * dt;
me.facing = Math.atan2(input.y, input.x);
}
// `room.render` interpolates everyone else a beat behind arrival, so remote characters
// glide instead of stepping at the tick rate. Your own character reads at its local values.
for (const [id, p] of room.render.players) draw(p, id === room.me);
requestAnimationFrame(() => frame(1 / 60));
}
requestAnimationFrame(() => frame(1 / 60)); Writes are batched and sent once per flush window (frame-aligned, 50 ms hard cap), and the server
applies them through validate before anyone else sees them.
What the validator can and cannot see
The signature is (prev, next, ctx). You get the last accepted values, the incoming values, and
the calling client. You do not get state, so a validator cannot ask a question about the rest
of the world: it cannot check whether a wall is in the way, whether the match has started, or
what another player is doing.
Anything cross-entity belongs in tick, which runs after the writes for that tick are applied and
can see all of state:
tick(state) {
for (const [, p] of state.players) {
// Server-authored writes never pass through `validate`, so the room can correct
// anything it likes here.
if (insideWall(p.x, p.y)) pushOut(p);
}
} Anything that has to be atomic with a decision belongs in an RPC instead.
Corrections
When a validator returns prev, the server sends the owner a correction and the client’s local
value snaps back. Listen for it while you are tuning MAX_STEP:
room.on('correct', (c) => {
console.warn('corrected', c.collection, c.fields, c.previous, '->', c.patch);
}); The client re-applies newer local writes over a correction where it can, up to a window of 20
flushed writes. Past that the correction wins outright and c.snapped is true.
With physics
Dive is the same shape with a Rapier world behind it. The server simulates every character, each client writes only its own intents, and the client runs the same world locally so its own character answers the key immediately.
Three things change.
The entity declares which of its fields the simulation owns and which are intents:
// irtio/schema.ts
players: entity(
{ x: f32, y: f32, qz: f32, qw: f32, vx: f32, vy: f32, wz: f32, mx: f32, jump: f32, name: str(24) },
{
physics: {
body: { x: 'x', y: 'y', qz: 'qz', qw: 'qw', vx: 'vx', vy: 'vy', wz: 'wz' },
intents: ['mx', 'jump'],
},
},
), Body-mapped fields are simulation output and read-only to every client. The declared intents are
the only fields a client may write on a physics entity, which is why name is set once
server-side in onJoin.
The world builder lives in its own module that both sides import, by convention irtio/world.ts. It must never import @irtio/server or the room file, and it must be pure over
synced inputs: no Math.random(), no clock, no reading anything that is not a parameter. If the
two worlds are built differently they are not the same world.
// irtio/world.ts
import type RAPIER from '@dimforge/rapier3d-compat';
type Rapier = typeof RAPIER;
type World = RAPIER.World;
type Body = RAPIER.RigidBody;
export const gravity = { x: 0, y: -30, z: 0 } as const;
export const bodies = {
players: (rapier: Rapier) => ({
// Translation free in X and Y, pinned in Z: 2D on a 3D engine.
body: rapier.RigidBodyDesc.dynamic().enabledTranslations(true, true, false).lockRotations(),
colliders: [rapier.ColliderDesc.capsule(0.5, 0.35).setFriction(0.1).setDensity(1)],
}),
};
/** Is there floor within reach of the capsule's feet? There is no contact event queue,
* so every contact question in a room is a ray cast like this one. */
function grounded(world: World, rapier: Rapier, body: Body): boolean {
const at = body.translation();
const ray = new rapier.Ray({ x: at.x, y: at.y - 0.83, z: at.z }, { x: 0, y: -1, z: 0 });
return world.castRay(ray, 0.12, true, undefined, undefined, undefined, body) !== null;
}
export const intents = {
/** One step of steering. Called by the room's tick and replayed by the client's predictor. */
players: (body: Body, player: { mx: number; jump: number }, rapier: Rapier, world: World) => {
const v = body.linvel();
const want = player.mx * 9;
const limit = 90 * world.timestep;
const dv = Math.max(-limit, Math.min(limit, want - v.x));
if (dv !== 0) body.applyImpulse({ x: dv * body.mass(), y: 0, z: 0 }, true);
if (player.jump === 1 && grounded(world, rapier, body)) {
body.setLinvel({ x: v.x, y: 13, z: 0 }, true);
}
},
}; The room steps the world and the client asks for the same one:
// irtio/room.ts
export default defineRoom(schema, {
mode: 'tick',
tickRate: 30,
physics: { engine: 'rapier3d', gravity: { ...world.gravity }, bodies: world.bodies },
validate: {
// Clamp intents to the range honest play produces, so a modified client gains
// nothing by sending 40.
players: (_prev, next) => ({
...next,
mx: Math.max(-1, Math.min(1, next.mx)),
jump: next.jump >= 1 ? 1 : 0,
}),
},
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, room.physics.rapier, room.physics.world);
}
},
}); // main.ts
import rapier3d from '@irtio/client/rapier3d';
const room = await joinRoom(schema, {
role: 'player',
name: 'you',
physics: {
module: rapier3d,
gravity: world.gravity,
bodies: world.bodies,
intents: world.intents,
maxPredictedBodies: 64,
},
});
// Input sets intent fields. Nothing else about the character is client-writable.
function applyIntent(): void {
const me = room.state.players[room.me];
if (!me) return;
me.mx = Math.max(-1, Math.min(1, steer()));
me.jump = jumpHeld() ? 1 : 0;
} The engine comes from its own subpath entry point, so a game that imports no engine pays nothing for physics. Physics overview has the full contract, and Prediction explains the local world in detail.
Local collisions
Predict bodies a player pushes. Other bodies get kinematic proxies, so floors and platforms remain solid without full local simulation. The default budgets are 64 non-owned predicted bodies and 256 proxies. Bodies beyond the proxy budget are absent locally; monitor room.prediction.stats.absent.
See client prediction for the options and rendering behavior.
Related
- Physics overview
- Authoritative shooter, when position decides who wins