Integrations
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 in 3D with
Babylon.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; 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.
// 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:
// 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:
onBeforeRenderObservableis 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
+yup,+zpoints away from a camera atalpha = -π / 2, so the room’s+yis up the screen. The arrow keys here senddy = 1for up to match; in a right-handed engine such as Three.js, down is+y. - Aim from a pick.
scene.pickwith 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 itsonDisposeObservableremoves 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, becauseonLeaveno 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.