Share a turn with the world: /watch links, cards and chrome included

The share button lands on the instant replay. One click mints a slug —
persistent, unguessable, one per (room, turn) — and copies a /watch
link anyone can open: the server rebuilds exactly that turn for the
nameless SPECTATOR, public knowledge only, none of the rest of the
game visible. The page arrives with its OpenGraph card pre-baked into
the head (crawlers never run the app): the turn's title and round in
the text, and an og.png of the maze itself — cells, walls, doors, warp
mouths, homes, standees — painted server-side and PNG-encoded with
nothing but node's own zlib. The viewer page wears its chrome: the
WIZ-WAR masthead linking home, the turn's title, the reel opening
straight into the turn-owner's eyes with a "watch it again" loop, and
a footer that says what this table is and deals the visitor in.

The share page loads as its own entry so a shared link never drags the
full app's socket appetite along; the reel's modal scrim learns to
stand in a page instead of over one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 17:34:00 -04:00
co-authored by Claude Fable 5
parent 727b9144d0
commit 4838996cf0
10 changed files with 569 additions and 8 deletions
+118
View File
@@ -58,6 +58,8 @@ import {
type Room,
} from "./rooms";
import { engagementStats, recordHotseat } from "./stats";
import { getShare, loadShares, mintShare } from "./shares";
import { renderSharePng } from "./ogimage";
import { BOT_LINES, type BanterTrigger } from "./banter";
// --- Abuse limits: this is a public server on a small box. -----------------
@@ -80,6 +82,7 @@ const port = Number(process.env.PORT ?? 8787);
// is not reachable from the internet. Dev default stays LAN-friendly.
const host = process.env.HOST ?? "0.0.0.0";
loadPersistedRooms();
loadShares();
// A restart can land mid-bot-turn: without a kick, a restored room whose
// current actor is an automaton waits forever for a human to poke it.
setTimeout(() => {
@@ -97,6 +100,74 @@ const MIME: Record<string, string> = {
// The client build may be absent in development (vite serves it instead);
// serve a 404 for static requests in that case rather than dying on boot.
const staticRoot = existsSync(STATIC_DIR) ? realpathSync(normalize(STATIC_DIR)) : null;
// --- Share pages: one turn, rebuilt for the nameless viewer. ---------------
// Every lookup is a full-game replay, so results rest briefly in memory.
interface ShareData {
steps: { actor: PlayerId; events: unknown[]; view: unknown }[];
actor: string;
round: number;
}
const shareCache = new Map<string, { at: number; data: ShareData | null }>();
function shareData(id: string): ShareData | null {
const hit = shareCache.get(id);
if (hit && Date.now() - hit.at < 30_000) return hit.data;
let data: ShareData | null = null;
const share = getShare(id);
const room = share ? getRoom(share.roomId) : undefined;
if (share && room?.state) {
const steps = momentSteps(room, SPECTATOR, share.turn);
if (!("error" in steps) && steps.length > 0) {
let actor: string = steps[steps.length - 1]!.actor;
let round = 0;
outer: for (const st of steps) {
for (const e of st.events) {
if (e.type === "turnStarted" || e.type === "extraTurnStarted") {
actor = e.player;
if (e.type === "turnStarted") round = e.round;
break outer;
}
}
}
data = { steps: steps as unknown as ShareData["steps"], actor, round };
}
}
if (shareCache.size > 50) shareCache.clear();
shareCache.set(id, { at: Date.now(), data });
return data;
}
const escapeHtml = (t: string) =>
t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
/** index.html with this share's OpenGraph card folded into its head —
* crawlers never run the app, so the unfurl must arrive pre-baked. */
function shareHtml(id: string, data: ShareData, host: string, proto: string): string {
// The stock page carries its own generic card; strip it, or crawlers
// (which take the FIRST tag they meet) never see this turn's.
const html = readFileSync(join(staticRoot!, "index.html"), "utf8")
.replace(/<meta (?:property="og:|name="twitter:)[^>]*>\s*/g, "")
.replace(/<title>[^<]*<\/title>\s*/, "");
const base = `${proto}://${host}`;
const title = `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
const desc = `Round ${data.round || "?"} of a game of Wiz-War, magical combat in a stone labyrinth. ` +
`Watch the turn through ${escapeHtml(data.actor)}'s own eyes, then deal yourself in.`;
const metas = [
`<title>${title}</title>`,
`<meta property="og:type" content="website"/>`,
`<meta property="og:site_name" content="Wiz-War"/>`,
`<meta property="og:title" content="${title}"/>`,
`<meta property="og:description" content="${desc}"/>`,
`<meta property="og:url" content="${base}/watch/${id}"/>`,
`<meta property="og:image" content="${base}/watch/${id}/og.png"/>`,
`<meta property="og:image:width" content="1200"/>`,
`<meta property="og:image:height" content="630"/>`,
`<meta name="twitter:card" content="summary_large_image"/>`,
`<meta name="twitter:image" content="${base}/watch/${id}/og.png"/>`,
].join("\n ");
return html.replace("</head>", ` ${metas}\n </head>`);
}
const httpServer = createServer((req, res) => {
try {
if (req.method !== "GET" && req.method !== "HEAD") {
@@ -119,6 +190,36 @@ const httpServer = createServer((req, res) => {
res.writeHead(400).end();
return;
}
// Share pages: the turn's data, its card image, and its chrome.
const api = url.match(/^\/api\/share\/([a-z0-9]{4,20})$/);
if (api) {
const data = shareData(api[1]!);
if (!data) { res.writeHead(404, { "content-type": "application/json" }).end('{"error":"no such replay"}'); return; }
res.writeHead(200, {
"content-type": "application/json",
"cache-control": "public, max-age=60",
"access-control-allow-origin": "*",
});
res.end(JSON.stringify({ steps: data.steps, actor: data.actor, round: data.round }));
return;
}
const watch = url.match(/^\/watch\/([a-z0-9]{4,20})(\/og\.png)?$/);
if (watch) {
const data = shareData(watch[1]!);
if (!data) { res.writeHead(404).end("no such replay"); return; }
if (watch[2]) {
const last = data.steps[data.steps.length - 1]!;
const png = renderSharePng(last.view as Parameters<typeof renderSharePng>[0]);
res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=300" });
res.end(png);
return;
}
const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim();
const hostname = String(req.headers.host ?? `localhost:${port}`);
res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" });
res.end(shareHtml(watch[1]!, data, hostname, proto));
return;
}
let file = normalize(join(staticRoot, url === "/" ? "index.html" : url));
if (file !== staticRoot && !file.startsWith(staticRoot + sep)) {
res.writeHead(403).end();
@@ -508,6 +609,23 @@ wss.on("connection", (socket) => {
send(socket, { type: "catchUp", steps });
break;
}
case "share": {
// Mint (or re-find) the public link for one turn's replay.
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const turn = Number(msg.turn);
if (!Number.isInteger(turn) || turn < 0) return send(socket, { type: "error", message: "no such turn" });
const now = Date.now();
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
return send(socket, { type: "error", message: "one moment" });
}
session.lastCatchUpAt = now;
const steps = momentSteps(room, session.playerId, turn);
if ("error" in steps) return send(socket, { type: "error", message: steps.error });
const share = mintShare(room.id, turn);
send(socket, { type: "share", id: share.id, turn });
break;
}
case "moment": {
// A chronicle line's instant-replay eye: one turn's reel.
const room = session.roomId ? getRoom(session.roomId) : undefined;
+191
View File
@@ -0,0 +1,191 @@
// The share card: a 1200x630 OpenGraph image of the shared turn's maze,
// painted from a spectator view and encoded as PNG with nothing but
// node's own zlib — no image libraries on this small box. The unfurl
// text carries the words; this carries the board.
import { deflateSync } from "node:zlib";
import type { GameView } from "@wizwar/engine";
// --- Minimal PNG encoding (truecolor, filter 0) ----------------------------
const CRC_TABLE = new Int32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
CRC_TABLE[n] = c;
}
function crc32(buf: Uint8Array): number {
let c = ~0;
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff]! ^ (c >>> 8);
return ~c >>> 0;
}
function chunk(type: string, data: Uint8Array): Uint8Array {
const out = new Uint8Array(12 + data.length);
const dv = new DataView(out.buffer);
dv.setUint32(0, data.length);
for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
out.set(data, 8);
dv.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
return out;
}
function encodePng(width: number, height: number, rgb: Uint8Array): Buffer {
const ihdr = new Uint8Array(13);
const dv = new DataView(ihdr.buffer);
dv.setUint32(0, width);
dv.setUint32(4, height);
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // truecolor
const raw = new Uint8Array(height * (1 + width * 3));
for (let y = 0; y < height; y++) {
raw.set(rgb.subarray(y * width * 3, (y + 1) * width * 3), y * (1 + width * 3) + 1);
}
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", new Uint8Array(0)),
]);
}
// --- The painting ----------------------------------------------------------
type Rgb = [number, number, number];
const hex = (s: string): Rgb => [
parseInt(s.slice(1, 3), 16), parseInt(s.slice(3, 5), 16), parseInt(s.slice(5, 7), 16),
];
const BG = hex("#171a20");
const CELL = hex("#e9e1cb");
const CELL_LINE = hex("#cfc4a8");
const WALL = hex("#43331f");
const DOOR = hex("#a2703a");
const FIRE = hex("#d65c1c");
const STONE = hex("#8a8a94");
const WARP = hex("#7a4ccc");
const CREATURE = hex("#4a4438");
const TREASURE = hex("#c9a72a");
const HAZARD = hex("#7c8a4a");
/** Standee colors, mirroring the client's PLAYER_COLORS. */
const PLAYER: Rgb[] = [
hex("#1a9c46"), hex("#d3352b"), hex("#c9308f"),
hex("#3a3ac0"), hex("#2ab0c9"), hex("#c9a72a"),
];
export function renderSharePng(view: GameView): Buffer {
const W = 1200, H = 630;
const img = new Uint8Array(W * H * 3);
const rect = (x: number, y: number, w: number, h: number, c: Rgb) => {
const x0 = Math.max(0, Math.round(x)), y0 = Math.max(0, Math.round(y));
const x1 = Math.min(W, Math.round(x + w)), y1 = Math.min(H, Math.round(y + h));
for (let yy = y0; yy < y1; yy++) {
let o = (yy * W + x0) * 3;
for (let xx = x0; xx < x1; xx++) {
img[o] = c[0]; img[o + 1] = c[1]; img[o + 2] = c[2];
o += 3;
}
}
};
const disc = (cx: number, cy: number, r: number, c: Rgb) => {
for (let yy = Math.round(cy - r); yy <= cy + r; yy++) {
for (let xx = Math.round(cx - r); xx <= cx + r; xx++) {
if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue;
if ((xx - cx) ** 2 + (yy - cy) ** 2 > r * r) continue;
const o = (yy * W + xx) * 3;
img[o] = c[0]; img[o + 1] = c[1]; img[o + 2] = c[2];
}
}
};
rect(0, 0, W, H, BG);
const bw = view.board.width, bh = view.board.height;
const cell = Math.floor(Math.min((W - 120) / bw, (H - 80) / bh));
const ox = Math.round((W - bw * cell) / 2);
const oy = Math.round((H - bh * cell) / 2);
const px = (cx: number, cy: number) => [ox + cx * cell, oy + cy * cell] as const;
const line = Math.max(3, Math.round(cell * 0.1));
// The rooms of the maze.
const has = (cx: number, cy: number) => !!view.board.cells[`${cx},${cy}`];
for (const k of Object.keys(view.board.cells)) {
const [cx, cy] = k.split(",").map(Number) as [number, number];
const [x, y] = px(cx, cy);
rect(x, y, cell, cell, CELL);
rect(x, y, cell, 1, CELL_LINE);
rect(x, y, 1, cell, CELL_LINE);
}
// Home bases wear their owner's color as a floor border.
for (const p of view.players) {
if (!p.home) continue;
const [x, y] = px(p.home.x, p.home.y);
const c = PLAYER[p.colorIndex] ?? WALL;
const t = Math.max(2, Math.round(cell * 0.08));
rect(x + 2, y + 2, cell - 4, t, c);
rect(x + 2, y + cell - 2 - t, cell - 4, t, c);
rect(x + 2, y + 2, t, cell - 4, c);
rect(x + cell - 2 - t, y + 2, t, cell - 4, c);
}
// The floor's furniture: filled stone reads solid; other hazards dot.
for (const [k, content] of Object.entries(view.squareContents)) {
const [cx, cy] = k.split(",").map(Number) as [number, number];
const [x, y] = px(cx, cy);
if (content.kind === "stone") rect(x + 1, y + 1, cell - 2, cell - 2, STONE);
else disc(x + cell / 2, y + cell / 2, cell * 0.16, HAZARD);
}
// Interior walls, doors, and fire, on their edges.
for (const [k, e] of Object.entries(view.board.edges)) {
if (e === "open") continue;
const [kind, coords] = k.split(":") as [string, string];
const [ex, ey] = coords.split(",").map(Number) as [number, number];
const c = e === "door" ? DOOR : e === "firewall" ? FIRE : WALL;
if (kind === "V") {
const [x, y] = px(ex + 1, ey);
rect(x - line / 2, y, line, cell, c);
} else {
const [x, y] = px(ex, ey + 1);
rect(x, y - line / 2, cell, line, c);
}
}
// The rim: every open side facing nothing is a wall, except warp mouths.
const mouth = new Set(view.board.warps.map((w) => `${w.from.cell.x},${w.from.cell.y}:${w.from.side}`));
for (const k of Object.keys(view.board.cells)) {
const [cx, cy] = k.split(",").map(Number) as [number, number];
const [x, y] = px(cx, cy);
const side = (missing: boolean, sx: number, sy: number, w: number, h: number, s: string) => {
if (!missing) return;
rect(sx, sy, w, h, mouth.has(`${cx},${cy}:${s}`) ? WARP : WALL);
};
side(!has(cx + 1, cy), x + cell - line / 2, y, line, cell, "E");
side(!has(cx - 1, cy), x - line / 2, y, line, cell, "W");
side(!has(cx, cy + 1), x, y + cell - line / 2, cell, line, "S");
side(!has(cx, cy - 1), x, y - line / 2, cell, line, "N");
}
// Loose treasures glint gold.
for (const t of view.treasures) {
if (!t.position || t.carriedBy) continue;
const [x, y] = px(t.position.x, t.position.y);
rect(x + cell * 0.6, y + cell * 0.6, cell * 0.25, cell * 0.25, TREASURE);
}
// Creatures crouch dark; wizards stand in their colors.
for (const c of view.creatures) {
const [x, y] = px(c.position.x, c.position.y);
disc(x + cell * 0.3, y + cell * 0.68, cell * 0.18, CREATURE);
}
const standing = new Map<string, number>();
for (const p of view.players) {
if (!p.alive) continue;
const key = `${p.position.x},${p.position.y}`;
const n = standing.get(key) ?? 0;
standing.set(key, n + 1);
const [x, y] = px(p.position.x, p.position.y);
const cx = x + cell / 2 + (n - 0.5) * cell * 0.16;
disc(cx, y + cell * 0.42, cell * 0.26, PLAYER[p.colorIndex] ?? WALL);
disc(cx, y + cell * 0.42, cell * 0.26 - 2, PLAYER[p.colorIndex] ?? WALL);
}
return encodePng(W, H, img);
}
+4 -1
View File
@@ -428,7 +428,10 @@ const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]
*/
export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number): CatchUpStep[] | { error: string } {
if (!room.state) return { error: "game not started" };
if (!room.players.includes(playerId)) return { error: "you hold no seat in this room" };
// The SPECTATOR builds share pages: public knowledge only, no seat.
if (playerId !== SPECTATOR && !room.players.includes(playerId)) {
return { error: "you hold no seat in this room" };
}
const MAX_STEPS = 80;
const { state: fresh, events: dealt } = createGame(room.state.config);
let current = fresh;
+58
View File
@@ -0,0 +1,58 @@
// Share links: a minted slug names one turn of one game, and anyone
// holding it may watch that turn — spectator-redacted, nothing else of
// the game visible. Shares persist beside the rooms and survive restarts.
import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { randomInt } from "node:crypto";
import { statsDir } from "./store";
export interface Share {
id: string;
roomId: string;
turn: number;
createdAt: string;
}
const SHARE_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
const SHARE_ID_LENGTH = 10; // 31^10 ≈ 8×10^14 — unguessable, typeable
const shares = new Map<string, Share>();
function sharesFile(): string {
return join(statsDir(), "shares.jsonl");
}
export function loadShares(): void {
const file = sharesFile();
if (!existsSync(file)) return;
for (const line of readFileSync(file, "utf8").trim().split("\n")) {
if (!line) continue;
try {
const s = JSON.parse(line) as Share;
if (s.id) shares.set(s.id, s);
} catch {
// A torn tail line loses one share, never the server.
}
}
}
export function mintShare(roomId: string, turn: number): Share {
// One link per (room, turn): sharing the same turn twice hands back
// the same slug, so a re-share never splits an audience.
for (const s of shares.values()) {
if (s.roomId === roomId && s.turn === turn) return s;
}
let id = "";
for (let i = 0; i < SHARE_ID_LENGTH; i++) {
id += SHARE_ALPHABET[randomInt(SHARE_ALPHABET.length)];
}
const share: Share = { id, roomId, turn, createdAt: new Date().toISOString() };
shares.set(id, share);
appendFileSync(sharesFile(), JSON.stringify(share) + "\n", "utf8");
return share;
}
export function getShare(id: string): Share | undefined {
return shares.get(id);
}