# Babylon.js

> Run an IRT room in a Babylon.js scene, with predicted movement, interpolated players, aim from a pick, calls, spatial voice and teardown with the scene.

This page builds the runners game from
[Engines and frameworks](/docs/integrations/overview) in 3D with
Babylon.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`; a runner at room `(x, y)`
stands at `(x, 0, y)` in the scene. The room connection belongs to the
`Scene`: it's opened with the scene and closed by the scene's
`onDisposeObservable`, so disposing the scene, as a level change does,
tears down everything.

```ts
// arena.ts
import { CloseCode, RoomClient } from "@irt/client";
import { joinVoice } from "@irt/client/voice";
import { ArcRotateCamera, Engine, HemisphericLight, type Mesh, MeshBuilder, PointerEventTypes, Scene, Vector3 } from "@babylonjs/core";
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 createArena(engine: Engine, roomId: string, show: (status: string) => void) {
  const scene = new Scene(engine);
  const room = new RoomClient({ router, project: "my-game", room: roomId, schema });
  const camera = new ArcRotateCamera("camera", -Math.PI / 2, Math.PI / 3, 18, Vector3.Zero(), scene);
  new HemisphericLight("light", new Vector3(0, 1, 0), scene);
  const ground = MeshBuilder.CreateGround("ground", { width: 200, height: 200 }, scene);
  const meshes = new Map<number, 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 body = MeshBuilder.CreateCapsule(`runner-${r.id}`, { height: 1.6, radius: 0.4 }, scene);
      const nose = MeshBuilder.CreateBox(`nose-${r.id}`, { width: 0.5, height: 0.15, depth: 0.15 }, scene);
      nose.parent = body;
      nose.position.set(0.5, 0.3, 0);
      body.position.set(r.x, 0.8, r.y);
      body.rotation.y = -r.angle;
      meshes.set(r.id, body);
    }),
    room.entities.onChange(Runner, (r, changed) => {
      const body = meshes.get(r.id);
      if (body && changed.includes("angle")) body.rotation.y = -r.angle;
    }),
    // Disposing a mesh disposes its children, so the nose goes with it.
    room.entities.onLeave(Runner, (r) => {
      meshes.get(r.id)?.dispose();
      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("ArrowUp") ? 1 : 0) - (keys.has("ArrowDown") ? 1 : 0),
    }),
  });
  const others = room.interpolate(Runner, ["x", "y"]);

  scene.onPointerObservable.add((info) => {
    if (info.type !== PointerEventTypes.POINTERMOVE) return;
    const pick = scene.pick(scene.pointerX, scene.pointerY, (m) => m === ground);
    if (!pick.hit || !pick.pickedPoint) return;
    const at = me.render(["x", "y"]);
    for (const r of room.entities.mine(Runner)) {
      r.angle = Math.atan2(pick.pickedPoint.z - at.y, pick.pickedPoint.x - at.x);
      const body = meshes.get(r.id);
      if (body) body.rotation.y = -r.angle;
    }
  });

  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,
  });

  scene.onBeforeRenderObservable.add(() => {
    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.target.set(local.x, 0, local.y);
  });

  scene.onDisposeObservable.addOnce(() => {
    for (const off of offs) off();
    removeEventListener("keydown", down);
    removeEventListener("keyup", up);
    me.stop();
    room.leave();
    void voice.disconnect();
    meshes.clear();
  });

  return { scene, room, voice };
}
```

And the page that runs it:

```ts
// main.ts
import { IrtError } from "@irt/client";
import { Engine } from "@babylonjs/core";
import { createArena } from "./arena.js";

const canvas = document.querySelector<HTMLCanvasElement>("#game")!;
const status = document.querySelector("#status")!;
const engine = new Engine(canvas, true);
const arena = createArena(engine, new URLSearchParams(location.search).get("room") ?? "arena", (s) => {
  status.textContent = s;
});
engine.runRenderLoop(() => arena.scene.render());
addEventListener("resize", () => engine.resize());

// 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", () => {
  engine.stopRenderLoop();
  arena.scene.dispose();
});
```

How it fits together:

- **`onBeforeRenderObservable` is the frame loop.** It runs once per
  frame before the scene renders: remote runners take their
  interpolated position, the local runner its predicted one, and the
  camera's target follows the predicted position.
- **Babylon.js is left-handed.** With `+y` up, `+z` points away from a
  camera at `alpha = -π / 2`, so the room's `+y` is up the screen. The
  arrow keys here send `dy = 1` for up to match; in a right-handed engine
  such as [Three.js](/docs/integrations/three), down is `+y`.
- **Aim from a pick.** `scene.pick` with a predicate that accepts only
  the ground gives the point under the pointer; the angle to it from the
  predicted position is written to the owned runner.
- **Spatial voice.** Web Audio is right-handed, so voice positions flip
  the sign of `z`: a speaker on the right of the screen is heard on the
  right.
- **Teardown follows the scene.** `scene.dispose()` disposes every mesh,
  camera and light, and its `onDisposeObservable` removes the IRT
  listeners, stops the prediction and leaves the room. The observers
  added to the scene's own observables go with the scene.

## Common mistakes

- **Calling `room.leave()` without disposing the scene.** The meshes
  stay in it, frozen, because `onLeave` no longer runs.
- **Using `engine.getDeltaTime()` to move runners.** Movement comes from
  the room's ticks: the prediction steps at the room's fixed timestep
  and the interpolator places remote runners by the room's clock.
- **Picking every mesh for aim.** Without a predicate the pick hits the
  runners themselves, and the aim snaps to the nearest capsule.
