Integrations
Three.js
Render an IRT room with Three.js, with predicted movement, interpolated players, aim from a raycast, calls, spatial voice and teardown.
This page builds the runners game from
Engines and frameworks in 3D with
Three.js. It imports shared.ts from that page; read its
Connecting and
Teardown sections first.
The room’s world is flat, in x and y. The scene puts it on the
ground plane: a runner at room (x, y) stands at (x, 0, y) in Three.js,
and its angle turns it about the vertical axis.
// arena.ts
import { CloseCode, RoomClient } from "@irt/client";
import { joinVoice } from "@irt/client/voice";
import * as THREE from "three";
import { type Input, Runner, schema, stepper } from "./shared.js";
const router = location.hostname.endsWith(".irt-serve.com") ? "wss://eu.irt.io" : location.origin;
export function startArena(parent: HTMLElement, roomId: string, show: (status: string) => void) {
const room = new RoomClient({ router, project: "my-game", room: roomId, schema });
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(parent.clientWidth, parent.clientHeight);
parent.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, parent.clientWidth / parent.clientHeight, 0.1, 500);
scene.add(new THREE.HemisphereLight(0xffffff, 0x334455, 2));
const bodyShape = new THREE.CapsuleGeometry(0.4, 0.8);
const noseShape = new THREE.BoxGeometry(0.5, 0.15, 0.15);
const materials = {
mine: new THREE.MeshStandardMaterial({ color: 0x4cc9f0 }),
theirs: new THREE.MeshStandardMaterial({ color: 0xf72585 }),
nose: new THREE.MeshStandardMaterial({ color: 0xffffff }),
};
const meshes = new Map<number, THREE.Mesh>();
show("connecting");
const offs = [
room.on("waking", () => show("waking the room")),
room.on("disconnected", () => show("reconnecting")),
room.on("restored", () => show("")),
room.on("closed", (e) => {
if (e.code === CloseCode.SchemaMismatch) location.reload();
else show(e.message);
}),
room.entities.onEnter(Runner, (r) => {
const mesh = new THREE.Mesh(bodyShape, r.owner === room.player ? materials.mine : materials.theirs);
const nose = new THREE.Mesh(noseShape, materials.nose);
nose.position.set(0.5, 0.3, 0);
mesh.add(nose);
mesh.position.set(r.x, 0.8, r.y);
mesh.rotation.y = -r.angle;
scene.add(mesh);
meshes.set(r.id, mesh);
}),
room.entities.onChange(Runner, (r, changed) => {
const mesh = meshes.get(r.id);
if (mesh && changed.includes("angle")) mesh.rotation.y = -r.angle;
}),
room.entities.onLeave(Runner, (r) => {
const mesh = meshes.get(r.id);
if (mesh) scene.remove(mesh);
meshes.delete(r.id);
}),
];
const keys = new Set<string>();
const down = (e: KeyboardEvent) => keys.add(e.key);
const up = (e: KeyboardEvent) => keys.delete(e.key);
addEventListener("keydown", down);
addEventListener("keyup", up);
const me = room.predict(stepper, {
find: (r) => [...r.entities.all(Runner)].find((e) => e.owner === r.player),
sample: (): Input => ({
dx: (keys.has("ArrowRight") ? 1 : 0) - (keys.has("ArrowLeft") ? 1 : 0),
dy: (keys.has("ArrowDown") ? 1 : 0) - (keys.has("ArrowUp") ? 1 : 0),
}),
});
const others = room.interpolate(Runner, ["x", "y"]);
const ground = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const hit = new THREE.Vector3();
const aim = (e: PointerEvent) => {
const rect = renderer.domElement.getBoundingClientRect();
pointer.set(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
raycaster.setFromCamera(pointer, camera);
if (!raycaster.ray.intersectPlane(ground, hit)) return;
const at = me.render(["x", "y"]);
for (const r of room.entities.mine(Runner)) {
r.angle = Math.atan2(hit.z - at.y, hit.x - at.x);
const mesh = meshes.get(r.id);
if (mesh) mesh.rotation.y = -r.angle;
}
};
renderer.domElement.addEventListener("pointermove", aim);
const voice = joinVoice(room, {
position(player) {
const r = [...room.entities.all(Runner)].find((e) => e.owner === player);
return r && { x: r.x, y: 0, z: r.y };
},
spatial: true,
range: 20,
});
renderer.setAnimationLoop(() => {
const local = me.render(["x", "y"]);
for (const r of room.entities.all(Runner)) {
const at = r.owner === room.player ? local : others.get(r);
if (!Number.isNaN(at.x)) meshes.get(r.id)?.position.set(at.x, 0.8, at.y);
}
if (!Number.isNaN(local.x)) {
camera.position.set(local.x, 14, local.y + 10);
camera.lookAt(local.x, 0, local.y);
}
renderer.render(scene, camera);
});
const stop = async () => {
for (const off of offs) off();
renderer.setAnimationLoop(null);
renderer.domElement.removeEventListener("pointermove", aim);
removeEventListener("keydown", down);
removeEventListener("keyup", up);
me.stop();
room.leave();
await voice.disconnect();
for (const mesh of meshes.values()) scene.remove(mesh);
meshes.clear();
bodyShape.dispose();
noseShape.dispose();
for (const m of Object.values(materials)) m.dispose();
renderer.dispose();
renderer.domElement.remove();
};
return { room, voice, stop };
}
And the page that runs it, with a talk button and a call:
// main.ts
import { IrtError } from "@irt/client";
import { startArena } from "./arena.js";
const status = document.querySelector("#status")!;
const arena = startArena(document.querySelector("#game")!, new URLSearchParams(location.search).get("room") ?? "arena", (s) => {
status.textContent = s;
});
// Browsers grant the microphone only from a user gesture.
document.querySelector("#talk")!.addEventListener("click", () => void arena.voice.enableMic());
document.querySelector("#buy")!.addEventListener("click", async () => {
if (arena.room.status !== "open") return;
try {
const { coins } = await arena.room.call("buy", { item: "dash" });
status.textContent = `${coins} coins left`;
} catch (e) {
if (e instanceof IrtError) status.textContent = e.message;
else throw e;
}
});
document.querySelector("#leave")!.addEventListener("click", () => void arena.stop());
How it fits together:
- One animation loop.
setAnimationLoopdraws every frame: remote runners at their interpolated position, the local runner at its predicted one, and the camera following the predicted position, so the camera never lags the player. - Aim from a raycast. The pointer’s ray meets the ground plane, and
the angle from the predicted position to that point is written to the
owned runner. The local mesh turns at once; other clients get the new
anglethroughonChange, a state update later. - Spatial voice.
spatial: truepans speakers with Web Audio. The positions given tojoinVoiceare room positions on the Three.js axes,(x, 0, y), and the Web Audio listener faces-zwith+xto its right, as the camera does, so a speaker on the right of the screen is heard on the right. - The buy button checks
status.callthrows before the client is first ready and rejects while it reconnects, so the button does nothing until the room is open. - Teardown frees GPU memory. Removing a mesh from the scene doesn’t
free its geometry or material; the shared ones are disposed once, in
stop, with the renderer.
Resize handling is left out: on resize, set camera.aspect, call
camera.updateProjectionMatrix() and renderer.setSize(...) as in any
Three.js app.
Common mistakes
- New geometry and material per runner. A runner enters again on
every reconnect, so per-entity GPU resources pile up unless each
onLeavedisposes them. Share them, as above. - Driving the camera from
me.entity. It jumps on every correction;rendersmooths them. - Mixing up the axes. The room’s
yis the scene’sz. Map in one place, in both directions: positions, aim and voice.