Hosting it
import { RoomHub } from "tickcli";import { WS_OPTIONS, attach, sameSite, Tickets, GuestPasses, gameHash } from "tickcli/node";
const hub = new RoomHub({ secret, saves, onSnapshot: (snap) => writeFile("rooms.json", JSON.stringify(snap)) });try { hub.importRooms(JSON.parse(readFileSync("rooms.json", "utf8"))); } catch {} // the rooms of the last process, if under 2 minutes oldhub.start();process.on("SIGTERM", async () => { await hub.shutdown(); process.exit(0); });const wss = new WebSocketServer({ ...WS_OPTIONS });server.on("upgrade", (req, socket, head) => { if (!sameSite((n) => req.headers[n])) return socket.destroy(); wss.handleUpgrade(req, socket, head, (ws) => attach(ws, { hub, address, identity /* or authenticate: (ticket) => ... */ }));});Identity always comes from your app, never from the page:
- Signed-in people: issue a one-time ticket (
new Tickets(secret).issue({...}), 30 s) from an authenticated route; the relay’sconnect()returns{url, first: {v: 1, t: "auth", ticket}}and the ticket travels as the socket’s first message (proxies log addresses, so not in the URL).authenticate(ticket)turns it into{scope, game, who}. - Anonymous visitors: set a pass cookie (
GuestPasses, HttpOnly, SameSite=Strict, limited to one path) and check it at the upgrade (identity). scopeis your namespace (a team, a public link); a room code only picks a room inside it.gameis the page’s hash: players on another version are told to reload.
Restarts: hub.shutdown() writes pending saves, hands the live rooms to onSnapshot (plain JSON: rooms, matches, shared state, seats; no tickets or passes) and closes every socket with 1012. The next process calls importRooms(snapshot, {maxAgeMs}) (default 2 minutes; an older or other-format snapshot is ignored) before start(): each room comes back in the same match with a new host term, its seats held for 60 s and no host until a member is back; save leases are taken again. Players reconnect (spread over 0.3 to 3 s) and their welcome has kept: true. Keep the file private (it holds seat keys and save slots).
src/serve.ts (what createGameServer and npx tickcli run) is a complete host in about 120 lines.
Games hosted on another site (tickcli/remote)
Section titled “Games hosted on another site (tickcli/remote)”The game page lives anywhere (your own site, GitHub Pages, a game portal) and plays in rooms on a tickcli server. The page needs one tag:
<script src="https://rooms.example/c/dep_ab12.js" data-game="dots" data-version="1"></script> <!-- relay + room.js + game.js, set up --><script> Game({ /* as above */ }) </script>data-game names the game (its saves; default: the page’s file name), data-version keeps players of different versions apart (default 1). The server side:
import { createRemoteServer } from "tickcli/remote";
const deployments = { // "http://localhost:*" lets the author test the same page from a laptop; drop it whenever you like dep_ab12: { id: "dep_ab12", origins: ["https://mygame.example", "https://*.portal.example", "http://localhost:*"], caps: { rooms: 20, players: 200, kbPerSec: 2048 } },};const app = createRemoteServer({ deployment: (id) => deployments[id], secret: process.env.TICKCLI_SECRET, saves: "./saves" });await app.listen(8080); // behind https: the pages use wss://Or in your own server: createRemote({ hub, deployment }) gives upgrade(req, socket, head) and request(req, res) (pass roomsPerScope: (s) => remote.roomsPerScope(s) to the hub for room caps).
- A deployment is a public id plus the sites allowed to use it:
https://site, one wildcard levelhttps://*.example, any port:*. The server checks the page’sOriginwhen the socket opens: browsers send it and page scripts cannot change it, so another site cannot use your deployment. There is one kind of deployment: https sites, and http only for this machine (localhost,127.0.0.1,[::1]) when listed, e.g.http://localhost:*to test from a laptop. Pages with no site (null,file://) are refused. - Guests: the page keeps a random 128-bit key in its own
localStorageand sends it as the socket’s first message (no cookie, so it works from any site). The same key is the same seat; other players see a short id made from it, never the key. - Each deployment is its own namespace (rooms and saves never mix).
caps: open rooms, connected sockets, KB a second going out (over it, new sockets are told the game is busy and retry; open rooms carry on).revoked: truerefuses new sockets;remote.revoke(id)also closes its open rooms. - A refused page stops and shows why (“This site is not allowed for deployment dep_ab12.”).
- Not a guard: a script outside a browser can send any
Origin. The caps, the per-address limits and revoking the id are what stop a misused deployment.