Wire online multiplayer: game rooms, protocol, playable Svelte client

Server: room registry with 4-letter codes, host/join/start flow, the
authoritative command loop (seed + append-only command log per room —
the replay/async foundation), and per-player redacted views and events
broadcast after every change. Client: lobby, SVG board (floors, walls,
doors, homes, color-keyed treasures and wizard tokens matching the
physical set's six colors, warp arrows), click-to-move, click-to-punch,
card hand with tooltips from verified card text, cast flow with number
card attachment and waterbolt split, edge-click targeting for wall
spells, counteract-or-pass prompt, discard flow, end-turn draw
selector, and a humanized event log. Verified end-to-end over real
websockets with two clients: join, start, private deals, moves, turn
sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-15 19:52:10 -04:00
co-authored by Claude Fable 5
parent 7c607ad5c7
commit 36b3ffe9a6
7 changed files with 977 additions and 11 deletions
+352 -7
View File
@@ -1,19 +1,364 @@
<script lang="ts">
let status = $state("disconnected");
import { net } from "./net.svelte";
import Board from "./Board.svelte";
import { cardDef, isNumberCard, SIDES, stepTarget, cellKey } from "@wizwar/engine";
import type { CardInstance, Side } from "@wizwar/engine";
const ws = new WebSocket("ws://localhost:8787");
ws.onopen = () => (status = "connected");
ws.onclose = () => (status = "disconnected");
net.connect();
let name = $state("");
let joinCode = $state("");
let drawCount = $state(2);
/** Card selected in hand, pending a target. */
let selectedCard = $state<CardInstance | null>(null);
/** Number card attached to the pending cast. */
let attachedNumber = $state<CardInstance | null>(null);
/** Waterbolt split. */
let wbDamage = $state(0);
/** Cards marked for discard. */
let discardSelection = $state<Set<string>>(new Set());
const view = $derived(net.view);
const isYourTurn = $derived(view !== null && view.activePlayerId === view.you && !view.stack);
const youMustRespond = $derived(view?.stack != null && view.stack.waitingOn === view.you);
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
const selectedDef = $derived(selectedCard ? cardDef(selectedCard.cardId) : null);
const edgeSelectMode = $derived(
selectedCard?.cardId === "create-wall" || selectedCard?.cardId === "destroy-wall",
);
const numberTotal = $derived(attachedNumber ? cardDef(attachedNumber.cardId).value! : 1);
function clearSelection() {
selectedCard = null;
attachedNumber = null;
wbDamage = 0;
}
function selectCard(card: CardInstance) {
if (!view) return;
if (youMustRespond) {
net.command({ type: "counteract", instanceId: card.instanceId });
return;
}
if (youMustDiscard || discardSelection.size > 0) {
const next = new Set(discardSelection);
next.has(card.instanceId) ? next.delete(card.instanceId) : next.add(card.instanceId);
discardSelection = next;
return;
}
if (!isYourTurn) return;
if (selectedCard && isNumberCard(card.cardId) && !isNumberCard(selectedCard.cardId)) {
attachedNumber = attachedNumber?.instanceId === card.instanceId ? null : card;
wbDamage = numberTotal;
return;
}
if (selectedCard?.instanceId === card.instanceId) {
clearSelection();
return;
}
selectedCard = card;
attachedNumber = null;
if (isNumberCard(card.cardId)) {
// A bare number card: play it for movement.
net.command({ type: "playNumberForMovement", instanceId: card.instanceId });
clearSelection();
return;
}
// Self-targeting / untargeted spells cast immediately.
if (card.cardId === "speed") {
net.command({ type: "cast", instanceId: card.instanceId });
clearSelection();
}
}
function clickCell(cell: { x: number; y: number }) {
if (!view || !isYourTurn) return;
const me = view.players.find((p) => p.id === view.you)!;
// A cell click is a move if the cell is one legal step away.
for (const side of SIDES) {
const t = stepTarget(view.board, me.position, side);
if (t.kind !== "blocked" && cellKey(t.to) === cellKey(cell)) {
net.command({ type: "move", direction: side });
return;
}
}
}
function clickEdge(cell: { x: number; y: number }, side: Side) {
if (!selectedCard || !edgeSelectMode) return;
net.command({
type: "cast",
instanceId: selectedCard.instanceId,
target: { kind: "edge", cell, side },
});
clearSelection();
}
function clickPlayer(playerId: string) {
if (!view || !isYourTurn) return;
if (!selectedCard) {
// No card selected: same-square click = punch.
const me = view.players.find((p) => p.id === view.you)!;
const them = view.players.find((p) => p.id === playerId)!;
if (playerId !== view.you && cellKey(me.position) === cellKey(them.position)) {
net.command({ type: "punch", targetId: playerId });
}
return;
}
const cmd: Parameters<typeof net.command>[0] = {
type: "cast",
instanceId: selectedCard.instanceId,
target: { kind: "player", playerId },
};
if (attachedNumber) cmd.numberInstanceId = attachedNumber.instanceId;
if (selectedCard.cardId === "waterbolt") {
cmd.params = { damage: wbDamage, knockback: numberTotal - wbDamage };
}
net.command(cmd);
clearSelection();
}
function doDiscard() {
net.command({ type: "discard", instanceIds: [...discardSelection] });
discardSelection = new Set();
}
function endTurn() {
clearSelection();
net.command({ type: "endTurn", draw: drawCount });
}
function pickUp() { net.command({ type: "pickUpTreasure" }); }
function drop() { net.command({ type: "dropTreasure" }); }
function pass() { net.command({ type: "pass" }); }
const PLAYER_COLORS = ["#1a9c46", "#d3352b", "#c9308f", "#3a3ac0", "#2ab0c9", "#c9a72a"];
function playerColor(id: string): string {
const idx = view?.players.findIndex((p) => p.id === id) ?? 0;
return PLAYER_COLORS[idx % PLAYER_COLORS.length]!;
}
</script>
<main>
<h1>Wiz-War</h1>
<p>server: {status}</p>
<h1>Wiz-War <span class="subtitle">6th edition</span></h1>
{#if net.error}
<div class="toast">{net.error}</div>
{/if}
{#if !net.roomId}
<section class="lobby">
<p class="status">server: {net.status}</p>
<input placeholder="your wizard's name" bind:value={name} maxlength="20" />
<div class="lobby-actions">
<button disabled={!name.trim()} onclick={() => net.create(name)}>Create game</button>
<span>or</span>
<input placeholder="room code" bind:value={joinCode} maxlength="4" class="code" />
<button disabled={!name.trim() || !joinCode.trim()} onclick={() => net.join(joinCode, name)}>
Join
</button>
</div>
</section>
{:else if !net.started}
<section class="lobby">
<h2>Room <code>{net.roomId}</code></h2>
<p>Share the code with your opponents.</p>
<ul>
{#each net.players as p (p)}
<li>{p}{p === net.hostId ? " (host)" : ""}</li>
{/each}
</ul>
{#if net.you === net.hostId}
<button
disabled={net.players.length !== 2 && net.players.length !== 4}
onclick={() => net.start()}
>
Start game ({net.players.length} wizards — need 2 or 4)
</button>
{:else}
<p>Waiting for {net.hostId} to start…</p>
{/if}
</section>
{:else if view}
<div class="game">
<div class="board-pane">
<Board
{view}
edgeSelectMode={edgeSelectMode && isYourTurn}
onCellClick={clickCell}
onEdgeClick={clickEdge}
onPlayerClick={clickPlayer}
/>
</div>
<div class="side-pane">
{#if view.phase === "finished"}
<div class="banner winner">🏆 {view.winner} wins!</div>
{:else if youMustRespond}
<div class="banner respond">
{#if view.stack?.defenderId === view.you}
<strong>{view.stack.attackerId}</strong> attacks you
{#if view.stack.attackCard}with <strong>{cardDef(view.stack.attackCard.cardId).name}</strong>{:else}with a punch{/if}!
Click a counteraction card, or
{:else}
Respond to the counteraction, or
{/if}
<button onclick={pass}>let it resolve</button>
</div>
{:else if view.stack}
<div class="banner">Waiting for {view.stack.waitingOn}</div>
{:else if isYourTurn}
<div class="banner your-turn">
Your turn — round {view.turn.round}.
Moves: {view.turn.movementAllowance - view.turn.movementUsed}
{view.turn.attackUsed ? "· attack used" : ""}
</div>
{:else}
<div class="banner">{view.activePlayerId} is taking their turn…</div>
{/if}
<div class="players">
{#each view.players as p (p.id)}
<div class="player" class:dead={!p.alive} class:active={p.id === view.activePlayerId}>
<span class="dot" style:background={playerColor(p.id)}></span>
<span class="pname">{p.id}{p.id === view.you ? " (you)" : ""}</span>
<span class="life">{p.life}</span>
<span class="cards">🂠 {p.handCount}</span>
{#if p.lostTurns > 0}<span title="lost turns">💫{p.lostTurns}</span>{/if}
{#if p.carriedTreasureId}<span title="carrying treasure">💰</span>{/if}
</div>
{/each}
<div class="deck-info">deck {view.deckCount} · discard {view.discardCount}</div>
</div>
{#if selectedDef}
<div class="cast-hint">
<strong>{selectedDef.name}</strong>
{#if edgeSelectMode}
— click a wall line on the board
{:else if selectedDef.cardType === "attack"}
— click a target wizard{attachedNumber ? ` (powered by a ${numberTotal})` : " (click a number card to power it)"}
{/if}
{#if selectedCard?.cardId === "waterbolt"}
<label>damage <input type="number" min="0" max={numberTotal} bind:value={wbDamage} /></label>
(knockback {numberTotal - wbDamage})
{/if}
<button class="link" onclick={clearSelection}>cancel</button>
</div>
{/if}
<div class="hand">
{#each view.yourHand as card (card.instanceId)}
{@const def = cardDef(card.cardId)}
<button
class="card"
class:selected={selectedCard?.instanceId === card.instanceId}
class:attached={attachedNumber?.instanceId === card.instanceId}
class:marked={discardSelection.has(card.instanceId)}
title={def.text ?? ""}
onclick={() => selectCard(card)}
>
<span class="card-type">{def.cardType}</span>
<span class="card-name">{def.name}</span>
</button>
{/each}
</div>
<div class="actions">
{#if youMustDiscard}
<div class="banner respond">Hand over 7 — select cards and discard.</div>
{/if}
{#if discardSelection.size > 0}
<button onclick={doDiscard}>Discard {discardSelection.size} selected</button>
{/if}
{#if isYourTurn}
<button onclick={pickUp}>Pick up treasure</button>
<button onclick={drop}>Drop treasure</button>
<label>
draw
<select bind:value={drawCount}>
<option value={0}>0</option>
<option value={1}>1</option>
<option value={2}>2</option>
</select>
</label>
<button class="primary" onclick={endTurn}>End turn</button>
{/if}
</div>
<div class="log">
{#each net.log.slice(-40) as line, i (i)}
<div>{line}</div>
{/each}
</div>
</div>
</div>
{/if}
</main>
<style>
main {
font-family: system-ui, sans-serif;
padding: 2rem;
padding: 1rem 1.5rem;
max-width: 1100px;
margin: 0 auto;
}
h1 { margin: 0 0 0.75rem; }
.subtitle { font-size: 0.55em; color: #887; font-weight: normal; }
.toast {
background: #b33; color: white; padding: 0.5rem 0.75rem;
border-radius: 6px; margin-bottom: 0.5rem;
}
.lobby { display: flex; flex-direction: column; gap: 0.75rem; max-width: 420px; }
.lobby input { padding: 0.5rem; font-size: 1rem; }
.lobby-actions { display: flex; gap: 0.5rem; align-items: center; }
.code { width: 6ch; text-transform: uppercase; }
.status { color: #887; margin: 0; }
.game { display: grid; grid-template-columns: minmax(320px, 640px) minmax(280px, 1fr); gap: 1rem; }
@media (max-width: 800px) { .game { grid-template-columns: 1fr; } }
.side-pane { display: flex; flex-direction: column; gap: 0.75rem; min-width: 0; }
.banner { padding: 0.5rem 0.75rem; border-radius: 6px; background: #eee; }
.banner.your-turn { background: #d8efd8; }
.banner.respond { background: #f6dcb5; }
.banner.winner { background: gold; font-size: 1.2em; }
.players { display: flex; flex-direction: column; gap: 0.25rem; }
.player { display: flex; gap: 0.5rem; align-items: center; padding: 0.2rem 0.4rem; border-radius: 4px; }
.player.active { background: #eef4ff; }
.player.dead { opacity: 0.45; text-decoration: line-through; }
.dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
.pname { font-weight: 600; }
.deck-info { color: #887; font-size: 0.85em; }
.cast-hint { background: #eef; padding: 0.4rem 0.6rem; border-radius: 6px; }
.cast-hint input { width: 4ch; }
.link { background: none; border: none; color: #36c; cursor: pointer; text-decoration: underline; }
.hand { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.card {
display: flex; flex-direction: column; align-items: flex-start;
border: 1.5px solid #998; border-radius: 6px; background: #f6f2e6;
padding: 0.35rem 0.5rem; cursor: pointer; min-width: 7.5rem; text-align: left;
}
.card:hover { border-color: #333; }
.card.selected { border-color: #26c; background: #e6ecfc; }
.card.attached { border-color: #2a2; background: #e2f4e2; }
.card.marked { border-color: #c33; background: #fce6e6; }
.card-type { font-size: 0.65em; text-transform: uppercase; color: #776; }
.card-name { font-weight: 600; font-size: 0.9em; }
.actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
button { padding: 0.4rem 0.7rem; border-radius: 6px; border: 1px solid #998; background: #fff; cursor: pointer; }
button.primary { background: #26c; color: white; border-color: #26c; }
button:disabled { opacity: 0.5; cursor: default; }
.log {
background: #1e1c18; color: #cfc9ba; font-size: 0.8em;
border-radius: 6px; padding: 0.5rem 0.7rem; max-height: 240px;
overflow-y: auto; font-family: ui-monospace, monospace;
}
</style>