Skip to content

Your first multiplayer game

You will build tag: whoever is “it” chases the others, staying free earns points, the round lasts 60 seconds. You need Node 22 or newer.

Terminal window
mkdir games && cd games
npm init -y && npm install tickcli ws
npx tickcli . # serves every .html file here, with live rooms

Open the address it prints. The list is empty until you add a game.

Save this as tag.html:

<!doctype html><html><head><meta charset="utf-8"><title>Tag</title></head><body>
<script src="lib:room"></script>
<script src="lib:game"></script>
<script>
const W = 800, H = 600, SPEED = 230;
let x, y;
Game({
title: "Tag", help: "Arrow keys, WASD or the pad to run.", size: [W, H],
multiplayer: { max: 8, player: { x: [0, W], y: [0, H] } },
setup(g) { x = 60 + Math.random() * (W - 120); y = 60 + Math.random() * (H - 120); g.me.set({ x, y }); },
update(g, dt) {
const a = g.axis();
x = Math.max(12, Math.min(W - 12, x + a.x * SPEED * dt));
y = Math.max(12, Math.min(H - 12, y + a.y * SPEED * dt));
g.me.set({ x, y });
},
draw(g, ctx, w, h) {
ctx.fillStyle = "#0f172a"; ctx.fillRect(0, 0, w, h);
for (const p of g.players) {
ctx.fillStyle = p.me ? "#60a5fa" : "#f87171";
ctx.beginPath(); ctx.arc(p.state.x, p.state.y, 14, 0, 7); ctx.fill();
}
},
});
</script></body></html>

Reload the list, open Tag in two windows, press Start in the first. Both dots move on both screens.

What happened:

  • lib:room and lib:game are the two browser libraries; the server points the tags at its own copies.
  • multiplayer.player declares each field a player shares and its range. Values are clamped to it, and positions go out as whole numbers.
  • g.me.set({x, y}) can be called every frame: it goes out at most 12 times a second, only when something changed.
  • g.players is everyone, you included. Others are drawn a little in the past and eased, so they move smoothly.

Exactly one player is the host. Its host(g, dt) runs the rules and writes the shared state. Add this to the Game({...}) options:

host(g, dt) {
const s = g.shared, live = g.players.filter((p) => typeof p.state.x === "number");
if (s.endsAt == null) return g.shared.set({ endsAt: g.now + 60, it: live[0]?.id ?? null, score: {} });
if (g.now >= s.endsAt) return g.gameOver();
const it = live.find((p) => p.id === s.it); if (!it) return;
const score = {};
for (const p of live) if (p.id !== s.it) score[p.id] = ((s.score && s.score[p.id]) || 0) + dt;
g.shared.set({ score });
for (const p of live) if (p.id !== it.id && Math.hypot(p.state.x - it.state.x, p.state.y - it.state.y) < 28) { g.shared.set({ it: p.id }); break; }
},

And draw who is it: in draw, give the player whose id is g.shared.it a red ring.

  • g.now is a clock every screen shares, so endsAt is the same moment everywhere.
  • g.shared.set merges: {score: {[id]: 3}} changes one entry. Only the host may write it.
  • If the host leaves, another player becomes host at once and continues from g.shared. Keep nothing important only in host variables.
  • g.gameOver() ends the match; the lobby shows the scores and Play again.

Press “Copy invite link” in the lobby and send it: the friend lands in your room. On a phone the shell shows a touch pad and keeps the page from zooming.