# 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](/docs/integrations/overview) in 3D with
Three.js. It imports `shared.ts` from that page; read its
[Connecting](/docs/integrations/overview#connecting) and
[Teardown](/docs/integrations/overview#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.

```ts
// 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:

```ts
// 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.
