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
+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();