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
+1
View File
@@ -8,3 +8,4 @@ export * from "./board";
export * from "./cards";
export * from "./setups";
export * from "./game";
export * from "./view";
+73
View File
@@ -0,0 +1,73 @@
// Per-player projection of GameState: everything public, plus YOUR hand.
// The server sends this after every state change; clients never see the
// deck order or other players' hands.
import { type AssembledBoard } from "./board";
import { type CardInstance } from "./cards";
import {
boardView,
type CastStack,
type GameState,
type PlayerId,
type TreasureState,
type TurnState,
} from "./game";
export interface PlayerPublicView {
id: PlayerId;
position: { x: number; y: number };
home: { x: number; y: number };
life: number;
alive: boolean;
handCount: number;
carriedTreasureId: string | null;
lostTurns: number;
extraTurns: number;
}
export interface GameView {
you: PlayerId;
phase: GameState["phase"];
winner: PlayerId | null;
turn: TurnState;
activePlayerId: PlayerId;
/** Board with dynamic wall changes already merged in. */
board: AssembledBoard;
players: PlayerPublicView[];
yourHand: CardInstance[];
treasures: TreasureState[];
deckCount: number;
discardCount: number;
/** Cards on the stack are face-up: the whole exchange is public. */
stack: CastStack | null;
pendingDiscard: PlayerId | null;
}
export function viewFor(state: GameState, playerId: PlayerId): GameView {
const you = state.players.find((p) => p.id === playerId);
return {
you: playerId,
phase: state.phase,
winner: state.winner,
turn: state.turn,
activePlayerId: state.players[state.turn.activeIndex]!.id,
board: boardView(state),
players: state.players.map((p) => ({
id: p.id,
position: p.position,
home: p.home,
life: p.life,
alive: p.alive,
handCount: p.hand.length,
carriedTreasureId: p.carriedTreasureId,
lostTurns: p.lostTurns,
extraTurns: p.extraTurns,
})),
yourHand: you ? [...you.hand] : [],
treasures: state.treasures.map((t) => ({ ...t })),
deckCount: state.deck.length,
discardCount: state.discard.length,
stack: state.stack,
pendingDiscard: state.pendingDiscard,
};
}
+124 -4
View File
@@ -1,13 +1,133 @@
import { WebSocketServer } from "ws";
// Websocket front door. Protocol (JSON messages):
// client -> server:
// {type:"create", name} create a room, become host
// {type:"join", roomId, name} join (or rejoin) a room
// {type:"start"} host starts the game
// {type:"command", command} a game Command for the engine
// server -> client:
// {type:"welcome"} on connect
// {type:"room", roomId, players, hostId, started}
// {type:"events", events} redacted for this recipient
// {type:"state", view} redacted full view (after every change)
// {type:"error", message}
import { WebSocketServer, WebSocket } from "ws";
import type { Command, PlayerId } from "@wizwar/engine";
import {
createRoom,
getRoom,
joinRoom,
redactFor,
runCommand,
startGame,
viewForPlayer,
type Room,
} from "./rooms";
const port = Number(process.env.PORT ?? 8787);
const wss = new WebSocketServer({ port });
interface Session {
socket: WebSocket;
playerId: PlayerId | null;
roomId: string | null;
}
const sessions = new Set<Session>();
function send(socket: WebSocket, message: unknown): void {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
}
function roomInfo(room: Room) {
return {
type: "room",
roomId: room.id,
players: room.players,
hostId: room.hostId,
started: room.state !== null,
};
}
function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): void {
for (const s of sessions) {
if (s.roomId === room.id && s.playerId) {
send(s.socket, makeMessage(s.playerId));
}
}
}
function broadcastRoomState(room: Room): void {
broadcast(room, (playerId) => roomInfo(room));
if (room.state) {
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId) }));
}
}
wss.on("connection", (socket) => {
socket.send(JSON.stringify({ type: "hello", game: "wizwar" }));
const session: Session = { socket, playerId: null, roomId: null };
sessions.add(session);
send(socket, { type: "welcome", game: "wizwar" });
socket.on("close", () => sessions.delete(session));
socket.on("message", (data) => {
// TODO: route client commands into game rooms once the engine exists.
console.log("received:", data.toString());
let msg: Record<string, unknown>;
try {
msg = JSON.parse(data.toString());
} catch {
return send(socket, { type: "error", message: "invalid JSON" });
}
try {
switch (msg.type) {
case "create": {
const name = String(msg.name ?? "").trim();
if (!name) return send(socket, { type: "error", message: "name required" });
const room = createRoom(name);
session.playerId = name;
session.roomId = room.id;
broadcastRoomState(room);
break;
}
case "join": {
const name = String(msg.name ?? "").trim();
const roomId = String(msg.roomId ?? "").trim();
if (!name || !roomId) return send(socket, { type: "error", message: "name and roomId required" });
const room = getRoom(roomId);
if (!room) return send(socket, { type: "error", message: "no such room" });
const problem = joinRoom(room, name);
if (problem) return send(socket, { type: "error", message: problem });
session.playerId = name;
session.roomId = room.id;
broadcastRoomState(room);
break;
}
case "start": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
if (session.playerId !== room.hostId) return send(socket, { type: "error", message: "only the host can start" });
const result = startGame(room);
if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
broadcastRoomState(room);
break;
}
case "command": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const result = runCommand(room, session.playerId, msg.command as Command);
if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId) }));
break;
}
default:
send(socket, { type: "error", message: `unknown message type: ${String(msg.type)}` });
}
} catch (e) {
send(socket, { type: "error", message: e instanceof Error ? e.message : "internal error" });
}
});
});
+111
View File
@@ -0,0 +1,111 @@
// Game rooms: the server is the authority. Each room holds one GameState and
// the append-only command log (the seed + log IS the game — the basis for
// replays and async play). Clients get per-player redacted views and events.
import {
applyCommand,
createGame,
redactEvent,
viewFor,
type Command,
type GameEvent,
type GameState,
type GameView,
type PlayerId,
} from "@wizwar/engine";
export interface LoggedCommand {
seq: number;
playerId: PlayerId;
command: Command;
at: string; // ISO timestamp (server-side wall clock; not used by the engine)
}
export interface Room {
id: string;
hostId: PlayerId;
players: PlayerId[]; // join order
seed: number;
state: GameState | null; // null until started
log: LoggedCommand[];
events: GameEvent[]; // full history (unredacted — redact per recipient)
}
const rooms = new Map<string, Room>();
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
function makeRoomCode(): string {
let code = "";
for (let i = 0; i < 4; i++) {
code += ROOM_CODE_ALPHABET[Math.floor(Math.random() * ROOM_CODE_ALPHABET.length)];
}
return rooms.has(code) ? makeRoomCode() : code;
}
export function createRoom(hostId: PlayerId): Room {
const room: Room = {
id: makeRoomCode(),
hostId,
players: [hostId],
seed: Math.floor(Math.random() * 0xffffffff),
state: null,
log: [],
events: [],
};
rooms.set(room.id, room);
return room;
}
export function getRoom(id: string): Room | undefined {
return rooms.get(id.toUpperCase());
}
export function joinRoom(room: Room, playerId: PlayerId): string | null {
if (room.state) return "game already started";
if (room.players.includes(playerId)) return null; // rejoin is fine
if (room.players.length >= 4) return "room is full";
room.players.push(playerId);
return null;
}
export function startGame(room: Room): { events: GameEvent[] } | { error: string } {
if (room.state) return { error: "already started" };
const n = room.players.length;
if (n !== 2 && n !== 4) return { error: "supported player counts: 2 or 4" };
const { state, events } = createGame({
playerIds: room.players,
seed: room.seed,
sets: ["basic"],
});
room.state = state;
room.events.push(...events);
return { events };
}
export function runCommand(
room: Room,
playerId: PlayerId,
command: Command,
): { events: GameEvent[] } | { error: string } {
if (!room.state) return { error: "game not started" };
const result = applyCommand(room.state, playerId, command);
if (!result.ok) return { error: result.error };
room.state = result.state;
room.log.push({
seq: room.log.length,
playerId,
command,
at: new Date().toISOString(),
});
room.events.push(...result.events);
return { events: result.events };
}
export function viewForPlayer(room: Room, playerId: PlayerId): GameView | null {
return room.state ? viewFor(room.state, playerId) : null;
}
export function redactFor(events: GameEvent[], playerId: PlayerId): GameEvent[] {
return events.map((e) => redactEvent(e, playerId)).filter((e): e is GameEvent => e !== null);
}
+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>
+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>
+124
View File
@@ -0,0 +1,124 @@
// Websocket client + reactive session state (Svelte 5 runes).
import type { Command, GameEvent, GameView } from "@wizwar/engine";
import { cardDef } from "@wizwar/engine";
const SERVER_URL = import.meta.env.VITE_WIZWAR_SERVER ?? "ws://localhost:8787";
function humanize(e: GameEvent): string | null {
switch (e.type) {
case "gameStarted": return `Game started — ${e.players.join(", ")}. ${e.firstPlayer} goes first.`;
case "turnStarted": return `${e.player}'s turn (round ${e.round}) —`;
case "turnSkipped": return `${e.player} loses a turn.`;
case "extraTurnStarted": return `${e.player} takes an extra turn!`;
case "moved": return null; // too chatty for the log
case "numberPlayedForMovement": return `${e.player} plays a ${e.value} for movement (${e.newAllowance} total).`;
case "punched": return `${e.attacker} punches ${e.target}!`;
case "spellCast": {
const num = e.numberValue ? ` with a ${e.numberValue}` : "";
const at = e.target ? ` at ${e.target}` : "";
return `${e.caster} casts ${cardDef(e.cardId).name}${num}${at}.`;
}
case "counteractionPlayed": return `${e.player} counters with ${cardDef(e.cardId).name}!`;
case "counterNullified": return `${cardDef(e.card.cardId).name} is nullified by Anti-Anti!`;
case "attackAbsorbedIntoHand": return `${e.player} absorbs the spell into their hand!`;
case "attackResolved":
if (e.redirected) return `The spell is reflected back at ${e.attacker}!`;
if (e.fullyStopped) return `The attack is completely stopped.`;
return null; // the damaged event tells the story
case "damaged": return `${e.player} takes ${e.amount} damage (${e.source}) — ${e.lifeAfter} life left.`;
case "stunned": return `${e.player} is stunned and loses a turn!`;
case "knockedBack": return `${e.player} is knocked back ${e.squares} square(s)!`;
case "stonesDestroyed": return `${e.player}'s magic stones are destroyed!`;
case "wallCreated": return `A wall appears!`;
case "wallDestroyed": return e.wasDoor ? `A door is blasted to rubble!` : `A wall crumbles!`;
case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`;
case "trapSprung": return `${e.player} walked into an old TRAP! Lose a turn.`;
case "trapRedrawnDuringDeal": return null;
case "died": return `${e.player} is dead${e.killedBy ? ` — killed by ${e.killedBy}` : ""}.`;
case "handTaken": return `${e.to} takes ${e.count} cards from ${e.from}'s body.`;
case "treasurePickedUp": return `${e.player} grabs ${e.owner}'s treasure!`;
case "treasureDropped": return e.onHomeOf ? `${e.player} drops a treasure on ${e.onHomeOf}'s home base!` : `${e.player} drops a treasure.`;
case "playerEliminated": return e.reason === "treasuresLost" ? `${e.player} is eliminated — both treasures lost!` : null;
case "cardsDiscarded": return `${e.player} discards ${e.cards.length} card(s).`;
case "cardsDrawn": return e.count > 0 ? `${e.player} draws ${e.count} card(s).` : null;
case "deckReshuffled": return `The discard pile is reshuffled (${e.size} cards).`;
case "turnEnded": return null;
case "gameWon": return `🏆 ${e.player} WINS ${e.reason === "treasures" ? "by treasure!" : "— last wizard standing!"}`;
default: return null;
}
}
class Net {
status = $state<"disconnected" | "connected">("disconnected");
roomId = $state<string | null>(null);
players = $state<string[]>([]);
hostId = $state<string | null>(null);
started = $state(false);
you = $state<string | null>(null);
view = $state<GameView | null>(null);
log = $state<string[]>([]);
error = $state<string | null>(null);
private ws: WebSocket | null = null;
connect(): void {
if (this.ws) return;
const ws = new WebSocket(SERVER_URL);
this.ws = ws;
ws.onopen = () => (this.status = "connected");
ws.onclose = () => {
this.status = "disconnected";
this.ws = null;
setTimeout(() => this.connect(), 1500);
};
ws.onmessage = (raw) => {
const msg = JSON.parse(raw.data as string);
switch (msg.type) {
case "room":
this.roomId = msg.roomId;
this.players = msg.players;
this.hostId = msg.hostId;
this.started = msg.started;
break;
case "state":
this.view = msg.view;
break;
case "events":
for (const e of msg.events as GameEvent[]) {
const line = humanize(e);
if (line) this.log = [...this.log, line];
}
break;
case "error":
this.error = msg.message;
setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
break;
}
};
}
private send(message: unknown): void {
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message));
}
create(name: string): void {
this.you = name;
this.send({ type: "create", name });
}
join(roomId: string, name: string): void {
this.you = name;
this.send({ type: "join", roomId, name });
}
start(): void {
this.send({ type: "start" });
}
command(command: Command): void {
this.send({ type: "command", command });
}
}
export const net = new Net();