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 practiceButton = document.querySelector("#practiceButton"); const onlineButton = document.querySelector("#onlineButton"); 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 gameMode = "practice"; let multiplayerState = "local"; let botTimer = 0; function createBoard() { return Array.from({ length: size }, () => Array(size).fill(empty)); } function setupGame() { clearBotTimer(); board = createBoard(); currentPlayer = "X"; captures = { X: 0, O: 0 }; gameOver = false; moveNumber = 0; winningCells = []; moveList.innerHTML = ""; render(); } function connectToServer() { gameMode = "online"; closeSocket(); localPlayer = ""; roomId = ""; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; socket = new WebSocket(`${protocol}//${window.location.host}/ws`); setMultiplayerState("connecting", "Connecting to matchmaker..."); updateModeButtons(); 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", (event) => { if (event.currentTarget !== socket) { return; } if (gameMode !== "online") { return; } localPlayer = ""; roomId = ""; setMultiplayerState("error", "Connection closed. Refresh to reconnect."); render(); }); socket.addEventListener("error", (event) => { if (event.currentTarget !== socket) { return; } setMultiplayerState("error", "Could not reach the multiplayer server."); render(); }); } function startPracticeMode() { gameMode = "practice"; closeSocket(); localPlayer = "X"; roomId = ""; multiplayerState = "local"; setupGame(); setMultiplayerState("local", "BOT practice. You are X. Random O replies after each move."); updateModeButtons(); } function closeSocket() { if (socket && socket.readyState <= WebSocket.OPEN) { socket.close(); } socket = null; } 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 = gameMode === "online" && 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 (multiplayerState === "local" && currentPlayer !== localPlayer) { return "Bot turn"; } if (currentPlayer === localPlayer) { return `Your turn (${localPlayer})`; } return `${currentPlayer} to play`; } function canPlayCell(row, col) { return (multiplayerState === "paired" || multiplayerState === "local") && !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; } if (gameMode === "online") { sendToServer({ type: "move", roomId, row, col, player: localPlayer }); return; } if (!gameOver) { setMultiplayerState("local", "Bot thinking..."); botTimer = window.setTimeout(playBotMove, 360); } } 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(activeState(), `${player} wins with four in a row.`); render(); return true; } if (isDraw()) { gameOver = true; setMultiplayerState(activeState(), "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." : waitingMessage(); setMultiplayerState(activeState(), `${captureMessage}${turnMessage}`); render(); flashCapturedCells(captured); if (source === "remote" && currentPlayer === localPlayer) { window.navigator.vibrate?.(40); } return true; } function activeState() { return gameMode === "practice" ? "local" : "paired"; } function waitingMessage() { return gameMode === "practice" ? "Bot turn." : `Waiting for ${currentPlayer}.`; } 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 playBotMove() { if (gameMode !== "practice" || gameOver || currentPlayer !== "O") { return; } const moves = legalMoves(); if (moves.length === 0) { return; } const move = moves[Math.floor(Math.random() * moves.length)]; applyMove(move.row, move.col, "O", "bot"); } function legalMoves() { const moves = []; for (let row = 0; row < size; row += 1) { for (let col = 0; col < size; col += 1) { if (board[row][col] === empty) { moves.push({ row, col }); } } } return moves; } function clearBotTimer() { if (botTimer) { window.clearTimeout(botTimer); botTimer = 0; } } function updateModeButtons() { practiceButton.classList.toggle("active", gameMode === "practice"); onlineButton.classList.toggle("active", gameMode === "online"); } 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 (gameMode === "online" && multiplayerState !== "paired") { return; } setupGame(); if (gameMode === "practice") { setMultiplayerState("local", "BOT practice. You are X. Random O replies after each move."); return; } setMultiplayerState("paired", `Game reset. You are ${localPlayer}. ${currentPlayer} plays first.`); sendToServer({ type: "reset", roomId }); }); practiceButton.addEventListener("click", startPracticeMode); onlineButton.addEventListener("click", () => { gameMode = "online"; localPlayer = ""; roomId = ""; multiplayerState = "connecting"; setupGame(); connectToServer(); }); startPracticeMode();