Coordinates on the board, moves in the record shown on the board, a status strip above it with the capture in words, notes under the variant selector and a still that follows it, keyboard play, and a way back to the game from the rules

From a tester's review.

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-23 18:48:53 -04:00
co-authored by Claude Fable 5.1
parent bac7182915
commit 5db89edbc1
7 changed files with 329 additions and 107 deletions
+41 -17
View File
@@ -15,6 +15,16 @@
let code = $state('');
/** The host's choices for the next table, from the game's declared options. */
let options = $state<Record<string, string>>(Object.fromEntries((game.options ?? []).map((o) => [o.key, o.default])));
/** The hall's still of the opening position, on whichever board the selector names. */
const still = $derived(
options.set === 'tablut'
? { n: 9, wood: '#8d8577', line: 'rgba(40,34,28,0.5)', dark: '#3d3530', darkRim: '#1c1714', pale: '#cfc4ad', paleRim: '#8c8069', king: '#c9b27a', kingRim: '#7d6636', corners: false,
a: [[3,0],[4,0],[5,0],[4,1],[0,3],[8,3],[0,4],[1,4],[7,4],[8,4],[0,5],[8,5],[4,7],[3,8],[4,8],[5,8]], d: [[4,2],[4,3],[2,4],[3,4],[5,4],[6,4],[4,5],[4,6]] }
: { n: 11, wood: '#8a5a2b', line: 'rgba(30,16,6,0.5)', dark: '#2a2320', darkRim: '#0d0a08', pale: '#ece4d0', paleRim: '#b9ab8b', king: '#f0d58a', kingRim: '#a67c1a', corners: true,
a: [[3,0],[4,0],[5,0],[6,0],[7,0],[5,1],[0,3],[10,3],[0,4],[10,4],[0,5],[1,5],[9,5],[10,5],[0,6],[10,6],[0,7],[10,7],[5,9],[3,10],[4,10],[5,10],[6,10],[7,10]], d: [[5,3],[4,4],[5,4],[6,4],[3,5],[4,5],[6,5],[7,5],[4,6],[5,6],[6,6],[5,7]] }
);
const mid = $derived((still.n - 1) / 2);
let busy = $state(false);
let error = $state('');
let seats = $state(allSeats());
@@ -156,7 +166,7 @@
<div class="lid-head">
<h1>Hnefatafl</h1>
<p class="pitch">The Viking board game. One king, one throne, four corners, and a ring of attackers closing in.</p>
<p class="tag">Hnefatafl was played across the Norse world for a thousand years before chess. Every piece moves like a rook and is taken by being sandwiched; the attackers must capture the king, the king must reach a corner. Play a housecarl, or open a table for a friend and play by turns. Copenhagen rules on the big board, or Tablut, the older game Linnaeus wrote down in 1732.</p>
<p class="tag">Hnefatafl was played across the Norse world for a thousand years before chess. Every piece moves like a rook and is taken by being sandwiched; the attackers must capture the king, the king must reach safety. Play a housecarl, or open a table for a friend and play by turns. Copenhagen rules on the big board, or Tablut, the older game Linnaeus wrote down in 1732.</p>
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game a labor of love</a></p>
</div>
@@ -168,12 +178,16 @@
{#if game.options?.length}
<div class="options">
{#each game.options as o (o.key)}
<label class="option">
<span>{o.label}</span>
<select bind:value={options[o.key]}>
{#each o.choices as c (c.value)}<option value={c.value}>{c.label}</option>{/each}
</select>
</label>
{@const chosen = o.choices.find((c) => c.value === options[o.key])}
<div class="option-block">
<label class="option">
<span>{o.label}</span>
<select bind:value={options[o.key]}>
{#each o.choices as c (c.value)}<option value={c.value}>{c.label}</option>{/each}
</select>
</label>
{#if chosen?.note}<p class="option-note muted small">{chosen.note}</p>{/if}
</div>
{/each}
</div>
{/if}
@@ -196,20 +210,20 @@
</div>
<div class="lid-look" aria-hidden="true">
<svg class="still" viewBox="0 0 110 110" aria-hidden="true">
<rect x="0" y="0" width="110" height="110" rx="4" fill="#8a5a2b" />
{#each Array.from({ length: 11 }, (_, r) => r) as r (r)}
{#each Array.from({ length: 11 }, (_, c) => c) as c (c)}
<rect x={c * 10} y={r * 10} width="10" height="10" fill={(r === 0 || r === 10) && (c === 0 || c === 10) ? 'rgba(224,101,92,0.4)' : r === 5 && c === 5 ? 'rgba(240,165,58,0.4)' : 'rgba(255,240,210,0.08)'} stroke="rgba(30,16,6,0.5)" stroke-width="0.4" />
<svg class="still" class:worn={options.set === 'tablut'} viewBox="0 0 {still.n * 10} {still.n * 10}" aria-hidden="true">
<rect x="0" y="0" width={still.n * 10} height={still.n * 10} rx="4" fill={still.wood} />
{#each Array.from({ length: still.n }, (_, r) => r) as r (r)}
{#each Array.from({ length: still.n }, (_, c) => c) as c (c)}
<rect x={c * 10} y={r * 10} width="10" height="10" fill={still.corners && (r === 0 || r === still.n - 1) && (c === 0 || c === still.n - 1) ? 'rgba(224,101,92,0.4)' : r === mid && c === mid ? 'rgba(240,165,58,0.4)' : 'rgba(255,240,210,0.08)'} stroke={still.line} stroke-width="0.4" />
{/each}
{/each}
{#each [[3,0],[4,0],[5,0],[6,0],[7,0],[5,1],[0,3],[10,3],[0,4],[10,4],[0,5],[1,5],[9,5],[10,5],[0,6],[10,6],[0,7],[10,7],[5,9],[3,10],[4,10],[5,10],[6,10],[7,10]] as [x, y] (`${x},${y}`)}
<circle cx={x * 10 + 5} cy={y * 10 + 5} r="3.4" fill="#2a2320" stroke="#0d0a08" stroke-width="0.6" />
{#each still.a as [x, y] (`${x},${y}`)}
<circle cx={x * 10 + 5} cy={y * 10 + 5} r="3.4" fill={still.dark} stroke={still.darkRim} stroke-width="0.6" />
{/each}
{#each [[5,3],[4,4],[5,4],[6,4],[3,5],[4,5],[6,5],[7,5],[4,6],[5,6],[6,6],[5,7]] as [x, y] (`${x},${y}`)}
<circle cx={x * 10 + 5} cy={y * 10 + 5} r="3.4" fill="#ece4d0" stroke="#b9ab8b" stroke-width="0.6" />
{#each still.d as [x, y] (`${x},${y}`)}
<circle cx={x * 10 + 5} cy={y * 10 + 5} r="3.4" fill={still.pale} stroke={still.paleRim} stroke-width="0.6" />
{/each}
<circle cx="55" cy="55" r="3.6" fill="#f0d58a" stroke="#a67c1a" stroke-width="0.6" />
<circle cx={mid * 10 + 5} cy={mid * 10 + 5} r="3.6" fill={still.king} stroke={still.kingRim} stroke-width="0.6" />
</svg>
</div>
@@ -609,12 +623,22 @@
gap: 0.5rem;
font-size: 0.9rem;
}
.option-note {
margin: 0.2rem 0 0;
max-width: 22em;
}
.still {
width: 100%;
max-width: 18rem;
display: block;
margin: 0 auto;
border-radius: 4px;
transition: filter 0.9s ease;
}
.still.worn {
filter: contrast(0.85) sepia(0.15);
}
.tally {
display: grid;