Bring your own engine
Engines like Phaser, PixiJS, Babylon.js and Kaplay run their own loop and own their canvas. tickcli then runs headless: it adds only the lobby, the notices and the countdown over your page (clicks pass through everywhere else) and runs the room. Your engine keeps drawing, moving and reading input.
This tutorial uses Phaser; the same two lines work in any engine (see below). The finished air shooter is examples/games/sky-duel.html.
1. Load the engine and tickcli
Section titled “1. Load the engine and tickcli”npm install tickcli ws phasernpx tickcli .<div id="stage"></div><script src="lib:phaser"></script> <!-- Phaser's own global build, from the installed package --><script src="lib:room"></script><script src="lib:game"></script>2. The room
Section titled “2. The room”const W = 800, H = 600;const g = Game({ headless: true, title: "My Phaser game", multiplayer: { max: 8, player: { x: [0, W], y: [0, H] } }, host(g, dt) { /* rules, once, on the host: g.shared.set({...}) */ },});setup, host, onAction, onFx, g.shared, g.gameOver() all work as in any tickcli game. There is no draw: your engine draws.
3. Two lines in the engine’s update
Section titled “3. Two lines in the engine’s update”class Main extends Phaser.Scene { create() { this.hero = this.add.circle(400, 300, 14, 0x60a5fa); this.keys = this.input.keyboard.createCursorKeys(); } update(time, ms) { const k = this.keys, v = 0.25 * ms; if (k.left.isDown) this.hero.x -= v; if (k.right.isDown) this.hero.x += v; if (k.up.isDown) this.hero.y -= v; if (k.down.isDown) this.hero.y += v;
if (g.state === "playing") g.me.set({ x: this.hero.x, y: this.hero.y }); // 1: share where I am g.sync((p) => this.add.circle(0, 0, 14, 0xf87171), (s, st) => s.setPosition(st.x, st.y), (s) => s.destroy()); // 2: everyone else }}new Phaser.Game({ type: Phaser.AUTO, parent: "stage", width: W, height: H, scene: Main });g.sync(make, place, remove) keeps one engine object per other player: make(player) when someone joins, place(sprite, state) every frame with their eased position, remove(sprite) when they leave.
Same two lines, other engines
Section titled “Same two lines, other engines”The g.me.set(...) line is the same everywhere. Only where you call it and the three g.sync callbacks change:
PixiJS 8, in app.ticker.add(() => { ... }):
g.sync(() => app.stage.addChild(new PIXI.Graphics().circle(0, 0, 12).fill(0xf87171)), (s, st) => s.position.set(st.x, st.y), (s) => s.destroy());Babylon.js, in scene.onBeforeRenderObservable.add(() => { ... }):
g.sync((p) => BABYLON.MeshBuilder.CreateBox(p.id, {}, scene), (s, st) => s.position.set(st.x, st.y, st.z), (s) => s.dispose());Kaplay, in onUpdate(() => { ... }):
g.sync(() => add([circle(12), pos(0, 0), color(248, 113, 113)]), (s, st) => { s.pos = vec2(st.x, st.y); }, (s) => destroy(s));Good to know
Section titled “Good to know”- Keys: in headless mode the shell never cancels key events, because some engines (Phaser) ignore a cancelled one.
g.heldandg.axis()still follow the arrows and WASD if you want them. - Touch: the shell adds no touch pad and leaves touches to your engine (Sky Duel flies toward a held finger).
- Shots and effects:
g.shoot({...})andg.shots(shots every screen moves by itself),g.fx(name, data)for sparks and sounds. Sky Duel uses both. - PixiJS compiles shaders with
new Functionunless itsunsafe-evaladd-on is loaded.lib:pixion the tickcli server includes it; elsewhere loadpixi.js/unsafe-evalif your page has a CSP without eval.