A table for a dozen wizards, begun by its host

Rooms take up to twelve seats and the host begins the duel with whoever
is seated; a full table begins by itself. The lobby is a roster with a
bot seat and the host's begin button. The ledger pins the turn and your
own columns and scrolls sideways to the rest; the phone bar shows every
wizard's health; a wizard who is out watches the rest; each charmed
wizard gets their own gesture; an unnamed spell goes at whoever hurt you
last, else the weakest opponent; a stab has a target from the start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric Wagoner
2026-09-23 09:34:21 -04:00
co-authored by Claude Fable 5.1
parent 79947e31ba
commit 58d7db106e
15 changed files with 299 additions and 61 deletions
+13 -8
View File
@@ -6,7 +6,7 @@
import SpellSheet from './SpellSheet.svelte';
import TurnSummary from './TurnSummary.svelte';
import WizardStatus from './WizardStatus.svelte';
import { castKey, tokensText, type Duel } from '$lib/game/duel.svelte';
import { castKey, listText, tokensText, type Duel } from '$lib/game/duel.svelte';
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
import { MAGIC_GESTURES } from '$lib/game/spells';
@@ -15,8 +15,6 @@
const foeMonsters = $derived(duel.state.monsters.filter((m) => m.owner !== duel.me));
const outcome = $derived(duel.state.over);
const won = $derived(outcome?.winner === duel.me);
const charmedName = $derived(duel.charmedFoe ? duel.state.wizards[duel.charmedFoe].name : '');
const charmedHandName = $derived(duel.charmedFoe ? duel.state.wizards[duel.charmedFoe].constraints.charmed?.hand : '');
</script>
<main class="board">
@@ -35,6 +33,12 @@
{/if}
</div>
{:else}
{#if duel.out}
<section class="move out" aria-labelledby="move-heading">
<h2 id="move-heading">{duel.you.alive ? 'You have yielded' : 'You have fallen'}</h2>
<p class="threats muted">The duel goes on without you. Watch the ledger fill; {listText(duel.others.filter((id) => duel.state.wizards[id].alive && !duel.state.wizards[id].surrendered).map((id) => duel.state.wizards[id].name))} still stand.</p>
</section>
{:else}
<section class="move" aria-labelledby="move-heading">
{#if duel.timeStopped}
<h2 id="move-heading">Time stands still. Take your extra turn.</h2>
@@ -162,23 +166,23 @@
</select>
</label>
{/if}
{#if duel.youCharmedFoe}
{#each duel.charmedFoes as id (id)}
<label class="field">
<span>{charmedName}'s charmed {charmedHandName} hand makes</span>
<select bind:value={duel.charmGesture}>
<span>{duel.state.wizards[id].name}'s charmed {duel.state.wizards[id].constraints.charmed?.hand} hand makes</span>
<select bind:value={duel.charmGestures[id]}>
{#each MAGIC_GESTURES as g (g)}
<option value={g}>{glyph(g)} {GESTURE_SHORT[g]}</option>
{/each}
</select>
</label>
{/if}
{/each}
</div>
{#if duel.remote?.error}
<p class="warning">{duel.remote.error}</p>
{/if}
{#if duel.moved}
<p class="waiting">Your move is in. Waiting for {duel.awaiting.join(' and ')}.</p>
<p class="waiting">Your move is in. Waiting for {duel.awaitingText}.</p>
{/if}
{#if duel.surrendering}
<p class="warning">Both palms at once is surrender. You will lose the duel.</p>
@@ -191,6 +195,7 @@
{duel.moved ? 'Waiting' : duel.sending ? 'Sending' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act in the stopped moment' : 'Reveal gestures'}
</button>
</section>
{/if}
{/if}
</section>
+3 -3
View File
@@ -55,7 +55,7 @@
error = '';
try {
rememberName(name);
const remote = await Duel.createRoom(name.trim(), 2);
const remote = await Duel.createRoom(name.trim());
await goto(`/join/${remote.roomId}`);
} catch (e) {
error = e instanceof Error ? e.message : 'The duel could not be opened.';
@@ -110,7 +110,7 @@
<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>
<p class="tag">Richard Bartle's 1977 pencil-and-paper duel, free in your browser. Each turn every wizard chooses a gesture per hand and all reveal together; a Fireball is F-S-S-D-D on one hand, a Shield is a single palm. Play the bot, or open a table for up to a dozen wizards and play by turns.</p>
<a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game — a labor of love</a>
</div>
@@ -128,7 +128,7 @@
{/if}
</p>
<div class="ways">
<button type="button" class="quiet" disabled={!ready || busy} onclick={create}>Create a duel</button>
<button type="button" class="quiet" disabled={!ready || busy} onclick={create}>Open a table</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" />
+53 -8
View File
@@ -79,14 +79,14 @@
<tr>
<th class="turn" scope="col"><span class="visually-hidden">Turn</span></th>
{#each columns as who (who)}
<th class:you={who === duel.me} class:foe={who !== duel.me} colspan="2" scope="colgroup">{duel.state.wizards[who].name}{#if who === duel.me}&nbsp;(you){/if}</th>
<th class:you={who === duel.me} class:foe={who !== duel.me} class:gone={!duel.state.wizards[who].alive || duel.state.wizards[who].surrendered} colspan="2" scope="colgroup">{duel.state.wizards[who].name}{#if who === duel.me}&nbsp;(you){/if}</th>
{/each}
</tr>
<tr class="hands">
<th></th>
{#each columns as who (who)}
<th scope="col">left</th>
<th scope="col">right</th>
<th scope="col" class:pin-left={who === duel.me}>left</th>
<th scope="col" class:pin-right={who === duel.me}>right</th>
{/each}
</tr>
</thead>
@@ -96,7 +96,7 @@
<td class="turn">{row.label}{#if row.note}<span class="rownote">{row.note}</span>{/if}</td>
{#each columns as who (who)}
{#each ['left', 'right'] as const as hand (hand)}
<td class="cell" class:you={who === duel.me} class:foe={who !== duel.me}>
<td class="cell" class:you={who === duel.me} class:foe={who !== duel.me} class:pin-left={who === duel.me && hand === 'left'} class:pin-right={who === duel.me && hand === 'right'}>
{#each row.cells[who] as p (p.index)}
{@const notes = notesFor(who, p.index, hand)}
{@const other = hand === 'left' ? p.entry.right : p.entry.left}
@@ -114,7 +114,7 @@
<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">
<td class="cell you" class:pin-left={hand === 'left'} class:pin-right={hand === 'right'}>
{#each duel.activeSets as set (set)}
{@const undecided = duel.chosenGesture(set, hand) === null || duel.charmedHand === hand}
{@const cast = duel.chosen[set][hand]}
@@ -138,17 +138,53 @@
<style>
.scroller {
max-height: clamp(170px, 30vh, 400px);
overflow-y: auto;
overflow: auto;
border-top: 1px solid var(--rule-strong);
border-bottom: 1px solid var(--rule-strong);
}
/* With many wizards the table grows sideways; the turn and your own two columns stay pinned on the left. */
.ledger {
width: 100%;
border-collapse: collapse;
min-width: 100%;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
}
.ledger th,
.ledger td {
min-width: 4.5rem;
}
th.pin-left,
td.pin-left,
th.pin-right,
td.pin-right {
position: sticky;
z-index: 1;
background: var(--slate-deep);
}
th.pin-left,
td.pin-left {
left: 2.9rem;
}
th.pin-right,
td.pin-right {
left: 7.4rem;
}
thead th.pin-left,
thead th.pin-right {
z-index: 3;
}
thead th.gone {
text-decoration: line-through;
color: var(--bone-dim);
}
thead th {
position: sticky;
top: 0;
@@ -177,7 +213,12 @@
th.turn,
td.turn {
position: sticky;
left: 0;
z-index: 2;
background: var(--slate-deep);
width: 2.9rem;
min-width: 2.9rem;
color: var(--bone-faint);
text-align: right;
padding-right: 0.5rem;
@@ -266,4 +307,8 @@
border-bottom: 2px solid var(--ember);
}
tr.draft td.turn {
background: var(--slate);
}
</style>
+18 -3
View File
@@ -8,9 +8,12 @@
{#if !duel.state.over}
<div class="bar">
<div class="health">
<div class="health" class:many={duel.others.length > 1}>
<span><em>{duel.you.name}</em> {duel.you.hp}/{MAX_HP}</span>
<span><em>{duel.foe.name}</em> {duel.foe.hp}/{MAX_HP}</span>
{#each duel.others as id (id)}
{@const w = duel.state.wizards[id]}
<span class:gone={!w.alive || w.surrendered}><em>{w.name}</em> {w.hp}/{MAX_HP}</span>
{/each}
</div>
<div class="gestures" aria-label="Chosen gestures">
{#each HANDS as hand (hand)}
@@ -18,7 +21,7 @@
{/each}
</div>
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
{duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Reveal'}
{duel.out ? 'Out' : duel.moved ? 'Waiting' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Reveal'}
</button>
</div>
{/if}
@@ -55,10 +58,22 @@
line-height: 1.3;
}
.health.many {
flex-direction: row;
flex-wrap: wrap;
gap: 0 0.6rem;
font-size: 0.72rem;
}
.health em {
color: var(--bone);
}
.health .gone {
text-decoration: line-through;
opacity: 0.6;
}
.gestures {
display: flex;
gap: 0.5rem;
+1 -1
View File
@@ -42,7 +42,7 @@
onclick={() => duel.follow(hand, plan)}
>
<span class="which">Plan with {hand} hand</span>
<span class="rest">{#if plan.done.length}<span class="done">{tokensText(plan.done)}</span> then {/if}{tokensText(plan.remaining)} <span class="count">({plan.remaining.length} more)</span></span>
<span class="rest">{#if plan.done.length}<span class="done">{tokensText(plan.done)}</span>&nbsp;then&nbsp;{/if}{tokensText(plan.remaining)} <span class="count">({plan.remaining.length} more)</span></span>
</button>
{/each}
</div>