Retrofit a Three.js game
The canvas retrofit is the shape. This page is the same shape against a
Three.js loop, and unlike a page of invented snippets, every “after” line here is copied from examples/rapier-arena, which builds, typechecks and runs its own browser test in this
repository. If a line here stops matching that file, the test at packages/cli/test/docs-snippets.test.ts fails.
Three.js changes nothing about the model. Your meshes stay yours, your camera stays yours, your
materials and lights and post-processing stay yours. What changes is where the numbers you feed
into mesh.position come from.
What is different from the canvas case
Two things, and only two.
A scene graph is not a draw call. The canvas loop redraws from scratch every frame, so iterating a collection is the whole change. Three.js keeps meshes alive between frames, so joins and leaves have to add and remove meshes. That is a map from id to mesh and a pass to reconcile it, which is code you would have written for any multi-entity Three.js scene anyway.
3D usually means physics, and physics means the server owns the positions. The client writes
an intent (a steering axis), the server steps the world, and the client predicts locally with the
same world-builder module. That is the Rapier path, and it is what rapier-arena shows.
1. Scaffold, with physics
npx irtio init --physics That writes the same files as a plain init plus irtio/world.ts, the shared world-builder both
sides import. The rule that makes prediction work: world.ts is pure over synced inputs, so no Math.random(), no clock, no module state.
2. The schema declares which fields the simulation owns
// irtio/schema.ts
players: entity(
{ x: f32, y: f32, z: f32, vx: f32, vy: f32, vz: f32, ax: f32, az: f32, name: str(24) },
{
physics: {
body: { x: 'x', y: 'y', z: 'z', vx: 'vx', vy: 'vy', vz: 'vz' },
intents: ['ax', 'az'],
},
},
), body fields are simulation output and read-only everywhere. intents are the only fields a
client may write. Map the velocity channels as well as the positions: a correction can only rebase
what the schema carries, and a predicted body drifts without them.
3. Join, and hand the client the same world
+import { joinRoom } from '@irtio/client';
+
+import { schema } from './irtio/schema.js';
+import * as world from './irtio/world.js';
+
+const room = await joinRoom(schema, {
+ role: 'player',
+ physics: {
+ gravity: world.gravity,
+ setup: world.setup,
+ bodies: world.bodies,
+ intents: world.intents,
+ },
+}); Passing physics is what turns on client-side prediction. Without it everything still works; the
local ball just waits a round trip before it moves.
4. Input writes an intent instead of moving a mesh
function applyIntent(): void {
- ball.position.x += ax * SPEED;
- ball.position.z += az * SPEED;
+ const me = room.state.players[room.me];
+ if (!me) return;
+ me.ax = Math.max(-1, Math.min(1, ax));
+ me.az = Math.max(-1, Math.min(1, az));
} The clamp is not politeness. The room clamps the same values in validate, and clamping on both
sides means the prediction agrees with the server instead of being corrected every tick.
5. The render loop reconciles meshes against room.render
function frame(): void {
- ball.position.set(local.x, local.y, local.z);
+ const players = new Map<string, { x: number; y: number; z: number }>();
+ for (const [id, p] of room.render.players) players.set(id, p);
+
+ syncPlayers(handles, world.BALL_RADIUS, players, room.me);
+ followCamera(handles, players.get(room.me));
handles.renderer.render(handles.scene, handles.camera);
requestAnimationFrame(frame);
} syncPlayers is ordinary Three.js and lives in your own code, not in irtio. In rapier-arena it
is about twenty lines: for each id in the map, find or create a mesh, set its position; for each
mesh with no id left, remove and dispose it. Write it once and every collection uses it.
Read from room.render, not room.state. render gives your own predicted body at its immediate
local values and everyone else’s a beat behind arrival, interpolated between the updates either
side, which is what stops remote balls stepping at the tick rate. room.state stays the
authoritative read path for game logic and tests.
What did not change
The scene, the camera rig, the lights, the materials, the resize handler, the HUD, the loader, the
post-processing. Look at examples/rapier-arena/src/render.ts: it is plain Three.js and imports
nothing from irtio.
Verify it
npx irtio dev
npx irtio simulate --bots 8 --seconds 30 A physics room has one number worth watching that a canvas room does not: misprediction. The run
reports mean and maximum divergence between what the client predicted and what the server decided,
plus snap counts. On localhost with rapier-arena that reads as a few units of mean misprediction
and zero snaps. A snap means a correction landed outside the resimulation window, and a room that
snaps regularly has a world-builder that is not pure, or intents the client is not clamping.
Next steps
- Physics for the full prediction model
- Retrofit a Phaser game
- Retrofit an existing game, the canvas original