Skip to content

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.

Terminal window
npm install tickcli ws phaser
npx 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>
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.

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.

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));
  • Keys: in headless mode the shell never cancels key events, because some engines (Phaser) ignore a cancelled one. g.held and g.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({...}) and g.shots (shots every screen moves by itself), g.fx(name, data) for sparks and sounds. Sky Duel uses both.
  • PixiJS compiles shaders with new Function unless its unsafe-eval add-on is loaded. lib:pixi on the tickcli server includes it; elsewhere load pixi.js/unsafe-eval if your page has a CSP without eval.