/// /** * Battleship Multiplayer - Game Room Pattern * * ARCHITECTURE: * - Each player has their own instance with shared state Cells * - myName and myPlayerNumber determine what this player can see * - Ships are only visible on your own board * - Shots you've fired are visible on enemy board * * Uses properly typed Cells instead of JSON serialization. * * See: lobby.tsx for the lobby entry point */ import { action, computed, NAME, pattern, UI } from "commontools"; import { areAllShipsSunk, buildShipPositions, COLS, findShipAt, type GameState, getInitials, GRID_INDICES, isShipSunk, type RoomInput, type RoomOutput, ROWS, SHIP_NAMES, type ShotsState, type SquareState, } from "./schemas.tsx"; // ============================================================================= // STATIC STYLES (at module scope - safe because they're plain objects, not JSX) // ============================================================================= const headerCellStyle = { backgroundColor: "#1a1a2e", display: "flex", alignItems: "center", justifyContent: "center", color: "#888", fontSize: "12px", fontWeight: "bold", height: "30px", }; const baseCellStyle = { display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", fontSize: "16px", fontWeight: "bold", height: "32px", width: "32px", }; const gridContainerStyle = { display: "grid", gridTemplateColumns: "30px repeat(10, 32px)", gap: "2px", backgroundColor: "#000", padding: "2px", }; // ============================================================================= // PATTERN // ============================================================================= const BattleshipRoom = pattern( ({ gameName: _gameName, player1, player2, shots, gameState, myName, myPlayerNumber, }) => { // Cast once for use throughout const playerNum = myPlayerNumber as 1 | 2; // Fire shot action - closes over pattern state directly const fireShot = action<{ row: number; col: number }>(({ row, col }) => { const state = gameState.get(); // Can't fire if game is over if (state.phase === "finished") return; // Can only fire on your turn if (state.currentTurn !== playerNum) return; // Get current shots const currentShots = shots.get(); // Target the opponent's board (shots are stored as "shots received by player X") const targetPlayerNum = playerNum === 1 ? 2 : 1; const targetShots = currentShots[targetPlayerNum]; // Can't fire at same spot twice if (targetShots[row]?.[col] !== "empty") return; // Get opponent's data directly const opponentData = targetPlayerNum === 1 ? player1.get() : player2.get(); if (!opponentData) return; // Debug: Log the raw ships data to understand reactive proxy behavior console.log("[fireShot] opponentData:", opponentData); console.log("[fireShot] opponentData.ships:", opponentData.ships); console.log( "[fireShot] ships array length:", opponentData.ships?.length, ); if (opponentData.ships && opponentData.ships.length > 0) { console.log("[fireShot] first ship element:", opponentData.ships[0]); console.log( "[fireShot] first ship type:", opponentData.ships[0]?.type, ); } // Get ships array, filtering out any undefined elements (can happen with reactive proxies) const ships = (opponentData.ships || []).filter( (s): s is NonNullable => s != null && s.type != null, ); console.log("[fireShot] filtered ships length:", ships.length); if (ships.length === 0) { console.warn("[fireShot] No valid ships found in opponent data"); return; } // Check if hit const hitShip = findShipAt(ships, { row, col }); // Update shots grid const newTargetShots = targetShots.map((r, ri) => r.map((c, ci) => ri === row && ci === col ? (hitShip ? "hit" : "miss") : c ) ) as SquareState[][]; const updatedShotsData: ShotsState = { ...currentShots, [targetPlayerNum]: newTargetShots, }; shots.set(updatedShotsData); // Build message let message = ""; const coordStr = `${COLS[col]}${row + 1}`; if (hitShip) { if (isShipSunk(hitShip, newTargetShots)) { message = `${coordStr}: Hit! You sunk the ${ SHIP_NAMES[hitShip.type] }!`; } else { message = `${coordStr}: Hit!`; } } else { message = `${coordStr}: Miss.`; } // Check for win (use filtered ships array) const allSunk = ships.length > 0 && areAllShipsSunk(ships, newTargetShots); if (allSunk) { // Get winner's data directly const winnerData = playerNum === 1 ? player1.get() : player2.get(); const winnerName = winnerData?.name || `Player ${playerNum}`; const newState: GameState = { ...state, phase: "finished", winner: playerNum, lastMessage: `${message} ${winnerName} wins!`, }; gameState.set(newState); } else { // Switch turns const nextTurn = playerNum === 1 ? 2 : 1; const nextPlayerData = nextTurn === 1 ? player1.get() : player2.get(); const nextPlayerName = nextPlayerData?.name || `Player ${nextTurn}`; const newState: GameState = { ...state, currentTurn: nextTurn, lastMessage: `${message} ${nextPlayerName}'s turn.`, }; gameState.set(newState); } }); // Board cells computed directly const myBoardCells = computed(() => { const playerNum = myPlayerNumber as 1 | 2; const playerData = playerNum === 1 ? player1.get() : player2.get(); const currentShots = shots.get(); // Guard against null state during hydration if (!playerData || !currentShots) { return []; } // Filter out undefined ship elements (reactive proxy issue across pattern boundaries) const myShips = (playerData.ships || []).filter( (s): s is NonNullable => s != null && s.type != null, ); const myShots = currentShots[playerNum] || []; const shipPositions = buildShipPositions(myShips); return GRID_INDICES.map(({ row, col }) => { const shotState: SquareState = myShots[row]?.[col] ?? "empty"; const hasShip = !!shipPositions[`${row},${col}`]; const bgColor = shotState === "hit" ? "#dc2626" : shotState === "miss" ? "#374151" : hasShip ? "#22c55e" : "#1e3a5f"; const content = shotState === "hit" ? "X" : shotState === "miss" ? "O" : ""; return { row, col, bgColor, content, gridRow: `${row + 2}`, gridCol: `${col + 2}`, }; }); }); const enemyBoardCells = computed(() => { const playerNum = myPlayerNumber as 1 | 2; const gs = gameState.get(); const currentShots = shots.get(); // Guard against null state during hydration if (!gs || !currentShots) { return []; } const isFinished = gs.phase === "finished"; const oppNum = playerNum === 1 ? 2 : 1; const oppShots = currentShots[oppNum] || []; return GRID_INDICES.map(({ row, col }) => { const shotState: SquareState = oppShots[row]?.[col] ?? "empty"; const bgColor = shotState === "hit" ? "#dc2626" : shotState === "miss" ? "#374151" : "#1e3a5f"; const content = shotState === "hit" ? "X" : shotState === "miss" ? "O" : ""; const canClick = shotState === "empty" && !isFinished; return { row, col, bgColor, content, gridRow: `${row + 2}`, gridCol: `${col + 2}`, cursor: canClick ? "pointer" : "default", }; }); }); // Consolidated computed values - reduces subscription overhead const myColor = computed(() => { const data = myPlayerNumber === 1 ? player1.get() : player2.get(); return data?.color || "#3b82f6"; }); // Consolidated player 1 display data const player1Display = computed(() => { const p1 = player1.get(); const gs = gameState.get(); return { color: p1?.color || "#3b82f6", name: p1?.name || "Player 1", initials: getInitials(p1?.name || "P1"), bgColor: gs?.currentTurn === 1 ? "#1e40af" : "#1e293b", status: gs?.currentTurn === 1 ? "Active" : "Waiting", }; }); // Consolidated player 2 display data const player2Display = computed(() => { const p2 = player2.get(); const gs = gameState.get(); return { color: p2?.color || "#ef4444", name: p2?.name || "Player 2", initials: getInitials(p2?.name || "P2"), bgColor: gs?.currentTurn === 2 ? "#1e40af" : "#1e293b", status: gs?.currentTurn === 2 ? "Active" : "Waiting", }; }); // Consolidated status display data const statusDisplay = computed(() => { const gs = gameState.get(); if (!gs) { return { showTurnIndicator: "none", bgColor: "#1e293b", message: "Loading...", lastMessage: "", }; } const finished = gs.phase === "finished"; const won = gs.winner === myPlayerNumber; const myTurn = gs.currentTurn === myPlayerNumber; return { showTurnIndicator: myTurn && !finished ? "block" : "none", bgColor: finished ? (won ? "#166534" : "#991b1b") : myTurn ? "#1e40af" : "#1e293b", message: finished ? (won ? "Victory! You sunk all enemy ships!" : "Defeat. Your fleet was destroyed.") : (myTurn ? "Your turn - fire at the enemy fleet!" : "Waiting for opponent..."), lastMessage: gs.lastMessage || "", }; }); return { [NAME]: computed(() => `Battleship: ${myName}`), [UI]: (
{/* Header */}

BATTLESHIP

Playing as
{getInitials(myName)}
{myName}
{/* Status bar */}
{statusDisplay.message}
{/* Turn indicator */}
YOUR TURN
{/* Game boards */}
{/* My Board (left) - uses pre-computed headers from module scope */}

Your Fleet

{COLS.map((c, i) => (
{c}
))} {ROWS.map((rowIdx) => (
{rowIdx + 1}
))} {myBoardCells.map((cell, idx) => (
{cell.content}
))}
{/* Enemy Board (right) - uses event delegation for clicks */}

Enemy Waters

{COLS.map((c, i) => (
{c}
))} {ROWS.map((rowIdx) => (
{rowIdx + 1}
))} {enemyBoardCells.map((cell, idx) => (
fireShot.send({ row: cell.row, col: cell.col })} > {cell.content}
))}
{/* Legend */}
Your Ship Unknown X Hit O Miss
{/* Players sidebar */}
{/* Player 1 */}
{player1Display.initials}
{player1Display.name} {myPlayerNumber === 1 ? " (you)" : ""}
{player1Display.status}
vs
{/* Player 2 */}
{player2Display.initials}
{player2Display.name} {myPlayerNumber === 2 ? " (you)" : ""}
{player2Display.status}
{/* Last message from game state */}
{statusDisplay.lastMessage}
), myName, myPlayerNumber, fireShot, }; }, ); export default BattleshipRoom;