Three.js

This page builds a shared Three.js scene from scratch: a schema, a room, and a render loop that keeps meshes in step with the players in the room. It assumes you know Three.js and have a page that already renders something.

To add irtio to a Three.js game that already exists, read Retrofit a Three.js game instead. That page is a diff. This one is the whole file.

Install

npx irtio init      # adds @irtio/schema, @irtio/server, @irtio/client and @irtio/testing
npm install
npm install three
npm install -D @types/three

irtio init asks one question, whether anything in your game moves without a player doing something, and writes irtio/schema.ts, irtio/room.ts, irtio/room.test.ts and irtio.json. The project id it writes is public and domain locked, so it is safe in client code.

The loop

Three.js keeps its own render loop. irtio does not take it over and gives you no callback that fires when state changes. You read the room inside the loop you already have.

Two read paths exist, and they are not interchangeable.

Read pathWhat it gives youWhere to use it
room.stateexactly what the server last said, plus your own local writesgame logic, your own input, tests
room.renderthe same shape, with non-owned instances drawn a beat behind arrival and their numeric fields interpolatedthe render loop

Draw from room.render. A room ticks 20 times a second and a browser paints 60, so drawing room.state steps remote players three frames at a time. room.render fills the gaps. Your own instance comes back from room.render at its immediate local values, so your input still feels instant.

Writes go the other way. Assign to the instance you own and the client batches every field you touched in one animation frame into a single update, capped at 50 ms by writeIntervalMs.

A shared arena

Players are boxes on a plane. Each client moves its own box. The room owns eight pickups and awards a point when a box reaches one.

Schema

// irtio/schema.ts
import { defineSchema, entity, f32, str, u8, u16 } from '@irtio/schema';

export const HALF = 20;   // arena half-width, world units

export const schema = defineSchema(
  {
    // One instance per client. The client that joined as it owns it and writes x, z and ry.
    players: entity({ x: f32, z: f32, ry: f32, hue: u8, name: str(24), score: u16 }),

    // serverOwned: the room places these and no client may write them.
    pickups: entity({ x: f32, z: f32 }, { serverOwned: true }),
  },
  {
    project: 'p_c0ffee1234abcd56',   // written by `irtio init`
    roles: ['player'] as const,
  },
);

score sits on the owned collection, and validate below refuses any client write that changes it. The room writes it instead.

Room file

// irtio/room.ts
import { defineRoom } from '@irtio/server';

import { HALF, schema } from './schema.js';

const MAX_STEP = 4;         // furthest a player may travel between two writes, world units
const PICKUP_RADIUS = 1.2;

const clamp = (v: number) => Math.min(HALF, Math.max(-HALF, v));
const spot = () => ({ x: (Math.random() - 0.5) * 2 * HALF, z: (Math.random() - 0.5) * 2 * HALF });

export default defineRoom(schema, {
  mode: 'tick',
  tickRate: 20,
  maxClients: 16,

  onCreate(state) {
    for (let i = 0; i < 8; i++) state.pickups.add(`pickup:${i}`, spot());
  },

  onJoin(state, ctx) {
    if (ctx.reconnecting) return;
    state.players.add(
      ctx.clientId,
      { ...spot(), ry: 0, hue: Math.floor(Math.random() * 256), name: ctx.name || 'anon', score: 0 },
      { owner: ctx.clientId },   // this is what lets the client write the instance
    );
  },

  onLeave(state, ctx) {
    state.players.remove(ctx.clientId);
  },

  // Every owned write passes through here. Return next to accept, prev to reject, or a clamped
  // object.
  validate: {
    players(prev, next) {
      if (!Number.isFinite(next.x) || !Number.isFinite(next.z)) return prev;
      // The room assigned these three at join, so a client write to them is a lie.
      if (next.name !== prev.name || next.hue !== prev.hue || next.score !== prev.score) return prev;
      if (Math.hypot(next.x - prev.x, next.z - prev.z) > MAX_STEP) return prev;
      return { ...next, x: clamp(next.x), z: clamp(next.z) };
    },
  },

  tick(state) {
    for (const [, player] of state.players) {
      for (const [, pickup] of state.pickups) {
        if (Math.hypot(player.x - pickup.x, player.z - pickup.z) > PICKUP_RADIUS) continue;
        // Move the pickup rather than removing and re-adding it: the client then sees two
        // changed fields instead of a leave and a join.
        const next = spot();
        pickup.x = next.x;
        pickup.z = next.z;
        player.score += 1;
      }
    }
  },
});

Scene code

// main.ts
import { joinRoom } from '@irtio/client';
import * as THREE from 'three';

import { HALF, schema } from './irtio/schema.js';

type Box = THREE.Mesh<THREE.BufferGeometry, THREE.MeshStandardMaterial>;
/** The fields the reconcile pass below reads. Both collections satisfy it. */
type Drawn = { x: number; z: number; ry?: number; hue?: number };

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 400);
scene.add(new THREE.HemisphereLight(0xffffff, 0x334455, 2));

const ground = new THREE.Mesh(
  new THREE.PlaneGeometry(HALF * 2, HALF * 2),
  new THREE.MeshStandardMaterial({ color: 0x1b2733 }),
);
ground.rotation.x = -Math.PI / 2;
scene.add(ground);

// Geometry is shared by every mesh of a kind, so a join allocates a material and nothing else.
const playerGeometry = new THREE.BoxGeometry(1, 1, 1);
const pickupGeometry = new THREE.OctahedronGeometry(0.4);

const room = await joinRoom(schema, { role: 'player', name: 'you' });

const keys = new Set<string>();
addEventListener('keydown', (e) => keys.add(e.key.toLowerCase()));
addEventListener('keyup', (e) => keys.delete(e.key.toLowerCase()));
addEventListener('pagehide', () => room.leave());
addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

const SPEED = 8;   // world units per second
const clock = new THREE.Clock();
const playerMeshes = new Map<string, Box>();
const pickupMeshes = new Map<string, Box>();

function drive(dt: number): void {
  // room.state, not room.render: this is the instance you write, and you want your own value.
  const me = room.state.players[room.me];
  if (!me) return;   // the instance arrives a frame or two after the join resolves
  const dx = (keys.has('d') ? 1 : 0) - (keys.has('a') ? 1 : 0);
  const dz = (keys.has('s') ? 1 : 0) - (keys.has('w') ? 1 : 0);
  if (dx === 0 && dz === 0) return;
  const len = Math.hypot(dx, dz);
  me.x += (dx / len) * SPEED * dt;   // owned write, batched into one update per frame
  me.z += (dz / len) * SPEED * dt;
  me.ry = Math.atan2(dx, dz);
}

/** Add a mesh for every id in the collection, drop the mesh for every id that left. */
function reconcile(
  meshes: Map<string, Box>,
  records: Iterable<readonly [string, Drawn]>,
  make: (record: Drawn) => Box,
): void {
  const seen = new Set<string>();
  for (const [id, record] of records) {
    seen.add(id);
    let mesh = meshes.get(id);
    if (!mesh) {
      mesh = make(record);
      meshes.set(id, mesh);
      scene.add(mesh);
    }
    mesh.position.set(record.x, 0.5, record.z);
    mesh.rotation.y = record.ry ?? 0;
  }
  for (const [id, mesh] of meshes) {
    if (seen.has(id)) continue;
    scene.remove(mesh);
    mesh.material.dispose();   // geometry is shared, so only the material is this mesh's to free
    meshes.delete(id);
  }
}

const makePlayer = (record: Drawn): Box =>
  new THREE.Mesh(
    playerGeometry,
    new THREE.MeshStandardMaterial({
      color: new THREE.Color().setHSL((record.hue ?? 0) / 256, 0.6, 0.55),
    }),
  );

const makePickup = (): Box =>
  new THREE.Mesh(pickupGeometry, new THREE.MeshStandardMaterial({ color: 0xffd166 }));

function frame(): void {
  const dt = Math.min(clock.getDelta(), 0.1);
  drive(dt);

  // room.render: remote boxes glide between ticks instead of stepping at 20 Hz.
  reconcile(playerMeshes, room.render.players, makePlayer);
  reconcile(pickupMeshes, room.render.pickups, makePickup);

  const me = playerMeshes.get(room.me);
  if (me) {
    camera.position.set(me.position.x, 14, me.position.z + 18);
    camera.lookAt(me.position);
  }

  renderer.render(scene, camera);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

Run it:

npx irtio dev

Open the page, copy the URL from the address bar once it carries ?room=CODE, and paste it into a second tab. Two boxes appear in both.

Engine-specific notes

Coordinate system. Three.js is y up and right handed. The schema above syncs x and z and pins y in the render loop, because the game is flat. Sync y too when height is part of the game. The unit is whatever you decide it is, so long as MAX_STEP in irtio/room.ts and SPEED in your loop agree about it.

Object graph. A collection is a map from id to record. A Three.js scene is a tree of objects that live between frames. The bridge is a Map<string, Mesh> and one reconcile pass, as above. Do not put a THREE.Object3D in the schema. Sync the numbers the other player needs and build the mesh from them.

Interpolation. room.render already interpolates numeric fields between the two updates either side of now - interpDelayMs, which defaults to max(50, 2 x tick interval) and is a joinRoom option. Do not lerp on top of it. Two layers of smoothing read as lag, not as smooth. Set interpolate: false on a collection whose values should snap, such as a scoreboard.

Angles. room.render lerps a rotation field like any other number, so an angle crossing pi interpolates the long way round and the mesh spins. Store the facing as sin and cos and call Math.atan2 when you draw it, or accept the wrap on a value that changes slowly.

Cleanup. Dispose a material when its mesh leaves, as the reconcile pass does. Dispose shared geometry once when the page tears down, not per removal. Call room.leave() on pagehide so the room sees the departure without waiting out the reconnection grace window, which is 30 seconds by default and set with reconnectGraceMs in irtio/room.ts.

The loop and write batching. In a browser the write window is one animation frame with a 50 ms hard cap. A backgrounded tab stops firing requestAnimationFrame, and the cap alone keeps pending writes moving. Writing the same field several times inside one frame costs one update, so there is no reason to throttle input by hand. Call room.flush() only when a single input has to leave now, and never in a loop.

3D and physics. When the world needs collisions the server should own the positions and the client should write an intent. That is a different shape from this page, and Physics covers it, along with client-side prediction against the same world both sides build.

Next steps