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
+23
View File
@@ -321,6 +321,9 @@ class Net {
private turnCounter = -1;
/** The turn that already carries an instant-replay eye (one per turn). */
private eyeTurn = -1;
/** The turn whose moment reel is open (share links point at it). */
private momentTurn: number | null = null;
private shareResolve: ((url: string) => void) | null = null;
private seen: Record<string, number> = loadSeen();
/** Room whose live stream this connection has already shown once: states
* after the first mark themselves seen while the tab is visible. */
@@ -423,6 +426,10 @@ class Net {
case "moment":
this.moment = msg.steps;
break;
case "share":
this.shareResolve?.(`${location.origin}/watch/${msg.id}`);
this.shareResolve = null;
break;
case "events": {
let talk = 0;
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
@@ -688,9 +695,25 @@ class Net {
/** Summon one turn's reel by its chronicle turn number. */
requestMoment(turn: number): void {
this.momentTurn = turn;
this.send({ type: "moment", turn });
}
/** Mint a public link to the open moment's turn. */
requestShare(): Promise<string> {
return new Promise((resolve, reject) => {
if (this.momentTurn === null) return reject(new Error("no turn open"));
this.shareResolve = resolve;
this.send({ type: "share", turn: this.momentTurn });
setTimeout(() => {
if (this.shareResolve === resolve) {
this.shareResolve = null;
reject(new Error("share timed out"));
}
}, 10_000);
});
}
closeMoment(): void {
this.moment = null;
}