The hall comes first, in the shape of wizwar's landing

The home page is a centred card: title and pitch, your name, play the
bot or continue the duel under way, create or join a duel by code, a
sample ledger, the duels this browser holds with whose move it is, and
the story of the game. The single-player board lives at /play. The
droplet gains the duel server as a systemd service behind Caddy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-09-22 17:11:45 -04:00
co-authored by Claude Fable 5.1
parent e1bda87987
commit 6c6edca4be
9 changed files with 578 additions and 254 deletions
+2 -1
View File
@@ -46,7 +46,8 @@ p {
}
button,
select {
select,
input {
font: inherit;
color: inherit;
}
+234 -44
View File
@@ -1,16 +1,20 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { allSeats, api, forgetSeat } from '$lib/game/client';
import { Duel } from '$lib/game/duel.svelte';
import { Duel, duel, NAME_MAX, playerName, rememberName } from '$lib/game/duel.svelte';
import type { RoomView } from '$lib/game/view';
let { name }: { name: string } = $props();
let name = $state(playerName() || duel.you.name);
let code = $state('');
let busy = $state(false);
let error = $state('');
let seats = $state(allSeats());
let games = $state<Record<string, RoomView | null>>({});
let aboutOpen = $state(false);
/** The single-player duel saved in this browser, if one is under way. */
const local = $derived(duel.inProgress ? duel : null);
const ready = $derived(name.trim().length > 0);
// Each held seat is looked up once, so the ledger can say whose move it is.
$effect(() => {
@@ -27,11 +31,17 @@
}
});
async function playBot() {
rememberName(name);
await goto('/play');
}
async function create() {
busy = true;
error = '';
try {
const remote = await Duel.createRoom(name, 2);
rememberName(name);
const remote = await Duel.createRoom(name.trim(), 2);
await goto(`/join/${remote.roomId}`);
} catch (e) {
error = e instanceof Error ? e.message : 'The duel could not be opened.';
@@ -42,6 +52,7 @@
function join(e: SubmitEvent) {
e.preventDefault();
rememberName(name);
const c = code.trim().toUpperCase();
if (c) void goto(`/join/${c}`);
}
@@ -71,24 +82,78 @@
const g = games[held.roomId];
return !!g?.state && !g.state.over && g.waitingOn.includes(held.seat);
}
/* A few turns of a duel, as the ledger writes them. */
const sample = [
{ turn: 1, a: ['W', 'S'], b: ['P', 'W'], notes: { b0: 'Shield' } },
{ turn: 2, a: ['W', 'D'], b: ['P', 'D'], notes: { a1: 'Missile', b0: 'Shield' } },
{ turn: 3, a: ['S', 'F'], b: ['F', 'D'], notes: { a0: 'Counter-spell' } },
{ turn: 4, a: ['F', 'S'], b: ['F', 'D'], notes: { b0: 'Paralysis', b1: 'Lightning bolt' } }
];
</script>
<section class="hall" id="hall">
<h2>Duel a friend</h2>
<p class="pitch">A duel between people is played by turns, whenever each of you has a moment. Open one and send the link, or join with a code.</p>
<div class="ways">
<button type="button" class="reveal small" disabled={busy} onclick={create}>Create a duel as {name}</button>
<form class="joinform" onsubmit={join}>
<span class="or">or join one</span>
<input type="text" class="code" bind:value={code} maxlength="4" placeholder="CODE" autocapitalize="characters" autocomplete="off" aria-label="room code" />
<button type="submit" class="quiet" disabled={code.trim().length < 4}>Join</button>
</form>
<section class="lid">
<div class="lid-head">
<h1>Waving Hands</h1>
<p class="pitch">Two wizards. Two hands each. The right run of gestures is a spell.</p>
<p class="tag">Richard Bartle's 1977 pencil-and-paper duel, free in your browser. Each turn both wizards choose a gesture per hand and reveal them together; a Fireball is F-S-S-D-D on one hand, a Shield is a single palm. Play the bot, or a friend by turns.</p>
<a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game a labor of love</a>
</div>
{#if error}<p class="warning">{error}</p>{/if}
{#if seats.length}
<div class="lid-play">
<label class="name">
<span>Your wizard's name</span>
<input type="text" bind:value={name} maxlength={NAME_MAX} placeholder="e.g. Morwenna" autocomplete="nickname" />
</label>
<button type="button" class="reveal primary" disabled={!ready} onclick={playBot}>
{local ? `Continue against ${local.foe.name} (turn ${local.turnNumber})` : 'Play now against the bot'}
</button>
{#if local}
<p class="muted small">Or start afresh from the board's "New duel".</p>
{/if}
<div class="ways">
<button type="button" class="quiet" disabled={!ready || busy} onclick={create}>Create a duel</button>
<form class="joinform" onsubmit={join}>
<span class="or">or join one</span>
<input type="text" class="code" bind:value={code} maxlength="4" placeholder="CODE" autocapitalize="characters" autocomplete="off" aria-label="room code" />
<button type="submit" class="quiet" disabled={!ready || code.trim().length < 4}>Join</button>
</form>
</div>
{#if error}<p class="warning">{error}</p>{/if}
</div>
<div class="lid-look" aria-hidden="true">
<table class="sample">
<thead>
<tr><th></th><th colspan="2">Corwin</th><th colspan="2">Ysolde</th></tr>
</thead>
<tbody>
{#each sample as row (row.turn)}
<tr>
<td class="t">{row.turn}</td>
{#each ['a0', 'a1', 'b0', 'b1'] as cell, i (cell)}
{@const g = i < 2 ? row.a[i] : row.b[i - 2]}
{@const note = (row.notes as Record<string, string | undefined>)[cell]}
<td class:cast={!!note}><span class="letter">{g}</span>{#if note}<span class="note">{note}</span>{/if}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
<p class="caption">The ledger: every gesture both wizards have made, and the spells they finished.</p>
</div>
{#if seats.length || local}
<div class="ledger">
<div class="ledger-head">your duels</div>
{#if local}
<div class="ledger-row your-move">
<a class="ledger-resume" href="/play">
<span class="ledger-code">bot</span>
<span class="ledger-info">your move · turn {local.turnNumber} · against {local.foe.name}</span>
</a>
</div>
{/if}
{#each seats as held (held.roomId)}
<div class="ledger-row" class:your-move={yourMove(held)}>
<a class="ledger-resume" href={`/join/${held.roomId}`}>
@@ -101,45 +166,108 @@
</div>
{/if}
<details class="about">
<summary>about this game — a labor of love</summary>
<details class="about" id="about" bind:open={aboutOpen}>
<summary>about this game</summary>
<div class="about-body">
<p>Waving Hands is Richard Bartle's game from 1977, played with pencil, paper and two hands. Each wizard writes down a gesture for each hand, both reveal at once, and the right run of gestures on one hand is a spell. It was later known as Spellbinder and Spellcaster, and Andrew Plotkin's <em>Spellcast</em> brought it to university X terminals in 1993, which is where the maker of this page first met it.</p>
<p>This version keeps the game as written: all forty spells, the same simultaneous resolution, the same ledger of letters. The rules text it follows is the fanzine transcription, kept in full on the <a href="/rules">rules page</a>. The bot is a heuristic that reads your gestures as you would read its; the hand silhouettes on the buttons are adapted from Plotkin's bitmaps, with his notice retained.</p>
<p>It is free, keeps no accounts, and stores your single-player duel in your own browser. Duels between people live on a small server as an append-only ledger of moves, so a game can be replayed from its first gesture.</p>
<p>Waving Hands is Richard Bartle's game from 1977, played with pencil, paper and two hands. It was later known as Spellbinder and Spellcaster, and Andrew Plotkin's <em>Spellcast</em> brought it to university X terminals in 1993, which is where the maker of this page first met it.</p>
<p>This version keeps the game as written: all forty spells, the same simultaneous resolution, the same ledger of letters. The rules text it follows is the fanzine transcription, kept in full on the <a href="/rules">rules page</a>. The bot reads your gestures as you would read its; the hand silhouettes on the buttons are adapted from Plotkin's bitmaps, with his notice retained.</p>
<p>It is free and keeps no accounts. A duel against the bot lives in your own browser. A duel between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first gesture, and each wizard is shown only what the rules let them see.</p>
</div>
</details>
</section>
<style>
.hall {
max-width: 1280px;
margin: 0 auto;
padding: 1.5rem var(--gutter) 0.5rem;
border-top: 1px solid var(--rule);
.lid {
width: 100%;
max-width: 54rem;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 8px;
padding: clamp(1.4rem, 3vw, 2.2rem) clamp(1.2rem, 3vw, 2.6rem) 1.8rem;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-areas: 'head' 'play' 'look' 'ledger' 'about';
gap: 1.2rem 2.2rem;
}
h2 {
font-size: 1.4rem;
margin-bottom: 0.3rem;
@media (min-width: 901px) {
.lid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas: 'head head' 'play look' 'ledger ledger' 'about about';
}
}
.lid-head {
grid-area: head;
text-align: center;
}
h1 {
font-size: clamp(2.6rem, 6vw, 3.6rem);
font-weight: 300;
font-variation-settings: 'opsz' 144;
letter-spacing: -0.01em;
}
.pitch {
max-width: 38em;
font-style: italic;
font-size: 1.2rem;
margin-top: 0.3rem;
}
.tag {
color: var(--bone-dim);
margin-bottom: 0.9rem;
max-width: 40em;
margin: 0.6rem auto 0.4rem;
font-size: 0.95rem;
}
.about-link {
display: inline-block;
color: var(--frost);
font-style: italic;
text-decoration: underline dotted;
font-size: 0.95rem;
}
.lid-play {
grid-area: play;
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.name {
display: block;
}
.name span {
display: block;
font-size: 0.8rem;
letter-spacing: 0.12em;
color: var(--bone-dim);
margin-bottom: 0.3rem;
}
.name input {
width: 100%;
}
.reveal.primary {
width: 100%;
text-align: center;
}
.small {
font-size: 0.85rem;
margin-top: -0.4rem;
}
.ways {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem 1.25rem;
}
.reveal.small {
padding: 0.45rem 1rem;
font-size: 1rem;
gap: 0.6rem 1rem;
}
.joinform {
@@ -151,6 +279,7 @@
.or {
color: var(--bone-dim);
font-style: italic;
font-size: 0.95rem;
}
.code {
@@ -165,7 +294,7 @@
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.4rem 0.9rem;
padding: 0.45rem 0.9rem;
color: var(--bone);
}
@@ -175,15 +304,73 @@
.warning {
color: var(--blood);
margin-top: 0.6rem;
}
.lid-look {
grid-area: look;
}
.sample {
width: 100%;
border-collapse: collapse;
background: var(--slate-deep);
border: 1px solid var(--rule-strong);
border-radius: 6px;
overflow: hidden;
}
.sample th {
font-weight: 500;
font-style: italic;
padding: 0.4rem 0 0.2rem;
border-bottom: 1px solid var(--rule-strong);
}
.sample td {
text-align: center;
padding: 0.3rem 0.2rem;
border-bottom: 1px solid var(--rule);
line-height: 1.1;
}
.sample td.t {
width: 2rem;
color: var(--bone-faint);
font-size: 0.8rem;
}
.sample td:nth-child(3) {
border-right: 1px solid var(--rule-strong);
}
.sample .letter {
display: block;
font-size: 1.6rem;
}
.sample td.cast .letter {
color: var(--ember);
}
.sample .note {
display: block;
font-size: 0.65rem;
font-style: italic;
color: var(--ember);
}
.caption {
font-size: 0.85rem;
color: var(--bone-dim);
margin-top: 0.5rem;
text-align: center;
}
/* the games ledger */
.ledger {
margin-top: 1.4rem;
grid-area: ledger;
border-top: 1px solid var(--rule-strong);
padding-top: 0.5rem;
max-width: 40em;
}
.ledger-head {
@@ -222,6 +409,7 @@
.ledger-code {
font-weight: 600;
letter-spacing: 0.12em;
min-width: 3.5em;
}
.ledger-info {
@@ -245,7 +433,9 @@
}
.about {
margin-top: 1.4rem;
grid-area: about;
border-top: 1px solid var(--rule);
padding-top: 0.6rem;
}
.about summary {
@@ -255,9 +445,9 @@
}
.about-body {
max-width: 40em;
max-width: 44em;
color: var(--bone-dim);
padding: 0.6rem 0 0.4rem;
padding: 0.6rem 0 0.2rem;
}
.about-body p + p {
+5
View File
@@ -827,3 +827,8 @@ export const duel = new Duel();
export function playerName(): string {
return readName();
}
/** Keep a chosen name for every duel to come; the local duel takes it at once. */
export function rememberName(raw: string): void {
duel.setName(raw);
}
+21 -188
View File
@@ -1,204 +1,37 @@
<script lang="ts">
import { goto } from '$app/navigation';
import Board from '$lib/components/Board.svelte';
import Hall from '$lib/components/Hall.svelte';
import { COMPACT_QUERY, duel, NAME_MAX } from '$lib/game/duel.svelte';
let confirmingNew = $state(false);
let renaming = $state(false);
let nameDraft = $state('');
function startRename() {
nameDraft = duel.you.name;
renaming = true;
}
function saveName(e: SubmitEvent) {
e.preventDefault();
duel.setName(nameDraft);
renaming = false;
}
function startNew() {
if (duel.inProgress && !confirmingNew) {
confirmingNew = true;
return;
}
confirmingNew = false;
duel.newGame();
}
$effect(() => {
duel.persist();
});
// The store's idea of a narrow screen must agree with the stylesheets' breakpoint.
$effect(() => {
const query = window.matchMedia(COMPACT_QUERY);
const apply = () => (duel.compact = query.matches);
apply();
query.addEventListener('change', apply);
return () => query.removeEventListener('change', apply);
});
</script>
<svelte:head>
<title>Waving Hands</title>
</svelte:head>
<header class="masthead">
<div>
<h1>Waving Hands</h1>
{#if renaming}
<form class="rename" onsubmit={saveName}>
<label class="field">
<span>Your name</span>
<!-- svelte-ignore a11y_autofocus -->
<input type="text" bind:value={nameDraft} maxlength={NAME_MAX} autofocus autocomplete="nickname" placeholder="Leave empty for a drawn name" />
</label>
<button type="submit" class="reveal small">Save</button>
<button type="button" class="quiet" onclick={() => (renaming = false)}>Cancel</button>
</form>
{:else}
<p class="standfirst">
You are <em>{duel.you.name}</em><button type="button" class="link" onclick={startRename}>change name</button>, duelling <em>{duel.foe.name}</em>.
{#if duel.state.turn === 0}Each turn, both wizards choose one gesture per hand and reveal them together. The right run of gestures on one hand is a spell.{/if}
</p>
{/if}
</div>
<nav class="actions">
<a class="quiet" href="/rules">Rules</a>
<button type="button" class="quiet" onclick={startNew}>New duel</button>
<a class="quiet" href="#hall">Duel a friend</a>
</nav>
</header>
{#if confirmingNew}
<div class="confirm" role="alertdialog" aria-labelledby="confirm-heading">
<p id="confirm-heading">Abandon the duel against {duel.foe.name}? Turn {duel.turnNumber} will be lost.</p>
<div class="choices">
<button type="button" class="reveal small" onclick={startNew}>Start a new duel</button>
<button type="button" class="quiet" onclick={() => (confirmingNew = false)}>Keep playing</button>
</div>
</div>
{/if}
<Board {duel} />
<Hall name={duel.you.name} />
<footer class="colophon">
<p>After Richard Bartle's <em>Waving Hands</em> (1977), also known as Spellbinder and Spellcaster. <a href="/rules">Read the full rules</a>.</p>
</footer>
<main class="hall-page">
<Hall />
<footer class="colophon">
<p>After Richard Bartle's <em>Waving Hands</em> (1977), also known as Spellbinder and Spellcaster. <a href="/rules">Read the full rules</a>.</p>
</footer>
</main>
<style>
.masthead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem var(--gutter) 0.6rem;
max-width: 1280px;
margin: 0 auto;
}
h1 {
font-size: clamp(2rem, 4vw, 2.7rem);
font-weight: 300;
font-variation-settings: 'opsz' 144;
letter-spacing: -0.01em;
}
.standfirst {
max-width: 38em;
color: var(--bone-dim);
margin-top: 0.4rem;
}
.standfirst em {
color: var(--bone);
}
.standfirst .link {
margin-left: 0.5em;
font-size: 0.8rem;
}
.rename {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin-top: 0.4rem;
}
.rename input {
width: 14em;
}
.actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.quiet {
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.4rem 0.9rem;
white-space: nowrap;
color: var(--bone);
text-decoration: none;
line-height: 1.55;
}
.quiet:hover {
border-color: var(--bone);
}
.confirm {
max-width: 1280px;
margin: 0 auto 0.75rem;
padding: 0.75rem var(--gutter);
}
.confirm p {
color: var(--blood);
margin-bottom: 0.5rem;
}
.choices {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.reveal.small {
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
.hall-page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: clamp(1rem, 4vh, 3rem) var(--gutter) 2rem;
}
.colophon {
max-width: 1280px;
margin: 0 auto;
padding: 2rem var(--gutter) 3rem;
color: var(--bone-faint);
font-size: 0.85rem;
}
max-width: 54rem;
width: 100%;
padding: 1.5rem 0 0;
color: var(--bone-faint);
font-size: 0.85rem;
text-align: center;
}
.colophon a {
color: var(--frost);
}
/* Must match COMPACT_QUERY in duel.svelte.ts. */
@media (max-width: 860px) {
.colophon {
padding-bottom: 6rem;
}
.masthead {
flex-direction: column;
}
color: var(--frost);
}
</style>
+201
View File
@@ -0,0 +1,201 @@
<script lang="ts">
import { goto } from '$app/navigation';
import Board from '$lib/components/Board.svelte';
import { COMPACT_QUERY, duel, NAME_MAX } from '$lib/game/duel.svelte';
let confirmingNew = $state(false);
let renaming = $state(false);
let nameDraft = $state('');
function startRename() {
nameDraft = duel.you.name;
renaming = true;
}
function saveName(e: SubmitEvent) {
e.preventDefault();
duel.setName(nameDraft);
renaming = false;
}
function startNew() {
if (duel.inProgress && !confirmingNew) {
confirmingNew = true;
return;
}
confirmingNew = false;
duel.newGame();
}
$effect(() => {
duel.persist();
});
// The store's idea of a narrow screen must agree with the stylesheets' breakpoint.
$effect(() => {
const query = window.matchMedia(COMPACT_QUERY);
const apply = () => (duel.compact = query.matches);
apply();
query.addEventListener('change', apply);
return () => query.removeEventListener('change', apply);
});
</script>
<svelte:head>
<title>Waving Hands: against the bot</title>
</svelte:head>
<header class="masthead">
<div>
<h1>Waving Hands</h1>
{#if renaming}
<form class="rename" onsubmit={saveName}>
<label class="field">
<span>Your name</span>
<!-- svelte-ignore a11y_autofocus -->
<input type="text" bind:value={nameDraft} maxlength={NAME_MAX} autofocus autocomplete="nickname" placeholder="Leave empty for a drawn name" />
</label>
<button type="submit" class="reveal small">Save</button>
<button type="button" class="quiet" onclick={() => (renaming = false)}>Cancel</button>
</form>
{:else}
<p class="standfirst">
You are <em>{duel.you.name}</em><button type="button" class="link" onclick={startRename}>change name</button>, duelling <em>{duel.foe.name}</em>.
{#if duel.state.turn === 0}Each turn, both wizards choose one gesture per hand and reveal them together. The right run of gestures on one hand is a spell.{/if}
</p>
{/if}
</div>
<nav class="actions">
<a class="quiet" href="/rules">Rules</a>
<button type="button" class="quiet" onclick={startNew}>New duel</button>
<a class="quiet" href="/">The hall</a>
</nav>
</header>
{#if confirmingNew}
<div class="confirm" role="alertdialog" aria-labelledby="confirm-heading">
<p id="confirm-heading">Abandon the duel against {duel.foe.name}? Turn {duel.turnNumber} will be lost.</p>
<div class="choices">
<button type="button" class="reveal small" onclick={startNew}>Start a new duel</button>
<button type="button" class="quiet" onclick={() => (confirmingNew = false)}>Keep playing</button>
</div>
</div>
{/if}
<Board {duel} />
<footer class="colophon">
<p>After Richard Bartle's <em>Waving Hands</em> (1977), also known as Spellbinder and Spellcaster. <a href="/rules">Read the full rules</a>.</p>
</footer>
<style>
.masthead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem var(--gutter) 0.6rem;
max-width: 1280px;
margin: 0 auto;
}
h1 {
font-size: clamp(2rem, 4vw, 2.7rem);
font-weight: 300;
font-variation-settings: 'opsz' 144;
letter-spacing: -0.01em;
}
.standfirst {
max-width: 38em;
color: var(--bone-dim);
margin-top: 0.4rem;
}
.standfirst em {
color: var(--bone);
}
.standfirst .link {
margin-left: 0.5em;
font-size: 0.8rem;
}
.rename {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin-top: 0.4rem;
}
.rename input {
width: 14em;
}
.actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.quiet {
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.4rem 0.9rem;
white-space: nowrap;
color: var(--bone);
text-decoration: none;
line-height: 1.55;
}
.quiet:hover {
border-color: var(--bone);
}
.confirm {
max-width: 1280px;
margin: 0 auto 0.75rem;
padding: 0.75rem var(--gutter);
}
.confirm p {
color: var(--blood);
margin-bottom: 0.5rem;
}
.choices {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.reveal.small {
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
.colophon {
max-width: 1280px;
margin: 0 auto;
padding: 2rem var(--gutter) 3rem;
color: var(--bone-faint);
font-size: 0.85rem;
}
.colophon a {
color: var(--frost);
}
/* Must match COMPACT_QUERY in duel.svelte.ts. */
@media (max-width: 860px) {
.colophon {
padding-bottom: 6rem;
}
.masthead {
flex-direction: column;
}
}
</style>