Babylon.js
This page builds a shared Babylon.js scene from scratch: a schema, a room, and a per-frame observer that keeps meshes in step with the players in the room. It assumes you know Babylon.js.
Install
npx irtio init # adds @irtio/schema, @irtio/server, @irtio/client and @irtio/testing
npm install
npm install @babylonjs/core irtio init writes irtio/schema.ts, irtio/room.ts, irtio/room.test.ts and irtio.json.
The project id in them is public and domain locked, so it is safe in client code.
The loop
Babylon.js keeps its own render loop, and scene.onBeforeRenderObservable fires once per frame
before the scene draws. irtio has no callback for state changes, so you read the room there.
| Read path | What it gives you | Where to use it |
|---|---|---|
room.state | what the server last said, plus your own local writes | game logic, your own input, tests |
room.render | the same shape, with non-owned instances a beat behind arrival and their numeric fields interpolated | positioning meshes |
Position meshes from room.render. A room ticks 20 times a second and the engine renders at 60,
so room.state steps remote meshes three frames at a time. Your own instance comes back from room.render at its immediate local values.
A shared arena
Schema
// irtio/schema.ts
import { defineSchema, entity, f32, str } from '@irtio/schema';
export const HALF = 20; // arena half-width, world units
export const schema = defineSchema(
{
// One instance per client, owned by the client that joined as it.
players: entity({ x: f32, z: f32, ry: f32, color: str(8), name: str(24) }),
},
{
project: 'p_c0ffee1234abcd56', // written by `irtio init`
roles: ['player'] as const,
},
); Room file
// irtio/room.ts
import { defineRoom } from '@irtio/server';
import { HALF, schema } from './schema.js';
const COLORS = ['#4dabf7', '#ffd166', '#9ae66e', '#ff6b6b', '#c084fc'];
const MAX_STEP = 4; // furthest a player may travel between two writes, world units
const clamp = (v: number) => Math.min(HALF, Math.max(-HALF, v));
export default defineRoom(schema, {
mode: 'tick',
tickRate: 20,
maxClients: 16,
onJoin(state, ctx) {
if (ctx.reconnecting) return;
state.players.add(
ctx.clientId,
{
x: (Math.random() - 0.5) * 2 * HALF,
z: (Math.random() - 0.5) * 2 * HALF,
ry: 0,
color: COLORS[state.players.size % COLORS.length],
name: ctx.name || 'anon',
},
{ owner: ctx.clientId }, // this is what lets the client write the instance
);
},
onLeave(state, ctx) {
state.players.remove(ctx.clientId);
},
validate: {
players(prev, next) {
if (!Number.isFinite(next.x) || !Number.isFinite(next.z)) return prev;
// The room assigned these at join, so a client write to them is a lie.
if (next.name !== prev.name || next.color !== prev.color) return prev;
if (Math.hypot(next.x - prev.x, next.z - prev.z) > MAX_STEP) return prev;
return { ...next, x: clamp(next.x), z: clamp(next.z) };
},
},
// Tick mode needs a tick, even an empty one. Nothing here moves on its own.
tick() {},
}); Scene code
// main.ts
import { joinRoom } from '@irtio/client';
import {
ArcRotateCamera,
Color3,
Engine,
HemisphericLight,
Mesh,
MeshBuilder,
Scene,
StandardMaterial,
Vector3,
} from '@babylonjs/core';
import { HALF, schema } from './irtio/schema.js';
const SPEED = 8; // world units per second
const canvas = document.getElementById('render') as HTMLCanvasElement;
const engine = new Engine(canvas, true);
const scene = new Scene(engine);
const camera = new ArcRotateCamera('camera', -Math.PI / 2, Math.PI / 3.2, 34, Vector3.Zero(), scene);
new HemisphericLight('light', new Vector3(0, 1, 0), scene);
MeshBuilder.CreateGround('ground', { width: HALF * 2, height: HALF * 2 }, scene);
const room = await joinRoom(schema, { role: 'player', name: 'you' });
const keys = new Set<string>();
addEventListener('keydown', (e) => keys.add(e.key.toLowerCase()));
addEventListener('keyup', (e) => keys.delete(e.key.toLowerCase()));
addEventListener('resize', () => engine.resize());
addEventListener('pagehide', () => room.leave());
const meshes = new Map<string, Mesh>();
function drive(dt: number): void {
// room.state, not room.render: this is the instance you write.
const me = room.state.players[room.me];
if (!me) return; // the instance arrives a frame or two after the join resolves
const dx = (keys.has('d') ? 1 : 0) - (keys.has('a') ? 1 : 0);
const dz = (keys.has('w') ? 1 : 0) - (keys.has('s') ? 1 : 0);
if (dx === 0 && dz === 0) return;
const len = Math.hypot(dx, dz);
me.x += (dx / len) * SPEED * dt; // owned write, batched into one update per frame
me.z += (dz / len) * SPEED * dt;
me.ry = Math.atan2(dx, dz);
}
function reconcile(): void {
// room.render: remote players glide between ticks instead of stepping at 20 Hz.
for (const [id, player] of room.render.players) {
let mesh = meshes.get(id);
if (!mesh) {
mesh = MeshBuilder.CreateBox(id, { size: 1 }, scene);
const material = new StandardMaterial(`${id}:mat`, scene);
material.diffuseColor = Color3.FromHexString(player.color);
mesh.material = material;
meshes.set(id, mesh);
}
mesh.position.set(player.x, 0.5, player.z);
mesh.rotation.y = player.ry;
}
for (const [id, mesh] of meshes) {
// room.state is the authoritative answer to who is still in the room.
if (room.state.players.has(id)) continue;
mesh.dispose(false, true); // the mesh and the material it owns
meshes.delete(id);
}
}
scene.onBeforeRenderObservable.add(() => {
const dt = Math.min(engine.getDeltaTime() / 1000, 0.1);
drive(dt);
reconcile();
const me = meshes.get(room.me);
if (me) camera.setTarget(me.position);
});
engine.runRenderLoop(() => scene.render()); Run npx irtio dev, open the page, copy the URL once it carries ?room=CODE, and paste it into
a second tab.
Engine-specific notes
Coordinate system. Babylon.js is y up and left handed by default, and one unit is a metre by
convention. scene.useRightHandedSystem = true flips it. Pick one before you write the schema:
clients that disagree about the sign of z mirror each other.
Imported models. The glTF loader parents what it loads under a __root__ node that carries
the file’s handedness correction. Sync the position you set yourself, not a world matrix read
back off an imported node.
Object graph. A collection is a map from id to record. A scene holds meshes that live between
frames. The bridge is a Map<string, Mesh> and one reconcile pass. Do not put a mesh in the
schema. For a crowd of identical meshes, build one mesh and reconcile createInstance clones.
Interpolation. room.render interpolates numeric fields between the updates either side of now - interpDelayMs, which defaults to max(50, 2 x tick interval) and is a joinRoom option.
Do not add an animation on top of it. Two layers of smoothing read as lag. Set interpolate: false on a collection whose values should snap, such as a scoreboard. A rotation
field lerps like any other number, so an angle crossing pi takes the long way round and the mesh
spins. Store the facing as sin and cos and call Math.atan2 when you draw it.
Cleanup. mesh.dispose(false, true) frees the mesh and the material it owns. A material
shared by many meshes is not one mesh’s to free. Remove the Observer that onBeforeRenderObservable.add returns when you tear the scene down. Call room.leave() on pagehide so the room sees the departure without waiting out the reconnection grace window,
30 seconds by default and set with reconnectGraceMs in irtio/room.ts.
Write batching. engine.runRenderLoop runs on the animation frame, which is the frame the
client batches owned writes to, with a 50 ms hard cap set by writeIntervalMs on joinRoom.
Writing a field several times inside one observer call costs one update on the wire. Call room.flush() only when a single input has to leave now.
Babylon physics. A physics plugin on every client drifts, because each client integrates its own floats. When collisions decide the game, put the world on the server and write an intent from the client. Physics covers that shape.
Next steps
- Physics, for server-owned bodies and prediction
- Three.js, the same scene in the other 3D engine
- Ownership
- Client SDK
- Deploying