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 pathWhat it gives youWhere to use it
room.statewhat the server last said, plus your own local writesgame logic, your own input, tests
room.renderthe same shape, with non-owned instances a beat behind arrival and their numeric fields interpolatedpositioning 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