The guide keeps the room too, every cross-link carries it, and a move picked from the record brings the board into view on a phone

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 19:15:09 -04:00
co-authored by Claude Fable 5.1
parent c5d487edbf
commit 145667ea0d
4 changed files with 126 additions and 82 deletions
+114 -76
View File
@@ -3,15 +3,16 @@
// on them as tokens that slide and fade, and two taps or a keyboard to
// move. Above it, a strip says whose move it is and what the side is
// for; beside it, the record of moves, each one a click from being shown
// on the board. Copenhagen is fresh oak and clean stone; Tablut, the
// older game, is grey, bleached and a little uneven, by the tokens under
// data-set and a heavier grain and warp in the filters.
// on the board. Copenhagen is a newly crafted oak reenactment set;
// Tablut evokes an excavated stone board and worn counters. This is an
// artistic treatment, not a claim to reconstruct a particular find.
import TableTalk from './TableTalk.svelte';
import { beyond, cellOf, cornersOf, isEdge, movesFrom, rulesetOf, sideOf, throneOf, type Move, type Side } from '$lib/game';
import type { Room } from '$lib/net/room.svelte';
import { prefs, reducedMotion } from '$lib/prefs.svelte';
let { room }: { room: Room } = $props();
const artId = $props.id();
const g = $derived(room.state!);
const set = $derived(rulesetOf(g));
@@ -54,7 +55,7 @@
const chips = $derived.by(() => {
if (!worn) return [] as { x: number; y: number; r: number; d: string }[];
const spots = [
[1, 0, 3], [size - 1, 2, 0], [3, size - 1, 1], [size - 2, size - 2, 2], [0, size - 3, 3], [size - 4, 0, 1], [2, 4, 0]
[0, 0, 0], [size - 1, 2, 1], [3, size - 1, 2], [0, size - 3, 3], [size - 4, 0, 1]
] as const;
return spots.map(([cx, cy, corner]) => {
const x = PAD_L + cx * CELL + (corner === 1 || corner === 2 ? CELL : 0);
@@ -71,8 +72,8 @@
const w = PAD_L + size * CELL;
const h = PAD_T + size * CELL;
return [
`M${PAD_L + CELL * 0.6} ${PAD_T + 2} c 6 14, -4 22, 8 36 s 2 18, 14 26 s 4 10, 2 22`,
`M${w - 3} ${h - CELL * 2.4} c -12 4, -18 -6, -30 2 s -14 12, -26 8 s -12 -4, -18 6`
`M${PAD_L + 22} ${PAD_T} l3 11 -2 7 5 12 -1 9 4 8 -2 12 m-1 -29 -8 5 -3 7`,
`M${w} ${h - 62} l-12 3 -6 -2 -9 6 -7 1 -5 6 -10 3 m22 -10 -2 -7 -5 -4`
];
});
@@ -270,31 +271,55 @@
<div class="frame" class:calm>
<svg viewBox="0 0 {width} {height}" role="grid" aria-label="The board">
<defs>
<filter id="grain" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="7" result="noise" />
<feColorMatrix in="noise" type="saturate" values="0" result="grey" />
<feComponentTransfer in="grey" result="soft"><feFuncA type="linear" slope={worn ? 0.5 : 0.14} /></feComponentTransfer>
<linearGradient id={`${artId}-surface`} x2="0.85" y2="1">
<stop stop-color="var(--surface-light)" />
<stop offset="0.48" stop-color="var(--wood)" />
<stop offset="1" stop-color="var(--surface-dark)" />
</linearGradient>
<filter id={`${artId}-grain`} x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency={worn ? '0.48' : '0.025 0.5'} numOctaves="3" seed="7" result="noise" />
<feColorMatrix in="noise" type="saturate" values="0" />
<feComponentTransfer result="soft"><feFuncA type="linear" slope={worn ? 0.2 : 0.13} /></feComponentTransfer>
<feBlend in="SourceGraphic" in2="soft" mode="multiply" />
</filter>
<filter id="worn" x="-5%" y="-5%" width="110%" height="110%">
<feTurbulence type="fractalNoise" baseFrequency="0.07" numOctaves="2" seed="3" result="warp" />
<feDisplacementMap in="SourceGraphic" in2="warp" scale={worn ? 2.8 : 0} xChannelSelector="R" yChannelSelector="G" />
<filter id={`${artId}-worn`} x="-5%" y="-5%" width="110%" height="110%">
<feTurbulence type="fractalNoise" baseFrequency="0.09" numOctaves="2" seed="3" result="warp" />
<feDisplacementMap in="SourceGraphic" in2="warp" scale="0.65" xChannelSelector="R" yChannelSelector="G" />
</filter>
<filter id="mottle" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.018" numOctaves="3" seed="11" result="blotch" />
<feColorMatrix in="blotch" type="matrix" values="0 0 0 0 0.16 0 0 0 0 0.14 0 0 0 0 0.11 0 0 0 0.55 0" result="stain" />
<feBlend in="SourceGraphic" in2="stain" mode="multiply" />
<filter id={`${artId}-mottle`} x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.025" numOctaves="3" seed="11" />
<feColorMatrix type="matrix" values="0 0 0 0 0.20 0 0 0 0 0.17 0 0 0 0 0.12 0.28 0 0 0 0" />
<feComposite in2="SourceGraphic" operator="in" />
</filter>
{#each ['attacker', 'defender', 'king'] as kind (kind)}
<radialGradient id={`${artId}-${kind}`} cx="32%" cy="24%" r="80%">
<stop stop-color={`var(--${kind}-light)`} />
<stop offset="0.65" stop-color={`var(--${kind})`} />
<stop offset="1" stop-color={`var(--${kind}-rim)`} />
</radialGradient>
{/each}
</defs>
<rect class="wood" x="0" y="0" {width} {height} rx="6" filter="url(#grain)" />
<rect class="wood" x="0.7" y="0.7" width={width - 1.4} height={height - 1.4} rx={worn ? 3 : 6} fill={`url(#${artId}-surface)`} filter={`url(#${artId}-grain)`} />
<rect class="board-rim" x="2" y="2" width={width - 4} height={height - 4} rx={worn ? 2 : 5} />
{#if worn}
<rect class="stain" x="0" y="0" {width} {height} rx="6" filter="url(#mottle)" />
<rect class="stain" x="2" y="2" width={width - 4} height={height - 4} rx="3" filter={`url(#${artId}-mottle)`} />
{:else}
{#each [0.22, 0.51, 0.79] as part (part)}
<path class="join" d={`M${PAD_L + size * CELL * part} 3 v${height - 6}`} />
{/each}
{/if}
<g class="surface-wear" aria-hidden="true">
{#each chips as chip (chip.d)}<path class="chip" d={chip.d} />{/each}
{#each cracks as crack (crack)}
<path class="crack-light" d={crack} transform="translate(0.35 0.4)" />
<path class="crack" d={crack} />
{/each}
</g>
{#each showCoords ? Array.from({ length: size }, (_, n) => n) : [] as n (n)}
<text class="coord" x={PAD_L + n * CELL + CELL / 2} y={height - 4} text-anchor="middle">{String.fromCharCode(97 + n)}</text>
<text class="coord" x={PAD_L - 4} y={PAD_T + n * CELL + CELL / 2 + 3} text-anchor="end">{size - n}</text>
{/each}
<g filter="url(#worn)">
<g>
{#each g.cells as piece, i (i)}
{@const c = cellOf(size, i)}
{@const x = PAD_L + c.x * CELL}
@@ -323,11 +348,12 @@
<circle class="hint" cx={x + CELL / 2} cy={y + CELL / 2} r={CELL * 0.14} pointer-events="none" />
{/if}
{/each}
{#each chips as chip (chip.d)}
<path class="chip" d={chip.d} />
{/each}
{#each cracks as crack (crack)}
<path class="crack" d={crack} />
{#each [throne, ...corners] as place (place)}
{@const c = cellOf(size, place)}
<g class="inlay" transform={`translate(${PAD_L + c.x * CELL + 20} ${PAD_T + c.y * CELL + 20})`} aria-hidden="true">
<path d="M0 -15 L15 0 0 15 -15 0 Z" />
{#if !worn}<path d="M-7 0 H7 M0 -7 V7" />{/if}
</g>
{/each}
{#each tokens as t (t.id)}
{@const c = cellOf(size, t.at)}
@@ -340,11 +366,21 @@
style="transform: translate({cx}px, {cy}px)"
pointer-events="none"
>
<ellipse class="shadow" cx="1.5" cy="2.5" rx={CELL * 0.34} ry={CELL * 0.3} />
<circle class="body" cx="0" cy="0" r={CELL * 0.33} />
{#if t.piece === 'k'}
<path class="crown" d="M-9 4 l0 -9 l4.5 5 l4.5 -8 l4.5 8 l4.5 -5 l0 9 z" />
<ellipse class="shadow" cx="1" cy="3" rx="14" ry="12.5" />
<g filter={worn ? `url(#${artId}-worn)` : undefined}>
<circle class="base" cy="1.2" r="13.6" />
<circle class="body" r="13.2" fill={`url(#${artId}-${t.piece === 'a' ? 'attacker' : t.piece === 'k' ? 'king' : 'defender'})`} />
<path class="bevel-light" d="M-11.5 3 A12 12 0 0 1 8 -9" />
<path class="bevel-dark" d="M11.5 -3 A12 12 0 0 1 -7 9.8" />
<circle class="turned" r="10.2" />
{#if worn}
<path class="counter-wear" transform={`rotate(${(t.id * 137) % 360})`} d="M-10 -6 l2 1 M4 10 l2 -1 M-5 -9 l3 0.6" />
{/if}
{#if t.piece === 'k'}
<path class="crown-gleam" transform="translate(0.25 0.55)" d="M-8 4 V-5 L-4 0 0 -7 4 0 8 -5 V4 Z M-7 6 H7" />
<path class="crown" d="M-8 4 V-5 L-4 0 0 -7 4 0 8 -5 V4 Z M-7 6 H7" />
{/if}
</g>
</g>
{/each}
</g>
@@ -400,16 +436,22 @@
padding: 0 var(--gutter);
max-width: 1280px;
margin: 0 auto;
/* Copenhagen: fresh oak, clean stone. */
--wood: #8a5a2b;
/* Copenhagen: a crafted oak board, polished counters and brass inlay. */
--surface-light: #bd935c;
--surface-dark: #79502d;
--rim-light: #e0bf84;
--attacker-light: #63564a;
--defender-light: #fff4d8;
--king-light: #ffe4a0;
--wood: #a77945;
--wood-line: rgba(30, 16, 6, 0.55);
--coord: rgba(255, 240, 210, 0.55);
--coord: #302216;
--square: rgba(255, 240, 210, 0.08);
--square-edge: rgba(255, 250, 235, 0.11);
--throne: rgba(240, 165, 58, 0.35);
--corner: rgba(224, 101, 92, 0.35);
--corner: rgba(109, 52, 29, 0.22);
--attacker: #2a2320;
--attacker-rim: #0d0a08;
--attacker-rim: #211a15;
--defender: #ece4d0;
--defender-rim: #b9ab8b;
--king: #f0d58a;
@@ -417,17 +459,23 @@
--crown: #6b4a0f;
}
/* Tablut: the older game, cut in stone and kept in a long hall for a thousand years. */
/* Tablut: an excavated-object aesthetic, with limestone and aged counters. */
.board[data-set='tablut'] {
--surface-light: #aaa18b;
--surface-dark: #797564;
--rim-light: #c4b99e;
--attacker-light: #625e51;
--defender-light: #e2d5b3;
--king-light: #e5cc92;
--wood: #8a8578;
--wood-line: rgba(36, 32, 27, 0.45);
--coord: rgba(30, 26, 20, 0.5);
--coord: #37362c;
--square: rgba(255, 250, 235, 0.05);
--square-edge: rgba(255, 250, 235, 0.09);
--throne: rgba(120, 100, 70, 0.4);
--corner: transparent;
--attacker: #3d3530;
--attacker-rim: #1c1714;
--attacker-rim: #29281f;
--defender: #cfc4ad;
--defender-rim: #8c8069;
--king: #c9b27a;
@@ -510,12 +558,12 @@
}
.wood {
fill: var(--wood);
transition: fill 1.2s ease;
stroke: var(--surface-dark);
stroke-width: 1.4;
}
.stain {
fill: rgba(120, 110, 96, 0.22);
fill: #fff;
pointer-events: none;
}
@@ -528,8 +576,8 @@
.crack {
fill: none;
stroke: rgba(24, 20, 16, 0.55);
stroke-width: 0.9;
stroke: rgba(43, 39, 29, 0.48);
stroke-width: 0.5;
stroke-linecap: round;
pointer-events: none;
}
@@ -538,13 +586,14 @@
fill: var(--coord);
font-family: var(--font);
font-size: 9px;
font-weight: 600;
pointer-events: none;
}
.square {
fill: var(--square);
stroke: var(--wood-line);
stroke-width: 1;
stroke-width: 0.65;
cursor: default;
transition: fill 0.3s ease;
}
@@ -594,40 +643,29 @@
opacity: 0;
}
.piece .shadow {
fill: rgba(0, 0, 0, 0.35);
}
.board-rim { fill: none; stroke: var(--rim-light); stroke-width: 0.7; opacity: 0.65; pointer-events: none; }
.join { fill: none; stroke: #50351f; stroke-width: 0.6; opacity: 0.18; pointer-events: none; }
.surface-wear { pointer-events: none; }
.crack-light { fill: none; stroke: #e1d6bd; stroke-width: 0.55; opacity: 0.45; }
.inlay { fill: none; stroke: var(--rim-light); stroke-width: 0.8; opacity: 0.75; pointer-events: none; }
.board[data-set='tablut'] .inlay { stroke: #494638; opacity: 0.45; }
.piece .body {
stroke-width: 2;
transition:
fill 1.2s ease,
stroke 1.2s ease;
}
.piece.attacker .body {
fill: var(--attacker);
stroke: var(--attacker-rim);
}
.piece.defender .body {
fill: var(--defender);
stroke: var(--defender-rim);
}
.piece.king .body {
fill: var(--king);
stroke: var(--king-rim);
}
.piece .crown {
fill: var(--crown);
}
.piece.selected .body {
stroke: var(--frost);
stroke-width: 3;
}
.piece { --piece-rim: var(--defender-rim); }
.piece.attacker { --piece-rim: var(--attacker-rim); }
.piece.king { --piece-rim: var(--king-rim); }
.piece .shadow { fill: #16130e; opacity: 0.28; }
.piece .base { fill: var(--piece-rim); }
.piece .body { stroke: var(--piece-rim); stroke-width: 0.65; }
.bevel-light { fill: none; stroke: #fff1cc; stroke-width: 0.9; opacity: 0.48; stroke-linecap: round; }
.bevel-dark { fill: none; stroke: #211d16; stroke-width: 0.8; opacity: 0.3; stroke-linecap: round; }
.turned { fill: none; stroke: var(--piece-rim); stroke-width: 0.45; opacity: 0.35; }
.counter-wear { fill: none; stroke: #534b37; stroke-width: 0.5; opacity: 0.3; stroke-linecap: round; }
.attacker .counter-wear { stroke: #d6c6a3; opacity: 0.23; }
.board[data-set='tablut'] .bevel-light { opacity: 0.25; stroke-width: 1.1; }
.board[data-set='tablut'] .turned { opacity: 0.12; }
.piece .crown { fill: var(--crown); stroke: var(--crown); stroke-width: 0.7; stroke-linejoin: round; }
.piece .crown-gleam { fill: #f8e5b2; stroke: #f8e5b2; stroke-width: 0.9; opacity: 0.7; }
.piece.selected .body { stroke: var(--frost); stroke-width: 2.5; }
.how {
max-width: 620px;
+7 -2
View File
@@ -1,5 +1,10 @@
<script lang="ts">
// How to play: a walk through the page, from the hall to a first game.
import { page } from '$app/state';
/** Opened from a table, the page offers the way back to it and carries it to its cross-links. */
const room = $derived((page.url.searchParams.get('room') ?? '').toUpperCase());
const withRoom = $derived((path: string) => (room ? `${path}?room=${room}` : path));
</script>
<svelte:head>
@@ -8,9 +13,9 @@
<main class="guide">
<header>
<a class="back" href="/">Back to the hall</a> · <a class="back" href="/rules">The rules</a>
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a> · {/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/rules')}>The rules</a>
<h1>How to play</h1>
<p class="lede">A walk through the page, from the hall to a first game. The game's own rules are on <a href="/rules">the rules page</a>.</p>
<p class="lede">A walk through the page, from the hall to a first game. The game's own rules are on <a href={withRoom('/rules')}>the rules page</a>.</p>
<nav>
<a href="#hall">The hall</a> · <a href="#table">The table</a> · <a href="#board">The board</a> · <a href="#capture">A capture</a> · <a href="#gallery">The Peanut Gallery and table talk</a> ·
<a href="#first">A first game</a>
+2 -2
View File
@@ -123,7 +123,7 @@
{/if}
{/if}
<button type="button" class="quiet" onclick={() => (prefsOpen = true)}>Preferences</button>
<a class="quiet" href="/guide">How to play</a>
<a class="quiet" href={room ? `/guide?room=${roomId}` : '/guide'}>How to play</a>
<a class="quiet" href={room ? `/rules?room=${roomId}` : '/rules'}>Rules</a>
{#if room}
<button type="button" class="quiet" title="Something behaved unexpectedly? Tell the keeper." onclick={() => (reporting = true)}>Report</button>
@@ -182,7 +182,7 @@
{/if}
<footer class="colophon">
<p>Hnefatafl. <a href="/rules">Read the full rules</a>. A rule read wrong or a bug found: <a href="mailto:eric@ericwagoner.com">send word</a>.</p>
<p>Hnefatafl. <a href={room ? `/rules?room=${roomId}` : '/rules'}>Read the full rules</a>. A rule read wrong or a bug found: <a href="mailto:eric@ericwagoner.com">send word</a>.</p>
</footer>
<style>
+2 -1
View File
@@ -5,6 +5,7 @@
/** Opened from a table, the page offers the way back to it. */
const room = $derived((page.url.searchParams.get('room') ?? '').toUpperCase());
const withRoom = $derived((path: string) => (room ? `${path}?room=${room}` : path));
</script>
<svelte:head>
@@ -13,7 +14,7 @@
<main class="rules">
<header>
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a> · {/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href="/guide">How to play</a>
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a> · {/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/guide')}>How to play</a>
<h1>The rules</h1>
<p class="lede">
Hnefatafl is the tafl family's best-known member: a Viking board game played across Scandinavia and the North Atlantic from the fourth century until chess displaced it. No