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,
};
}