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
+1 -1
View File
@@ -1612,7 +1612,7 @@
{/if}
{#if net.moment && net.moment.length > 0}
<Replay steps={net.moment} moment onclose={() => net.closeMoment()} />
<Replay steps={net.moment} moment onshare={() => net.requestShare()} onclose={() => net.closeMoment()} />
{:else if net.catchUp && net.catchUp.length > 0}
<Replay steps={net.catchUp} onclose={() => net.closeCatchUp()} />
{:else if local.replaySteps && local.replaySteps.length > 0}
+28 -1
View File
@@ -14,14 +14,35 @@
steps,
onclose,
moment = false,
onshare = null,
endLabel = null,
}: {
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[];
onclose: () => void;
/** An instant replay of one turn: open straight into first person,
* through the eyes of the wizard whose turn it is. */
moment?: boolean;
/** Mint a public link to this turn; resolves to the URL. */
onshare?: (() => Promise<string>) | null;
/** What the leave button says once the reel has run out. */
endLabel?: string | null;
} = $props();
/** The share button's little life: offer, mint, report. */
let shareState = $state<"idle" | "minting" | "copied" | "failed">("idle");
async function doShare() {
if (!onshare || shareState === "minting") return;
shareState = "minting";
try {
const url = await onshare();
await navigator.clipboard.writeText(url);
shareState = "copied";
} catch {
shareState = "failed";
}
setTimeout(() => (shareState = "idle"), 4000);
}
let idx = $state(0);
let playing = $state(true);
let speed = $state(1);
@@ -378,7 +399,13 @@
<button class="replay-eyes" class:lit={recorder !== null} onclick={toggleRecord}>
{recorder ? "⏹ stop & save" : "⏺ save video"}</button>
{/if}
<button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button>
{#if moment && onshare}
<button class="replay-eyes" class:lit={shareState === "copied"} onclick={doShare}>
{shareState === "idle" ? "🔗 share link"
: shareState === "minting" ? "…"
: shareState === "copied" ? "✓ link copied" : "share failed"}</button>
{/if}
<button class="replay-skip" onclick={onclose}>{atEnd ? (endLabel ?? "back to the game") : "skip to now"}</button>
</header>
<div class="replay-board" bind:this={stageEl}>
{#if fp}
+137
View File
@@ -0,0 +1,137 @@
<script lang="ts">
// The public share page (/watch/<id>): one turn of one game, watchable
// by anyone, spectator-redacted — the rest of the game stays private.
// The reel opens in first person through the turn-owner's eyes, framed
// by enough chrome to explain itself and point the way home.
import Replay from "./Replay.svelte";
import type { GameEvent, GameView } from "@wizwar/engine";
const id = location.pathname.split("/")[2] ?? "";
// Under the vite dev server the API lives on the game server's port.
const API = location.port === "5173" ? `http://${location.hostname}:8787` : "";
interface ShareSteps {
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView }[];
actor: string;
round: number;
}
let data = $state<ShareSteps | null>(null);
let failed = $state(false);
let reelKey = $state(0);
$effect(() => {
fetch(`${API}/api/share/${id}`)
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("gone"))))
.then((d) => (data = d))
.catch(() => (failed = true));
});
</script>
<div class="share-page">
<header class="share-head">
<a class="share-brand" href="/">WIZ-WAR</a>
{#if data}
<div class="share-title">Instant replay — {data.actor}'s turn{data.round ? ` (round ${data.round})` : ""}</div>
{/if}
</header>
{#if failed}
<div class="share-note">
<p>This replay has wandered off — the link may be old, or the game gone.</p>
<a class="share-cta" href="/">▶ deal yourself into a fresh game</a>
</div>
{:else if !data}
<div class="share-note"><p>Rebuilding the turn…</p></div>
{:else}
<div class="share-stage">
{#key reelKey}
<Replay steps={data.steps} moment endLabel="⟲ watch it again"
onclose={() => (reelKey += 1)} />
{/key}
</div>
{/if}
<footer class="share-foot">
<p>
<strong>Wiz-War</strong> is Tom Jolly's 1983 game of magical combat in
a stone labyrinth. This digital table is an unofficial, non-commercial
fan re-creation of the sixth edition (Chessex, 1993) — every card
transcribed, every wall verified, replays rebuilt move by move from
the game's own ledger.
</p>
<a class="share-cta" href="/">▶ play free — two to six wizards enter the maze</a>
</footer>
</div>
<style>
.share-page {
min-height: 100vh;
background: #171a20;
color: #d8d2c0;
font-family: "Archivo Narrow", system-ui, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem;
}
.share-head {
display: flex;
align-items: baseline;
gap: 1rem;
width: min(46rem, 100%);
margin-bottom: 0.6rem;
}
.share-brand {
font-family: "Oswald", sans-serif;
letter-spacing: 0.3em;
font-size: 1.1rem;
color: #e0b34a;
text-decoration: none;
}
.share-title {
font-family: "Oswald", sans-serif;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.85rem;
color: #e9e1cb;
}
/* The reel is a modal everywhere else; here it stands in the page. */
.share-stage { width: min(46rem, 100%); }
.share-stage :global(.replay-scrim) {
position: relative;
inset: auto;
background: none;
padding: 0;
display: block;
z-index: 1;
}
.share-stage :global(.replay) { width: 100%; max-height: none; }
.share-note {
background: #efe8d4;
color: #3a2f1f;
border-radius: 4px;
padding: 1.2rem 1.5rem;
margin: 2rem 0;
font-family: "Courier Prime", monospace;
text-align: center;
}
.share-foot {
width: min(46rem, 100%);
margin-top: 1rem;
color: #8d8672;
font-size: 0.9rem;
line-height: 1.5;
}
.share-foot strong { color: #c9a72a; }
.share-cta {
display: inline-block;
margin-top: 0.4rem;
background: #e9e1cb;
color: #43331f;
border: 1.5px solid #43331f;
border-radius: 3px;
padding: 0.45rem 0.9rem;
text-decoration: none;
font-family: "Oswald", sans-serif;
letter-spacing: 0.06em;
}
</style>
+1 -1
View File
@@ -290,7 +290,7 @@
.map((b) => project(b, ex, ey))
.filter((s): s is Projected => s !== null);
for (const spot of rubble) {
const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.4, rise: 0 }, ex, ey);
const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.55, rise: 0 }, ex, ey);
if (s) sprites.push({ ...s, fallback: "rubble" });
}
for (const f of fx) {
+8 -4
View File
@@ -1,6 +1,10 @@
import { mount } from "svelte";
import App from "./App.svelte";
const app = mount(App, { target: document.getElementById("app")! });
export default app;
// /watch/<id> is the public share page — one turn, anyone may look. It
// loads its own component so the full app (and its live socket appetite)
// stays out of a shared link's way.
const target = document.getElementById("app")!;
const page = location.pathname.startsWith("/watch/")
? import("./SharePage.svelte")
: import("./App.svelte");
page.then(({ default: Root }) => mount(Root, { target }));
+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;
}