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. setAnimationLoop draws 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 angle through onChange, a state update later.
  • Spatial voice. spatial: true pans speakers with Web Audio. The positions given to joinVoice are room positions on the Three.js axes, (x, 0, y), and the Web Audio listener faces -z with +x to 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. call throws 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 onLeave disposes them. Share them, as above.
  • Driving the camera from me.entity. It jumps on every correction; render smooths them.
  • Mixing up the axes. The room’s y is the scene’s z. Map in one place, in both directions: positions, aim and voice.

View this page as Markdown