Skip to content

From your laptop to live

The whole path for one game: write it, play it locally, test it, put it online, keep it running. You need Node 22 or newer. The server on your laptop is the same code as the one you deploy, so what works locally works live.

A tickcli game is one HTML file. Start from the quick start dots game, a tutorial, or ask an AI.

With an AI (Claude, ChatGPT, Cursor, …). Give it the rules, not just the idea. Paste this, then your idea:

Write a multiplayer browser game as ONE html file using tickcli.
Load <script src="room.js"></script> and <script src="game.js"></script>, then call Game({...}).
Rules:
- Declare every field a player sets, with its range: multiplayer: { max: 8, player: { x: [0, 800], y: [0, 600] } }.
- Each player writes only their own state: g.me.set({...}).
- One player's browser is the host: rules, scores, timers and monsters go in host(g, dt) via g.shared.set({...}).
- A player who joins late gets g.shared and g.players in full, so g.shared must tell the whole story.
- g.shared is read-only (frozen): copy before sorting. Timers are end times: g.shared.set({ endsAt: g.now + 60 }).
- Requests to the host: g.act(name, data) and onAction(g, from, name, data). Sparks and sounds: g.fx / onFx.
- Draw from g.players and g.shared in draw(g, ctx, w, h). Keep nothing important only in host variables.
Reference: https://tickcli.com/docs/llms.txt
My game: <your idea>

The llms.txt link gives the AI this whole site as plain text. For a bigger game, also paste one example from node_modules/tickcli/examples/games/ (tag.html is a good size).

Terminal window
mkdir games && cd games
npm init -y && npm install tickcli ws
# save the game here as dots.html
npx tickcli . --saves ./saves

Open http://localhost:8080/, pick the game. Then:

  • A second player: open the same address in a private window (or another browser). Two tabs of one browser are the same player, so the second tab takes over the first one’s seat (“opened in another tab”). Use Copy invite link, or add #room=CODE to the address.
  • Your phone: on the same Wi-Fi, open http://<your laptop's address>:8080/ (the server listens on every network address by default; find it in your network settings).
  • Edit, save, reload. The server reads the new file at once. Players still on the old version are told to press restart, and the room code moves to the new version, so everyone lands together.

What to try by hand before anything else:

Try Expect
Join by code from the second window both players appear, both move
Join after the match started the late player sees the current state (score, timer, map)
Close the host’s window another player becomes host within a second; the game goes on
Reload a player’s page they come back to the same seat
Phone in portrait the touch pad shows, buttons are reachable
Leave it alone 15 minutes the room warns, then closes (idle rooms)

A short script with two real browsers catches what breaks most often: joining, moving, the host’s rules. Ask your AI to adapt it to your game.

Terminal window
npm install -D playwright && npx playwright install chromium
// test-dots.mjs: node test-dots.mjs (with npx tickcli . running)
import { chromium } from "playwright";
const URL = "http://localhost:8080/play/dots.html";
const browser = await chromium.launch();
const errors = [];
const player = async () => { const p = await (await browser.newContext()).newPage(); p.on("pageerror", (e) => errors.push(String(e))); return p; };
const state = (p) => p.evaluate(() => JSON.parse(JSON.stringify({ state: game.state, me: game.me, players: game.players, room: game.room })));
const until = async (p, ok, ms = 10000) => { for (const t = Date.now(); Date.now() - t < ms; await p.waitForTimeout(150)) if (ok(await state(p).catch(() => null) || {})) return true; return false; };
const a = await player(), b = await player();
await a.goto(URL);
if (!await until(a, (g) => g.room?.code)) throw new Error("no room code");
const code = (await state(a)).room.code;
await b.goto(`${URL}#room=${code}`);
if (!await until(a, (g) => g.players?.length === 2)) throw new Error("second player never arrived");
await a.getByRole("button", { name: /Start/ }).click();
const id = (await state(b)).me.id;
await b.keyboard.down("ArrowRight"); await b.waitForTimeout(700); await b.keyboard.up("ArrowRight");
if (!await until(a, (g) => g.players.find((p) => p.id === id)?.state.x > 440)) throw new Error("the move did not reach the other screen");
await a.screenshot({ path: "dots.png" });
await browser.close();
if (errors.length) throw new Error(errors.join("\n"));
console.log("ok: join, start, move, no page errors");

window.game is the running game (state, players, room, you), so a test can read what a player sees. Add a check for each rule that matters (a hit lowers health, the timer ends the match, a late joiner sees the score).

Pick one.

A. Your own server (game and rooms together)

Section titled “A. Your own server (game and rooms together)”

Any machine with Node 22 and a public address (a small VPS is plenty: one process handled about 1,000 players in a live load test). Put an https proxy in front; Caddy gets the certificate by itself and passes WebSockets through:

/etc/caddy/Caddyfile
games.example.com {
reverse_proxy 127.0.0.1:8080
}
Terminal window
# on the server, in the folder with your games
export TICKCLI_SECRET="$(openssl rand -base64 32)" # store it; keep it the same across restarts
npx tickcli . --host 127.0.0.1 --saves ./saves --behind-proxy
  • --behind-proxy counts each player by their own address (from the proxy’s X-Forwarded-For, believed only from a private or local peer) and marks the guest cookie Secure. Without it every player looks like the proxy and the per-address limit (20 connections) caps the whole server.
  • TICKCLI_SECRET keeps players’ seats and saves across restarts. Back up ./saves.
  • Run it under systemd, Docker or pm2 so it restarts; on stop it writes pending saves first (SIGTERM). In your own Node app, createGameServer({ games, saves, secret, trustProxy: true, secure: true }) does the same.

B. Page anywhere, rooms on a tickcli server

Section titled “B. Page anywhere, rooms on a tickcli server”

Host the HTML wherever you like (your site, GitHub Pages, a game portal) and play in rooms on a tickcli server that runs tickcli/remote. Instead of room.js and game.js, the page has one tag:

<script src="https://rooms.example/c/dep_ab12.js" data-game="dots" data-version="1"></script>
  • The deployment (dep_ab12) lists the sites allowed to use it: your https site(s), plus http://localhost:* while you test this exact page from your laptop. There is one environment: what you test is what players get.
  • A page on a site not on the list stops and says so: “This site is not allowed for deployment dep_ab12.”
  • Raise data-version when a change makes old and new pages unable to play together; players on different versions never share a room.
  • Changing the game: edit the file and upload it; open rooms keep playing the old version until their players press restart. Raise multiplayer.saveVersion (and add migrateSave) when saved games change shape (saved games).
  • Restarts: players reconnect within about 3 seconds. To keep matches across a restart, hand the rooms over with onSnapshot and importRooms (hosting).
  • Watch: app.hub.stats() has players, rooms, data out, slow rooms and refusals by reason; show it on an admin page. A room that sends too much slows itself down and its host sees a hint in the console.
  • Limits worth knowing: 16 players a room, 64 KB of shared state, 50 messages a second per player (all limits).