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
+192
View File
@@ -0,0 +1,192 @@
<script lang="ts">
import type { GameView } from "@wizwar/engine";
import type { Side } from "@wizwar/engine";
const CELL = 48;
const WALL = 7;
let {
view,
edgeSelectMode = false,
onCellClick,
onEdgeClick,
onPlayerClick,
}: {
view: GameView;
edgeSelectMode?: boolean;
onCellClick?: (cell: { x: number; y: number }) => void;
onEdgeClick?: (cell: { x: number; y: number }, side: Side) => void;
onPlayerClick?: (playerId: string) => void;
} = $props();
const PLAYER_COLORS = ["#1a9c46", "#d3352b", "#c9308f", "#3a3ac0", "#2ab0c9", "#c9a72a"];
function playerColor(id: string): string {
const idx = view.players.findIndex((p) => p.id === id);
return PLAYER_COLORS[idx % PLAYER_COLORS.length]!;
}
const cells = $derived(
Object.keys(view.board.cells).map((k) => {
const [x, y] = k.split(",").map(Number);
return { x: x!, y: y! };
}),
);
const edges = $derived(
Object.entries(view.board.edges)
.filter(([, state]) => state !== "open")
.map(([key, state]) => {
const [kind, coords] = key.split(":") as [string, string];
const [x, y] = coords.split(",").map(Number) as [number, number];
return { kind, x, y, state };
}),
);
// Candidate edges for create/destroy wall clicks: every interior boundary.
const edgeHitboxes = $derived.by(() => {
if (!edgeSelectMode) return [];
const boxes: { cell: { x: number; y: number }; side: Side; x: number; y: number; w: number; h: number }[] = [];
for (const c of cells) {
if (view.board.cells[`${c.x + 1},${c.y}`]) {
boxes.push({ cell: c, side: "E", x: (c.x + 1) * CELL - 6, y: c.y * CELL + 4, w: 12, h: CELL - 8 });
}
if (view.board.cells[`${c.x},${c.y + 1}`]) {
boxes.push({ cell: c, side: "S", x: c.x * CELL + 4, y: (c.y + 1) * CELL - 6, w: CELL - 8, h: 12 });
}
}
return boxes;
});
// Group players by cell so co-located wizards fan out.
const wizardsByCell = $derived.by(() => {
const map = new Map<string, typeof view.players>();
for (const p of view.players) {
if (!p.alive) continue;
const k = `${p.position.x},${p.position.y}`;
map.set(k, [...(map.get(k) ?? []), p]);
}
return map;
});
</script>
<svg
viewBox={`-8 -8 ${view.board.width * CELL + 16} ${view.board.height * CELL + 16}`}
class="board"
>
<!-- floor -->
{#each cells as c (`${c.x},${c.y}`)}
<rect
x={c.x * CELL} y={c.y * CELL} width={CELL} height={CELL}
class="floor"
role="button" tabindex="-1"
onclick={() => onCellClick?.(c)}
onkeydown={() => {}}
/>
{/each}
<!-- homes & treasure spaces -->
{#each view.players as p (p.id)}
<text
x={p.home.x * CELL + CELL / 2} y={p.home.y * CELL + CELL / 2 + 2}
class="home" fill={playerColor(p.id)}
>✦</text>
{/each}
{#each view.treasures as t (t.id)}
{#if t.position}
<circle
cx={t.position.x * CELL + CELL / 2} cy={t.position.y * CELL + CELL * 0.72}
r={7} class="treasure" fill={playerColor(t.owner)}
/>
{/if}
{/each}
<!-- walls & doors -->
{#each edges as e (`${e.kind}:${e.x},${e.y}`)}
{#if e.kind === "V"}
<rect
x={(e.x + 1) * CELL - WALL / 2} y={e.y * CELL - WALL / 2}
width={WALL} height={CELL + WALL}
class={e.state === "door" ? "door" : "wall"}
/>
{:else}
<rect
x={e.x * CELL - WALL / 2} y={(e.y + 1) * CELL - WALL / 2}
width={CELL + WALL} height={WALL}
class={e.state === "door" ? "door" : "wall"}
/>
{/if}
{/each}
<!-- warp openings -->
{#each view.board.warps as w, i (i)}
<text
x={w.from.cell.x * CELL + CELL / 2 +
(w.from.side === "E" ? CELL * 0.42 : w.from.side === "W" ? -CELL * 0.42 : 0)}
y={w.from.cell.y * CELL + CELL / 2 + 3 +
(w.from.side === "S" ? CELL * 0.42 : w.from.side === "N" ? -CELL * 0.42 : 0)}
class="warp"
>{w.from.side === "N" ? "↑" : w.from.side === "S" ? "↓" : w.from.side === "E" ? "→" : "←"}</text>
{/each}
<!-- wizards -->
{#each [...wizardsByCell.entries()] as [key, group] (key)}
{#each group as p, i (p.id)}
{@const cx = p.position.x * CELL + CELL / 2 + (group.length > 1 ? (i - (group.length - 1) / 2) * 14 : 0)}
{@const cy = p.position.y * CELL + CELL * 0.36}
<g
role="button" tabindex="-1"
onclick={(ev) => { ev.stopPropagation(); onPlayerClick?.(p.id); }}
onkeydown={() => {}}
class="wizard"
>
<circle {cx} {cy} r={12} fill={playerColor(p.id)} stroke="#111" stroke-width="1.5" />
<text x={cx} y={cy + 4} class="wizard-label">{p.id[0]?.toUpperCase()}</text>
{#if p.carriedTreasureId}
<circle cx={cx + 9} cy={cy + 9} r={5} class="carried" />
{/if}
</g>
{/each}
{/each}
<!-- edge selection hitboxes -->
{#each edgeHitboxes as h (`${h.cell.x},${h.cell.y},${h.side}`)}
<rect
x={h.x} y={h.y} width={h.w} height={h.h}
class="edge-hit"
role="button" tabindex="-1"
onclick={(ev) => { ev.stopPropagation(); onEdgeClick?.(h.cell, h.side); }}
onkeydown={() => {}}
/>
{/each}
</svg>
<style>
.board {
width: 100%;
max-width: 640px;
background: #d8d2c4;
border: 3px solid #4a4438;
border-radius: 4px;
}
.floor {
fill: #e8e2d4;
stroke: #b8b0a0;
stroke-width: 1;
cursor: pointer;
}
.floor:hover { fill: #f2ecda; }
.wall { fill: #4a4438; }
.door { fill: #8b5a2b; }
.home { font-size: 26px; text-anchor: middle; dominant-baseline: middle; opacity: 0.85; }
.treasure { stroke: #111; stroke-width: 1.2; }
.warp { font-size: 13px; text-anchor: middle; fill: #6a5f4b; font-weight: bold; }
.wizard { cursor: pointer; }
.wizard-label {
font-size: 13px; font-weight: bold; fill: white;
text-anchor: middle; pointer-events: none;
}
.carried { fill: gold; stroke: #111; stroke-width: 1; }
.edge-hit { fill: rgba(30, 120, 240, 0.15); cursor: crosshair; }
.edge-hit:hover { fill: rgba(30, 120, 240, 0.5); }
</style>