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
+285
View File
@@ -0,0 +1,285 @@
<script lang="ts">
import { page } from '$app/state';
import Board from '$lib/components/Board.svelte';
import { seatFor } from '$lib/game/client';
import { COMPACT_QUERY, Duel, NAME_MAX, playerName } from '$lib/game/duel.svelte';
const roomId = $derived((page.params.code ?? '').toUpperCase());
let room = $state<Duel | null>(null);
let name = $state('');
let joining = $state(false);
let busy = $state(false);
let error = $state('');
let copied = $state(false);
const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`);
const lobby = $derived(room !== null && !room.remote?.started);
// Return to a held seat, or offer one.
$effect(() => {
const id = roomId;
room = null;
error = '';
const held = seatFor(id);
if (!held) {
name = playerName();
joining = true;
return;
}
joining = false;
Duel.resumeRoom(id, held)
.then((remote) => (room = new Duel(remote)))
.catch((e: Error) => (error = e.message));
});
// Keep the view fresh for as long as the room is on screen.
$effect(() => {
const r = room;
if (!r) return;
const stop = r.connect();
return stop;
});
// The store's idea of a narrow screen must agree with the stylesheets' breakpoint.
$effect(() => {
const r = room;
if (!r) return;
const query = window.matchMedia(COMPACT_QUERY);
const apply = () => (r.compact = query.matches);
apply();
query.addEventListener('change', apply);
return () => query.removeEventListener('change', apply);
});
async function takeSeat(e: SubmitEvent) {
e.preventDefault();
busy = true;
error = '';
try {
room = new Duel(await Duel.joinRoom(roomId, name));
joining = false;
} catch (err) {
error = err instanceof Error ? err.message : 'The seat could not be taken.';
} finally {
busy = false;
}
}
async function copyLink() {
try {
await navigator.clipboard.writeText(link);
copied = true;
setTimeout(() => (copied = false), 1600);
} catch {
// The link is on screen to copy by hand.
}
}
</script>
<svelte:head>
<title>Waving Hands: a duel</title>
</svelte:head>
<header class="masthead">
<div>
<h1>Waving Hands</h1>
{#if room?.remote?.started}
<p class="standfirst">
You are <em>{room.you.name}</em>, duelling
{#each room.others as id, i (id)}{#if i > 0}{i === room.others.length - 1 ? ' and ' : ', '}{/if}<em>{room.state.wizards[id].name}</em>{/each}.
{#if !room.remote.connected}<span class="muted">Reconnecting to the duel.</span>{/if}
</p>
{:else}
<p class="standfirst">A duel between people, played by turns whenever each of you has a moment.</p>
{/if}
</div>
<nav class="actions">
{#if room}
<button type="button" class="quiet room" title="Copy the invite link" onclick={copyLink}>
<span class="muted">invite friends · room</span> <b>{roomId}</b>{copied ? ' · link copied' : ''}
</button>
{/if}
<a class="quiet" href="/rules">Rules</a>
<a class="quiet" href="/">The hall</a>
</nav>
</header>
{#if error}
<p class="notice">{error}</p>
{/if}
{#if joining}
<section class="lobby">
<p class="eyebrow">room {roomId}</p>
<h2>Take a seat</h2>
<p>Another wizard has opened this duel and is waiting for an opponent.</p>
<form class="rename" onsubmit={takeSeat}>
<label class="field">
<span>Your name</span>
<input type="text" bind:value={name} maxlength={NAME_MAX} autocomplete="nickname" required />
</label>
<button type="submit" class="reveal small" disabled={busy}>Sit down</button>
</form>
</section>
{:else if room && lobby}
<section class="lobby">
<p class="eyebrow">room {roomId}</p>
<h2>Waiting for {room.remote!.size - room.remote!.seats.length === 1 ? 'one more wizard' : `${room.remote!.size - room.remote!.seats.length} more wizards`}</h2>
<p>Send this link, or the code. Whoever opens it takes the empty seat, and the duel begins.</p>
<p class="link-row"><code>{link}</code><button type="button" class="quiet" onclick={copyLink}>{copied ? 'Copied' : 'Copy link'}</button></p>
<p class="muted">so far: {room.remote!.seats.map((s) => s.name).join(', ')}</p>
<p><button type="button" class="quiet" onclick={() => room?.addBot()}>Seat the bot instead</button></p>
</section>
{:else if room}
<Board duel={room} />
{:else if !error}
<p class="notice muted">Finding your seat.</p>
{/if}
<footer class="colophon">
<p>After Richard Bartle's <em>Waving Hands</em> (1977), also known as Spellbinder and Spellcaster. <a href="/rules">Read the full rules</a>.</p>
</footer>
<style>
.masthead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem var(--gutter) 0.6rem;
max-width: 1280px;
margin: 0 auto;
}
h1 {
font-size: clamp(2rem, 4vw, 2.7rem);
font-weight: 300;
font-variation-settings: 'opsz' 144;
letter-spacing: -0.01em;
}
.standfirst {
max-width: 38em;
color: var(--bone-dim);
margin-top: 0.4rem;
}
.standfirst em {
color: var(--bone);
}
.actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.quiet {
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.4rem 0.9rem;
white-space: nowrap;
color: var(--bone);
text-decoration: none;
line-height: 1.55;
}
.quiet:hover {
border-color: var(--bone);
}
.quiet.room b {
letter-spacing: 0.12em;
font-weight: 600;
}
.eyebrow {
font-size: 0.85rem;
letter-spacing: 0.12em;
color: var(--bone-faint);
margin-bottom: 0.2rem;
}
.notice {
max-width: 1280px;
margin: 0 auto;
padding: 0 var(--gutter) 0.5rem;
color: var(--blood);
}
.notice.muted {
color: var(--bone-dim);
}
.lobby {
max-width: 1280px;
margin: 0 auto;
padding: 1rem var(--gutter) 2rem;
}
.lobby h2 {
font-size: 1.4rem;
margin-bottom: 0.4rem;
}
.lobby p + p {
margin-top: 0.6rem;
}
.rename {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin-top: 0.8rem;
}
.rename input {
width: 14em;
}
.reveal.small {
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
.link-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
}
code {
font-family: var(--font);
background: var(--slate);
padding: 0.3rem 0.6rem;
border-radius: 4px;
word-break: break-all;
}
.colophon {
max-width: 1280px;
margin: 0 auto;
padding: 2rem var(--gutter) 3rem;
color: var(--bone-faint);
font-size: 0.85rem;
}
.colophon a {
color: var(--frost);
}
/* Must match COMPACT_QUERY in duel.svelte.ts. */
@media (max-width: 860px) {
.masthead {
flex-direction: column;
}
.colophon {
padding-bottom: 6rem;
}
}
</style>