const size = 5; const target = 4; const empty = ""; const directions = [ [-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1], ]; const boardElement = document.querySelector("#board"); const messageElement = document.querySelector("#message"); const turnBadge = document.querySelector("#turnBadge"); const resetButton = document.querySelector("#resetButton"); const xCapturesElement = document.querySelector("#xCaptures"); const oCapturesElement = document.querySelector("#oCaptures"); const moveList = document.querySelector("#moveList"); const networkStatus = document.querySelector("#networkStatus"); let board; let currentPlayer; let captures; let gameOver; let moveNumber; let winningCells; let socket; let localPlayer = ""; let roomId = ""; let multiplayerState = "connecting"; function createBoard() { return Array.from({ length: size }, () => Array(size).fill(empty)); } function setupGame() { board = createBoard(); currentPlayer = "X"; captures = { X: 0, O: 0 }; gameOver = false; moveNumber = 0; winningCells = []; moveList.innerHTML = ""; render(); } function connectToServer() { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; socket = new WebSocket(`${protocol}//${window.location.host}/ws`); setMultiplayerState("connecting", "Connecting to matchmaker..."); socket.addEventListener("open", () => { setMultiplayerState("waiting", "Connected. Waiting for another player..."); }); socket.addEventListener("message", (event) => { const payload = JSON.parse(event.data); handleServerMessage(payload); }); socket.addEventListener("close", () => { localPlayer = ""; roomId = ""; setMultiplayerState("error", "Connection closed. Refresh to reconnect."); render(); }); socket.addEventListener("error", () => { setMultiplayerState("error", "Could not reach the multiplayer server."); render(); }); } function handleServerMessage(payload) { if (payload.type === "waiting") { localPlayer = ""; roomId = ""; multiplayerState = "waiting"; setupGame(); setMultiplayerState("waiting", "Waiting for another player..."); return; } if (payload.type === "paired") { localPlayer = payload.player; roomId = payload.roomId; multiplayerState = "paired"; setupGame(); setMultiplayerState("paired", `You are ${localPlayer}. ${currentPlayer} plays first.`); return; } if (payload.type === "move") { applyMove(payload.row, payload.col, payload.player, "remote"); return; } if (payload.type === "reset") { multiplayerState = "paired"; setupGame(); setMultiplayerState("paired", `Game reset. You are ${localPlayer}. ${currentPlayer} plays first.`); return; } if (payload.type === "opponent-left") { localPlayer = ""; roomId = ""; gameOver = true; setMultiplayerState("error", payload.message || "Opponent disconnected. Waiting for a new player..."); render(); return; } if (payload.type === "error") { setMultiplayerState("error", payload.message || "Multiplayer error."); render(); } } function render() { boardElement.innerHTML = ""; for (let row = 0; row < size; row += 1) { for (let col = 0; col < size; col += 1) { const cell = document.createElement("button"); cell.className = "cell"; cell.type = "button"; cell.role = "gridcell"; cell.dataset.row = row; cell.dataset.col = col; cell.setAttribute("aria-label", cellLabel(row, col)); cell.disabled = !canPlayCell(row, col); if (winningCells.some(([winRow, winCol]) => winRow === row && winCol === col)) { cell.classList.add("winning"); } if (board[row][col]) { const mark = document.createElement("span"); mark.className = `mark mark-${board[row][col].toLowerCase()}`; mark.textContent = board[row][col]; cell.append(mark); } cell.addEventListener("click", () => playLocalMove(row, col)); boardElement.append(cell); } } turnBadge.textContent = turnLabel(); turnBadge.className = `turn-badge player-${currentPlayer.toLowerCase()}`; xCapturesElement.textContent = captures.X; oCapturesElement.textContent = captures.O; resetButton.disabled = multiplayerState !== "paired"; } function turnLabel() { if (gameOver) { return "Game over"; } if (multiplayerState === "connecting") { return "Connecting"; } if (multiplayerState === "waiting") { return "Waiting"; } if (multiplayerState === "error") { return "Offline"; } if (currentPlayer === localPlayer) { return `Your turn (${localPlayer})`; } return `${currentPlayer} to play`; } function canPlayCell(row, col) { return multiplayerState === "paired" && !gameOver && localPlayer === currentPlayer && board[row][col] === empty; } function cellLabel(row, col) { const mark = board[row][col]; const position = `row ${row + 1}, column ${col + 1}`; return mark ? `${mark} at ${position}` : `Empty cell at ${position}`; } function playLocalMove(row, col) { if (!canPlayCell(row, col)) { return; } const applied = applyMove(row, col, localPlayer, "local"); if (!applied) { return; } sendToServer({ type: "move", roomId, row, col, player: localPlayer }); } function applyMove(row, col, player, source) { if (gameOver || player !== currentPlayer || board[row][col] !== empty) { return false; } moveNumber += 1; board[row][col] = player; const captured = captureFrom(row, col, player); captures[player] += captured.length; const win = findWin(player); addMove(row, col, player, captured.length); if (win.length > 0) { winningCells = win; gameOver = true; setMultiplayerState("paired", `${player} wins with four in a row.`); render(); return true; } if (isDraw()) { gameOver = true; setMultiplayerState("paired", "Draw. The board is full."); render(); return true; } currentPlayer = opponentOf(player); const captureMessage = captured.length > 0 ? `${player} captured ${captured.length}. ` : ""; const turnMessage = currentPlayer === localPlayer ? "Your turn." : `Waiting for ${currentPlayer}.`; setMultiplayerState("paired", `${captureMessage}${turnMessage}`); render(); flashCapturedCells(captured); if (source === "remote" && currentPlayer === localPlayer) { window.navigator.vibrate?.(40); } return true; } function captureFrom(row, col, player) { const opponent = opponentOf(player); const captured = []; for (const [rowStep, colStep] of directions) { const candidates = []; let nextRow = row + rowStep; let nextCol = col + colStep; while (isInside(nextRow, nextCol) && board[nextRow][nextCol] === opponent) { candidates.push([nextRow, nextCol]); nextRow += rowStep; nextCol += colStep; } if (candidates.length > 0 && isInside(nextRow, nextCol) && board[nextRow][nextCol] === player) { captured.push(...candidates); } } for (const [captureRow, captureCol] of captured) { board[captureRow][captureCol] = empty; } return captured; } function findWin(player) { const axes = [ [0, 1], [1, 0], [1, 1], [1, -1], ]; for (let row = 0; row < size; row += 1) { for (let col = 0; col < size; col += 1) { if (board[row][col] !== player) { continue; } for (const [rowStep, colStep] of axes) { const cells = [[row, col]]; for (let index = 1; index < target; index += 1) { const nextRow = row + rowStep * index; const nextCol = col + colStep * index; if (!isInside(nextRow, nextCol) || board[nextRow][nextCol] !== player) { break; } cells.push([nextRow, nextCol]); } if (cells.length === target) { return cells; } } } } return []; } function isDraw() { return board.every((row) => row.every((cell) => cell !== empty)); } function opponentOf(player) { return player === "X" ? "O" : "X"; } function isInside(row, col) { return row >= 0 && row < size && col >= 0 && col < size; } function addMove(row, col, player, captureCount) { const item = document.createElement("li"); const captureText = captureCount === 0 ? "" : `, captured ${captureCount}`; item.textContent = `${moveNumber}. ${player} to ${row + 1},${col + 1}${captureText}`; moveList.prepend(item); } function setMultiplayerState(state, text) { multiplayerState = state; networkStatus.textContent = text; networkStatus.className = `network-status ${state}`; messageElement.textContent = text; } function sendToServer(payload) { if (!socket || socket.readyState !== WebSocket.OPEN) { setMultiplayerState("error", "Connection lost. Refresh to reconnect."); render(); return; } socket.send(JSON.stringify(payload)); } function flashCapturedCells(captured) { for (const [row, col] of captured) { const cell = boardElement.querySelector(`[data-row="${row}"][data-col="${col}"]`); if (!cell) { continue; } cell.classList.add("captured"); window.setTimeout(() => cell.classList.remove("captured"), 380); } } resetButton.addEventListener("click", () => { if (multiplayerState !== "paired") { return; } setupGame(); setMultiplayerState("paired", `Game reset. You are ${localPlayer}. ${currentPlayer} plays first.`); sendToServer({ type: "reset", roomId }); }); setupGame(); connectToServer();