Skip to content

three.js and other engines

three.js (the shell keeps its board, bar and touch pad; three.js draws on the shell’s canvas). Load three.js as the global THREE (<script src="lib:three"></script> on the tickcli server, which makes it from the installed three package, 0.171 or newer; or your own bundle), then room.js, game.js, game3d.js:

const R3 = TickcliThree; // client/game3d.js: renderer(g), resize(renderer, camera, g), place(obj, state), pose(obj), pool(parent, make)
let renderer, scene, camera, me, others;
Game({
context: "webgl", // no 2D context: g.canvas is yours; draw(g) runs every frame
multiplayer: { max: 8, player: { x: [-20, 20], y: [0, 5], z: [-20, 20], qx: [-1, 1], qy: [-1, 1], qz: [-1, 1], qw: [-1, 1] } },
setup(g) { renderer = renderer || R3.renderer(g); /* scene, camera, me = new THREE.Mesh(...) */ others = others || R3.pool(scene, (p) => makeAvatar(p)); },
update(g, dt) { /* move me */ g.me.set(R3.pose(me)); }, // position + quaternion, rounded
host(g, dt) { g.shared.set({ gem: R3.pose(gem) }); }, // host-moved things ease too
draw(g) {
R3.resize(renderer, camera, g); // size, pixel ratio (the quality ladder), camera aspect
others.sync(g.players.filter((p) => !p.me)); // one object per other player, eased, removed when they leave
renderer.render(scene, camera);
},
});

A rotation is a quaternion in four fields qx, qy, qz, qw (three.js and Babylon order). Other screens ease it as one turn (slerp, the short way round, always of length 1); players declare each as [-1, 1] and send 3 decimals. In webgl mode the positions the host shares keep 2 decimals (multiplayer.decimals changes it; 2D games keep whole numbers). g.pointer.nx, ny (-1..1) go straight into a Raycaster. Example: examples/games/arena3d.html.

Any engine that runs its own loop (Phaser, PixiJS, Babylon.js, Kaplay, PlayCanvas, Excalibur, your own requestAnimationFrame): headless: true. The shell adds only the lobby, notices and countdown over your page (clicks pass through everywhere else) and leaves your page, canvas, loop and touches alone. The room is the same: setup, host, onAction, onFx, g.me.set, g.shared.set, g.players (eased), g.gameOver().

const g = Game({ headless: true, title: "My game", multiplayer: { max: 8, player: { x: [0, 800], y: [0, 600] } },
host(g, dt) { /* rules on one player: g.shared.set({...}) */ } });
// in your engine's update, once a frame: the whole room is these two lines
if (g.state === "playing") g.me.set({ x: hero.x, y: hero.y });
g.sync(make, place, remove); // one object per other player: make(p) when they join, place(sprite, state) every frame (eased), remove(sprite) when they leave
Engine Call from make / place / remove
Phaser 4 the scene’s update() this.add.circle(0, 0, 12, 0xff0000) / s.setPosition(st.x, st.y) / s.destroy()
PixiJS 8 app.ticker.add(...) app.stage.addChild(new PIXI.Graphics().circle(0, 0, 12).fill(0xff0000)) / s.position.set(st.x, st.y) / s.destroy()
Babylon.js scene.onBeforeRenderObservable.add(...) BABYLON.MeshBuilder.CreateBox(id, {}, scene) / s.position.set(st.x, st.y, st.z); s.rotationQuaternion = new BABYLON.Quaternion(st.qx, st.qy, st.qz, st.qw) / s.dispose()
Kaplay onUpdate(...) add([circle(12), pos(0, 0), color(255, 0, 0)]) / s.pos = vec2(st.x, st.y) / destroy(s)

On the tickcli server, <script src="lib:phaser"></script> and <script src="lib:pixi"></script> load the installed phaser and pixi.js packages’ own global builds (PixiJS with its no-eval add-on, since game pages run without eval), like lib:three.

Examples: own-engine.html (a plain canvas engine, two marked lines of room code), sky-duel.html (Phaser air shooter), neon-paint.html (PixiJS, draw together), arena3d.html (three.js), chess.html (the shell alone). All in examples/games/, played by two browsers in e2e/demos.ts.