Waving Hands: a browser duel after Bartle's 1977 game

SvelteKit 5 single-player version against a heuristic bot. The engine
resolves all forty spells simultaneously as the rules describe, the
ledger of gestures is the interface, and the duel is saved in the
browser. Tester feedback rounds added per-hand planning, threat
evidence, a result strip with one-time rule explanations, a phone
layout and a structured rules page. Deploys as a static site behind
Caddy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-09-22 15:33:29 -04:00
co-authored by Claude Fable 5.1
commit 865555e911
38 changed files with 8112 additions and 0 deletions
+261
View File
@@ -0,0 +1,261 @@
<script lang="ts">
import { FOE, YOU, type Duel } from '$lib/game/duel.svelte';
import { glyph } from '$lib/game/glyphs';
import { SPELL_BY_ID, type Hand } from '$lib/game/spells';
import type { HistoryEntry, WizardId } from '$lib/game/state';
let { duel }: { duel: Duel } = $props();
let scroller: HTMLDivElement | undefined = $state();
interface Placed {
entry: HistoryEntry;
index: number;
hidden: boolean;
}
interface Row {
key: string;
label: string;
note?: string;
cells: Record<WizardId, Placed[]>;
}
function placed(who: WizardId, turn: number, phases: string[]): Placed[] {
const history = who === YOU ? duel.you.history : duel.foeVisible;
const raw = duel.state.wizards[who].history;
const out: Placed[] = [];
history.forEach((entry, index) => {
if (entry.turn === turn && phases.includes(entry.phase)) {
out.push({ entry, index, hidden: raw[index].hiddenFrom.includes(YOU) });
}
});
return out;
}
const rows = $derived.by(() => {
const out: Row[] = [];
for (let t = 0; t < duel.state.turn; t++) {
out.push({ key: `t${t}`, label: String(t + 1), cells: { A: placed(YOU, t, ['main', 'haste']), B: placed(FOE, t, ['main', 'haste']) } });
for (const who of [YOU, FOE] as WizardId[]) {
for (const p of placed(who, t, ['timestop'])) {
out.push({ key: `t${t}-${who}-${p.index}`, label: '', note: 'time stop', cells: { A: who === YOU ? [p] : [], B: who === FOE ? [p] : [] } });
}
}
}
return out;
});
const drafting = $derived(!duel.state.over);
const turns = $derived(duel.you.history.length + duel.foe.history.length);
function notesFor(who: WizardId, index: number, hand: Hand): string[] {
return duel.state.wizards[who].casts
.filter((c) => c.index === index && c.hand === hand)
.map((c) => SPELL_BY_ID[c.spellId].name);
}
$effect(() => {
void turns;
if (scroller) scroller.scrollTop = scroller.scrollHeight;
});
</script>
<div class="scroller" bind:this={scroller}>
<table class="ledger">
<thead>
<tr>
<th class="turn" scope="col"><span class="visually-hidden">Turn</span></th>
<th class="you" colspan="2" scope="colgroup">{duel.you.name} (you)</th>
<th class="foe" colspan="2" scope="colgroup">{duel.foe.name}</th>
</tr>
<tr class="hands">
<th></th>
<th scope="col">left</th>
<th scope="col">right</th>
<th scope="col">left</th>
<th scope="col">right</th>
</tr>
</thead>
<tbody>
{#each rows as row (row.key)}
<tr class:stopped={!!row.note}>
<td class="turn">{row.label}{#if row.note}<span class="rownote">{row.note}</span>{/if}</td>
{#each [YOU, FOE] as const as who (who)}
{#each ['left', 'right'] as const as hand (hand)}
<td class="cell" class:you={who === YOU} class:foe={who === FOE}>
{#each row.cells[who] as p (p.index)}
{@const lit = who === FOE && duel.highlight?.hand === hand && p.index >= duel.highlight.from && p.index <= duel.highlight.to}
{@const notes = notesFor(who, p.index, hand)}
{@const other = hand === 'left' ? p.entry.right : p.entry.left}
<span class="entry" class:cast={notes.length > 0} class:hidden={p.hidden} class:haste={p.entry.phase === 'haste'} class:lit>
<span class="letter">{p.hidden ? '?' : glyph(p.entry[hand], other)}</span>
{#if notes.length}<span class="note">{notes.join(', ')}</span>{/if}
</span>
{/each}
</td>
{/each}
{/each}
</tr>
{/each}
{#if drafting}
<tr class="draft" class:stopped={duel.timeStopped}>
<td class="turn">{duel.timeStopped ? '' : duel.turnNumber}{#if duel.timeStopped}<span class="rownote">time stop</span>{/if}</td>
{#each ['left', 'right'] as const as hand (hand)}
<td class="cell you">
{#each duel.activeSets as set (set)}
{@const chosen = duel.forcedFor(set, hand) ?? duel.sets[set][hand].gesture}
{@const charmed = duel.charmedHand === hand}
{@const cast = duel.chosen[set][hand]}
<span class="entry" class:cast={!!cast} class:haste={set === 'haste'}>
<span class="letter" class:placeholder={chosen === null || charmed}>
{charmed ? '?' : chosen === null ? '·' : glyph(chosen, duel.gestureFor(set, hand === 'left' ? 'right' : 'left'))}
</span>
{#if cast && duel.sets[set][hand].spellId}<span class="note">{cast.spell.name}</span>{/if}
</span>
{/each}
</td>
{/each}
<td class="cell foe">{#if !duel.timeStopped}<span class="letter placeholder">?</span>{/if}</td>
<td class="cell foe">{#if !duel.timeStopped}<span class="letter placeholder">?</span>{/if}</td>
</tr>
{/if}
</tbody>
</table>
</div>
<style>
.scroller {
max-height: clamp(170px, 30vh, 400px);
overflow-y: auto;
border-top: 1px solid var(--rule-strong);
border-bottom: 1px solid var(--rule-strong);
}
.ledger {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
thead th {
position: sticky;
top: 0;
background: var(--slate-deep);
font-weight: 500;
text-align: center;
padding: 0.5rem 0 0.15rem;
font-size: 1.05rem;
z-index: 1;
}
thead th.you,
thead th.foe {
font-style: italic;
font-size: 1.15rem;
}
thead tr.hands th {
top: 2.2rem;
font-size: 0.8rem;
font-weight: 400;
color: var(--bone-dim);
padding: 0 0 0.35rem;
border-bottom: 1px solid var(--rule-strong);
}
th.turn,
td.turn {
width: 2.9rem;
color: var(--bone-faint);
text-align: right;
padding-right: 0.5rem;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
vertical-align: top;
padding-top: 0.6rem;
}
.rownote {
display: block;
font-size: 0.6rem;
font-style: italic;
line-height: 1.1;
color: var(--frost);
}
td.cell {
text-align: center;
vertical-align: top;
padding: 0.35rem 0.2rem 0.3rem;
border-bottom: 1px solid var(--rule);
line-height: 1.1;
}
tr.stopped td.cell {
background: rgba(140, 200, 224, 0.06);
}
td.cell.you:nth-child(3) {
border-right: 1px solid var(--rule-strong);
}
.entry {
display: block;
}
.entry.haste .letter {
font-size: clamp(1.1rem, 2.4vw, 1.4rem);
color: var(--frost);
}
.letter {
display: block;
font-size: clamp(1.6rem, 3.5vw, 2.1rem);
color: var(--bone);
}
.entry.cast .letter {
color: var(--ember);
}
.entry.hidden .letter {
color: var(--bone-faint);
}
.entry.lit {
background: rgba(140, 200, 224, 0.18);
outline: 1px solid var(--frost);
border-radius: 3px;
}
.entry.lit .letter {
color: var(--frost);
}
.placeholder {
color: var(--bone-faint);
}
.note {
display: block;
font-size: 0.72rem;
font-style: italic;
color: var(--ember);
line-height: 1.15;
margin-top: 0.15rem;
}
tr.draft td.cell {
background: var(--slate);
border-bottom: 2px solid var(--ember);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
</style>