Skip to content

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 old
hub.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’s connect() 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).
  • scope is your namespace (a team, a public link); a room code only picks a room inside it. game is 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 level https://*.example, any port :*. The server checks the page’s Origin when 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 localStorage and 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: true refuses 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.