Duels between people: rooms, the hall, and a games ledger

The store plays any seat, locally against the bot or remotely through
the server. A room has a four-letter code that is also its invite link;
the hall opens or joins one and lists the seats this browser holds with
whose move it is. The ledger and summary show every seat, threats name
their wizard, and the board is one component shared by the hall and the
room page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-09-22 17:07:51 -04:00
co-authored by Claude Fable 5.1
parent 8366c9d7ff
commit e1bda87987
10 changed files with 1430 additions and 524 deletions
+274
View File
@@ -0,0 +1,274 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { allSeats, api, forgetSeat } from '$lib/game/client';
import { Duel } from '$lib/game/duel.svelte';
import type { RoomView } from '$lib/game/view';
let { name }: { name: string } = $props();
let code = $state('');
let busy = $state(false);
let error = $state('');
let seats = $state(allSeats());
let games = $state<Record<string, RoomView | null>>({});
// Each held seat is looked up once, so the ledger can say whose move it is.
$effect(() => {
for (const held of seats) {
if (held.roomId in games) continue;
games[held.roomId] = null;
api.view(held.roomId, held.token)
.then((v) => {
games[held.roomId] = v;
})
.catch(() => {
games[held.roomId] = null;
});
}
});
async function create() {
busy = true;
error = '';
try {
const remote = await Duel.createRoom(name, 2);
await goto(`/join/${remote.roomId}`);
} catch (e) {
error = e instanceof Error ? e.message : 'The duel could not be opened.';
} finally {
busy = false;
}
}
function join(e: SubmitEvent) {
e.preventDefault();
const c = code.trim().toUpperCase();
if (c) void goto(`/join/${c}`);
}
function forget(roomId: string) {
forgetSeat(roomId);
seats = allSeats();
}
function status(held: { roomId: string; seat: string }): string {
const g = games[held.roomId];
if (g === undefined) return 'looking';
if (g === null) return 'unreachable';
const others = g.seats.filter((s) => s.id !== held.seat).map((s) => s.name);
if (!g.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`;
if (g.state.over) {
if (g.state.over.winner === held.seat) return 'you won';
if (g.state.over.winner === null) return 'a draw';
return `${g.state.wizards[g.state.over.winner].name} won`;
}
const turn = `turn ${g.state.turn + 1}`;
if (g.waitingOn.includes(held.seat)) return `your move · ${turn} · against ${others.join(', ')}`;
return `waiting on ${g.waitingOn.map((id) => g.state!.wizards[id].name).join(', ')} · ${turn}`;
}
function yourMove(held: { roomId: string; seat: string }): boolean {
const g = games[held.roomId];
return !!g?.state && !g.state.over && g.waitingOn.includes(held.seat);
}
</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>
</div>
{#if error}<p class="warning">{error}</p>{/if}
{#if seats.length}
<div class="ledger">
<div class="ledger-head">your duels</div>
{#each seats as held (held.roomId)}
<div class="ledger-row" class:your-move={yourMove(held)}>
<a class="ledger-resume" href={`/join/${held.roomId}`}>
<span class="ledger-code">{held.roomId}</span>
<span class="ledger-info">{status(held)}</span>
</a>
<button type="button" class="ledger-forget" title="forget this duel" onclick={() => forget(held.roomId)}>×</button>
</div>
{/each}
</div>
{/if}
<details class="about">
<summary>about this game — a labor of love</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>
</div>
</details>
</section>
<style>
.hall {
max-width: 1280px;
margin: 0 auto;
padding: 1.5rem var(--gutter) 0.5rem;
border-top: 1px solid var(--rule);
}
h2 {
font-size: 1.4rem;
margin-bottom: 0.3rem;
}
.pitch {
max-width: 38em;
color: var(--bone-dim);
margin-bottom: 0.9rem;
}
.ways {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem 1.25rem;
}
.reveal.small {
padding: 0.45rem 1rem;
font-size: 1rem;
}
.joinform {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.or {
color: var(--bone-dim);
font-style: italic;
}
.code {
width: 6.5em;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.15em;
font-weight: 600;
}
.quiet {
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.4rem 0.9rem;
color: var(--bone);
}
.quiet:hover:not(:disabled) {
border-color: var(--bone);
}
.warning {
color: var(--blood);
margin-top: 0.6rem;
}
/* the games ledger */
.ledger {
margin-top: 1.4rem;
border-top: 1px solid var(--rule-strong);
padding-top: 0.5rem;
max-width: 40em;
}
.ledger-head {
font-style: italic;
color: var(--bone-dim);
margin-bottom: 0.3rem;
}
.ledger-row {
display: flex;
align-items: center;
gap: 0.4rem;
border-radius: 4px;
padding: 0.15rem 0.3rem;
}
.ledger-row.your-move {
background: rgba(240, 165, 58, 0.12);
}
.ledger-resume {
flex: 1;
display: flex;
align-items: baseline;
gap: 0.7rem;
padding: 0.3rem 0.2rem;
color: var(--bone);
text-decoration: none;
font-size: 0.95rem;
}
.ledger-resume:hover .ledger-code {
text-decoration: underline;
}
.ledger-code {
font-weight: 600;
letter-spacing: 0.12em;
}
.ledger-info {
color: var(--bone-dim);
}
.ledger-row.your-move .ledger-info {
color: var(--ember);
}
.ledger-forget {
background: none;
border: 0;
color: var(--bone-faint);
font-size: 1.05rem;
padding: 0 0.3rem;
}
.ledger-forget:hover {
color: var(--blood);
}
.about {
margin-top: 1.4rem;
}
.about summary {
cursor: pointer;
font-style: italic;
color: var(--frost);
}
.about-body {
max-width: 40em;
color: var(--bone-dim);
padding: 0.6rem 0 0.4rem;
}
.about-body p + p {
margin-top: 0.6rem;
}
.about-body em {
color: var(--bone);
}
.about-body a {
color: var(--frost);
}
</style>