The first minute, for a stranger: what the game is, what to do, and how to join

The landing says the pitch before it asks for a name — steal treasure,
blast through walls, outwit your friends — and states the goal. The
coach says "select a card, then choose its target," which is what the
cards actually want. The turn banner counts treasures home beside the
moves left. The council speaks once when its three voices agree. A
friend arriving on an invite link meets "Join Eric's game," a name
field, and a seat, with the gallery an afterthought; the masthead
says "invite friends" beside the room code. The first-person view has
a toggle beside the board instead of a checkbox in preferences. How
to play opens with winning. And a shared moment's card is titled by
the turn's story — the treasure carried home, the blow that landed,
the spell cast — instead of whose turn it was.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-15 23:03:17 -04:00
co-authored by Claude Fable 5.1
parent a071494f4a
commit 4cc0423846
3 changed files with 100 additions and 25 deletions
+33
View File
@@ -36,6 +36,7 @@ import { randomBytes } from "node:crypto";
import { readFileSync, existsSync, realpathSync, statSync, createReadStream } from "node:fs";
import { extname, join, normalize, sep } from "node:path";
import { WebSocketServer, WebSocket } from "ws";
import { cardDef } from "@wizwar/engine";
import type { Command, PlayerId } from "@wizwar/engine";
import {
catchUpSteps,
@@ -198,10 +199,42 @@ function safeBase(rawHost: string, rawProto: string): string {
return `${proto}://${escapeHtml(rawHost)}`;
}
/** The turn's story in one line, for the share card: the deed a
* stranger would click to see, chosen by weight — a crown, a treasure
* carried home, a blow that landed, a spell cast — before the bare fact
* of whose turn it was. */
function headlineOf(data: ShareData): string | null {
const name = (id: string | null) => { if (!id) return "a spell"; try { return cardDef(id).name; } catch { return id; } };
let best: { rank: number; text: string } | null = null;
const offer = (rank: number, text: string) => { if (!best || rank > best.rank) best = { rank, text }; };
for (const step of data.steps) {
for (const e of step.events) {
switch (e.type) {
case "gameWon": offer(9, e.reason === "treasures" ? `${e.player} wins with two treasures home` : `${e.player} is the last wizard standing`); break;
case "playerEliminated": offer(8, e.reason === "killed" ? `${e.player} falls in the maze` : `${e.player} loses both treasures`); break;
case "treasureDropped": if (e.onHomeOf === e.player) offer(7, `${e.player} carries a stolen treasure home`); break;
case "treasurePickedUp": if (e.owner !== e.player) offer(5, `${e.player} snatches ${e.owner}'s treasure`); break;
case "attackResolved": if (e.damageDealt > 0) offer(6, `${e.attacker} hits ${e.defender} with ${name(e.attackCardId)} for ${e.damageDealt}`); else if (e.fullyStopped) offer(4, `${e.defender} stops ${e.attacker}'s ${name(e.attackCardId)} cold`); break;
case "punched": offer(3, `${e.attacker} punches ${e.target}`); break;
case "spellCast": offer(2, e.target ? `${e.caster} casts ${name(e.cardId)} at ${e.target}` : `${e.caster} casts ${name(e.cardId)}`); break;
case "jumpedPit": offer(3, `${e.player} leaps the pit`); break;
case "fellInPit": offer(3, `${e.player} falls into the pit`); break;
case "wardSprung": offer(4, `${e.owner}'s warded treasure bites ${e.victim}`); break;
case "slimeTrapSprung": offer(4, `the slime springs ${name(e.cardId)} on ${e.victim}`); break;
default: break;
}
}
}
return best ? (best as { text: string }).text : null;
}
function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string {
const base = safeBase(rawHost, rawProto);
const headline = data.whole ? null : headlineOf(data);
const title = data.whole
? "The whole tale — a game of Wiz-War, replayed"
: headline
? `${escapeHtml(headline)} — a Wiz-War instant replay`
: `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
const desc = data.whole
? `A full game of Wiz-War, magical combat in a stone labyrinth — every turn through its wizard's own eyes${data.actor ? `, to ${escapeHtml(data.actor)}'s triumph` : ""}. Watch it all, then deal yourself in.`
+52 -17
View File
@@ -1492,6 +1492,11 @@
function pass() { dispatch({ type: "pass" }); }
const me = $derived(view?.players.find((p) => p.id === view?.you) ?? null);
/** Enemy treasures resting on your home square: two wins the game. */
const treasuresHome = $derived(
view && me ? view.treasures.filter((t) => t.owner !== me!.id && t.position &&
t.position.x === me!.home.x && t.position.y === me!.home.y).length : 0,
);
const paBurnable = $derived(me ? Array.from({ length: Math.max(0, me.life - 1) }, (_, i) => i + 1) : []);
/** Two treasures can share a square: one dispatches, more ask whose. */
const treasuresHere = $derived(
@@ -1660,7 +1665,7 @@
{:else if net.roomId}
<button class="mast-room" title="copy the invite link — friends can join, or watch once it's begun"
onclick={copyInvite}>
{net.spectating ? "watching" : "room"} <b>{net.roomId}</b>{inviteCopied ? " — link copied!" : ""}
{net.spectating ? "watching" : "invite friends · room"} <b>{net.roomId}</b>{inviteCopied ? " — link copied!" : ""}
</button>
{#if net.audience > 0}
<span class="mast-audience" title="the Peanut Gallery">👁 {net.audience}</span>
@@ -2232,7 +2237,9 @@
<section class="boxlid">
<div class="boxlid-inner">
<div class="boxlid-title">Wiz-War</div>
<div class="boxlid-tag">A game of magical combat in a stone labyrinth</div>
<div class="boxlid-pitch">Steal treasure. Blast through walls. Outwit your friends.</div>
<div class="boxlid-tag">A turn-based wizard board game for two to six, free in your browser, with
friends or clockwork opponents. Carry home two treasures that aren't yours, or be the last wizard standing.</div>
<button class="about-link" onclick={() => { helpTab = "about"; showHelp = true; }}>
about this game — a labor of love
</button>
@@ -2375,12 +2382,29 @@
{/each}
</div>
{/if}
{#if net.spectating}
{#if net.players.length < 6}
<!-- A friend's link lands here: the seat is the invitation, the gallery the afterthought. -->
<div class="lobby-head">Join {net.hostId ?? "the"}'s game</div>
<div class="boxlid-tag">Choose your wizard's name and take a seat; the boards flip when the host starts.</div>
<div class="gallery-join">
<input bind:value={name} maxlength="20" placeholder="e.g. Mordecai" aria-label="your wizard's name" />
<button class="stamp primary" disabled={!name.trim()}
onclick={() => { setPref("wizardName", name.trim()); net.join(net.roomId!, name.trim()); }}>
Take a seat
</button>
</div>
<p class="waiting">👁 Just watching? You're in the Peanut Gallery until you sit.</p>
{:else}
<p class="waiting">👁 The table is full you watch from the Peanut Gallery. Waiting for the boards to flip</p>
{/if}
{/if}
<div class="lobby-head">Fill the table</div>
<div class="boxlid-tag">Two to six wizards enter the maze friends by code or link,
computer wizards seated below, or a mix of both.</div>
<div class="invite-row">
<code class="invite-link">{inviteLink}</code>
<button class="stamp tiny" onclick={copyInvite}>{inviteCopied ? "copied!" : "copy link"}</button>
<button class="stamp tiny" onclick={copyInvite}>{inviteCopied ? "copied!" : "copy invite link"}</button>
</div>
<ul class="roster">
{#each net.players as p (p)}
@@ -2399,18 +2423,7 @@
</li>
{/each}
</ul>
{#if net.spectating}
<p class="waiting">👁 You watch from the Peanut Gallery. Waiting for the boards to flip…</p>
{#if net.players.length < 6}
<div class="gallery-join">
<input bind:value={name} maxlength="20" placeholder="e.g. Mordecai" aria-label="your wizard's name" />
<button class="stamp" disabled={!name.trim()}
onclick={() => { setPref("wizardName", name.trim()); net.join(net.roomId!, name.trim()); }}>
or take a seat
</button>
</div>
{/if}
{:else if net.you === net.hostId}
{#if net.you === net.hostId}
{#if net.players.length < 6}
<div class="lobby-head">Seat a computer wizard</div>
<div class="boxlid-tag">An automaton plays a full seat by the rules — a hunter goes for treasure,
@@ -2645,6 +2658,14 @@
{/if}
{/snippet}
<section class="board-zone">
{#if view.you && !net.spectating}
<div class="view-toggle">
<button class="stamp tiny" class:lit={prefs.liveFp} onclick={() => setPref("liveFp", !prefs.liveFp)}
title="a first-person pane above the board, through your wizard's eyes">
{prefs.liveFp ? "▦ the board only" : "👁 through your eyes"}
</button>
</div>
{/if}
<div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}>
{#if prefs.liveFp && view.you && !net.spectating}
<LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)}
@@ -2768,7 +2789,7 @@
picking up ended your actions for this turn — <strong>End turn</strong> when ready.
{/if}
{:else}
{view.turn.movementAllowance - view.turn.movementUsed} moves left{view.turn.attackUsed ? " · attack spent" : ""}
{view.turn.movementAllowance - view.turn.movementUsed} moves left{view.turn.attackUsed ? " · attack spent" : ""} · treasures home {treasuresHome} of 2
{/if}
</div>
{#if !prefs.coached && !view.turn.actionsEnded}
@@ -2777,7 +2798,8 @@
<div class="slip coach">
<strong>How a turn works.</strong> Walk up to {view.turn.movementAllowance} squares by tapping
the square beside your wizard, one step at a time; play a NUMBER card first to walk farther.
Tap a card to cast it. When you're done, <strong>End turn</strong>. Stuck? Ask the automatons for hints.
Select a card, then choose its target — a wizard, a square, or a wall, as the hint bar says.
When you're done, <strong>End turn</strong>. Stuck? Ask the automatons for hints.
<button class="stamp tiny" onclick={() => setPref("coached", true)}>got it</button>
</div>
{/if}
@@ -2832,6 +2854,9 @@
<div class="council-head">The council considers your position…
<button class="hint-cancel" onclick={() => (councilHints = null)}>enough</button>
</div>
{#if councilHints.length > 1 && councilHints.every((h) => h.advice === councilHints![0]!.advice)}
<div class="council-row"><span class="council-name">The council agrees:</span> {councilHints[0]!.advice}</div>
{:else}
{#each councilHints as h (h.name)}
<div class="council-row">
<span class="council-name">{h.glyph} {h.name}</span>
@@ -2839,6 +2864,7 @@
{h.advice}
</div>
{/each}
{/if}
<button class="stamp tiny" onclick={askCouncil}>ask again</button>
</div>
{/if}
@@ -3445,6 +3471,15 @@
letter-spacing: 0.02em;
}
.boxlid-title.small { font-size: 1.7rem; }
.boxlid-pitch {
font-family: "Oswald", sans-serif;
font-weight: 700;
font-size: 1.15rem;
letter-spacing: 0.02em;
color: #3a2f1f;
margin: 0.2rem 0 0.1rem;
}
.view-toggle { display: flex; justify-content: flex-end; margin: 0 0 0.3rem; }
.boxlid-tag {
font-size: 0.95rem;
color: #6b5a41;
+7
View File
@@ -91,6 +91,13 @@ export const RULES_SECTIONS: RulesSection[] = [
];
export const HOW_TO_PLAY: RulesSection[] = [
{
title: "Winning",
body: [
"Carry two treasures that aren't yours home — one at a time, to your home square, and drop them there — or be the last wizard standing. Lose both of your own treasures to enemy homes and you're out.",
"A turn is: walk, then act. Step up to three squares, select a card and choose its target, and End turn. The banner above the board keeps count of your moves and your treasures home.",
],
},
{
title: "Moving",
body: [