diff --git a/backend/src/games/base/game-handler.interface.ts b/backend/src/games/base/game-handler.interface.ts new file mode 100644 index 0000000..69b7c68 --- /dev/null +++ b/backend/src/games/base/game-handler.interface.ts @@ -0,0 +1,52 @@ +import type { WebSocket as WS } from 'ws'; +import type { GameType } from '../types.js'; + +export interface Player { + id: string; + ws: WS; + symbol: string | null; + [key: string]: any; // Allow game-specific player properties +} + +export interface GameState { + board?: any; + currentPlayer?: string; + winner?: string | null; + isDraw?: boolean; + players: Array<{ id: string; symbol: string | null; [key: string]: any }>; + queue: string[]; + [key: string]: any; // Allow game-specific state properties +} + +export interface AddPlayerResult { + success: boolean; + error?: string; + gameState?: GameState; + [key: string]: any; // Allow game-specific return properties +} + +export interface GameHandler { + // Handle incoming messages + handleMessage( + roomId: string, + playerId: string, + message: any, + ws: WS + ): void; + + // Add player to game + addPlayer( + roomId: string, + playerId: string, + ws: WS + ): AddPlayerResult; + + // Remove player + removePlayer(roomId: string, playerId: string): void; + + // Get current game state for a player + getGameState(roomId: string, playerId: string): GameState | null; + + // Get game type + getGameType(): string; +} diff --git a/backend/src/games/game-manager.ts b/backend/src/games/game-manager.ts new file mode 100644 index 0000000..51a928a --- /dev/null +++ b/backend/src/games/game-manager.ts @@ -0,0 +1,28 @@ +import type { GameHandler } from './base/game-handler.interface.js'; + +class GameManager { + private handlers = new Map(); + + register(gameType: string, handler: GameHandler): void { + this.handlers.set(gameType, handler); + console.log(`✅ Registered game handler: ${gameType}`); + } + + getHandler(gameType: string): GameHandler { + const handler = this.handlers.get(gameType); + if (!handler) { + throw new Error(`Unknown game type: ${gameType}`); + } + return handler; + } + + hasHandler(gameType: string): boolean { + return this.handlers.has(gameType); + } + + getAllGameTypes(): string[] { + return Array.from(this.handlers.keys()); + } +} + +export const gameManager = new GameManager(); diff --git a/backend/src/games/tic-tac-toe/tic-tac-toe.handler.ts b/backend/src/games/tic-tac-toe/tic-tac-toe.handler.ts new file mode 100644 index 0000000..d89aa6a --- /dev/null +++ b/backend/src/games/tic-tac-toe/tic-tac-toe.handler.ts @@ -0,0 +1,417 @@ +import type { WebSocket as WS } from 'ws'; +import type { GameHandler, Player, GameState, AddPlayerResult } from '../base/game-handler.interface.js'; + +type TicTacToePlayer = Player & { + symbol: 'X' | 'O' | null; +}; + +type TicTacToeGameState = GameState & { + board: (string | null)[]; + currentPlayer: 'X' | 'O'; + winner: string | null; + isDraw: boolean; +}; + +const games = new Map(); + +function createGame(roomId: string): TicTacToeGameState { + const game: TicTacToeGameState = { + board: Array(9).fill(null), + currentPlayer: 'X', + winner: null, + isDraw: false, + players: [], + queue: [], + }; + games.set(roomId, game); + return game; +} + +function getGame(roomId: string): TicTacToeGameState | undefined { + return games.get(roomId); +} + +function checkWinner(board: (string | null)[]): string | null { + const lines = [ + [0, 1, 2], [3, 4, 5], [6, 7, 8], // rows + [0, 3, 6], [1, 4, 7], [2, 5, 8], // columns + [0, 4, 8], [2, 4, 6], // diagonals + ]; + + for (const [a, b, c] of lines) { + if (board[a] && board[a] === board[b] && board[a] === board[c]) { + return board[a]; + } + } + return null; +} + +function checkDraw(board: (string | null)[]): boolean { + return board.every(cell => cell !== null) && !checkWinner(board); +} + +function broadcastState(roomId: string, excludePlayerId?: string): void { + const game = games.get(roomId); + if (!game) return; + + game.players.forEach(player => { + if (player.id !== excludePlayerId && player.ws.readyState === 1) { + player.ws.send(JSON.stringify({ + type: 'gameState', + game: { + board: game.board, + currentPlayer: game.currentPlayer, + winner: game.winner, + isDraw: game.isDraw, + yourSymbol: player.symbol, + players: game.players.map(p => ({ id: p.id, symbol: p.symbol })), + queue: game.queue, + }, + })); + } + }); +} + +export class TicTacToeHandler implements GameHandler { + getGameType(): string { + return 'tic-tac-toe'; + } + + addPlayer(roomId: string, playerId: string, ws: WS): AddPlayerResult { + let game = getGame(roomId); + if (!game) { + game = createGame(roomId); + } + + // Check if player already in game + if (game.players.find(p => p.id === playerId)) { + return { success: false, error: 'Player already in game' }; + } + + // If 2 players already playing, add to queue + const activePlayers = game.players.filter(p => p.symbol !== null); + if (activePlayers.length >= 2) { + game.queue.push(playerId); + const player: TicTacToePlayer = { id: playerId, ws, symbol: null }; + game.players.push(player); + + // Broadcast to other players + this.broadcastPlayerJoined(roomId, playerId, null); + + return { success: true, gameState: this.getGameState(roomId, playerId)! }; + } + + // Assign symbol (X or O) + const symbol = activePlayers.length === 0 ? 'X' : 'O'; + const player: TicTacToePlayer = { id: playerId, ws, symbol }; + game.players.push(player); + + // Broadcast to other players + this.broadcastPlayerJoined(roomId, playerId, symbol); + + return { success: true, gameState: this.getGameState(roomId, playerId)! }; + } + + private broadcastPlayerJoined(roomId: string, newPlayerId: string, symbol: 'X' | 'O' | null): void { + const game = getGame(roomId); + if (!game) return; + + game.players.forEach(player => { + if (player.id !== newPlayerId && player.ws.readyState === 1) { + const gameState = this.getGameState(roomId, player.id); + if (gameState) { + player.ws.send(JSON.stringify({ + type: 'playerJoined', + playerId: newPlayerId, + symbol, + game: gameState, + })); + } + } + }); + } + + removePlayer(roomId: string, playerId: string): void { + const game = getGame(roomId); + if (!game) return; + + const playerIndex = game.players.findIndex(p => p.id === playerId); + if (playerIndex === -1) return; + + const player = game.players[playerIndex]; + const wasActive = player.symbol !== null; + + // Remove from players + game.players.splice(playerIndex, 1); + + // Remove from queue if there + const queueIndex = game.queue.indexOf(playerId); + if (queueIndex !== -1) { + game.queue.splice(queueIndex, 1); + } + + // If an active player left, promote next in queue + if (wasActive && game.queue.length > 0) { + const nextPlayerId = game.queue.shift()!; + const nextPlayer = game.players.find(p => p.id === nextPlayerId); + if (nextPlayer) { + nextPlayer.symbol = player.symbol as 'X' | 'O' | null; + } + } + + // If no players left, delete game + if (game.players.length === 0) { + games.delete(roomId); + } else if (game.players.filter(p => p.symbol !== null).length < 2 && !game.winner && !game.isDraw) { + // Reset game if not enough players + game.board = Array(9).fill(null); + game.currentPlayer = 'X'; + game.winner = null; + game.isDraw = false; + } + + // Broadcast player left + this.broadcastPlayerLeft(roomId, playerId); + } + + private broadcastPlayerLeft(roomId: string, leftPlayerId: string): void { + const game = getGame(roomId); + if (!game) return; + + game.players.forEach(player => { + if (player.ws.readyState === 1) { + const gameState = this.getGameState(roomId, player.id); + if (gameState) { + player.ws.send(JSON.stringify({ + type: 'playerLeft', + playerId: leftPlayerId, + game: gameState, + })); + } + } + }); + } + + getGameState(roomId: string, playerId: string): GameState | null { + const game = getGame(roomId); + if (!game) return null; + + const player = game.players.find(p => p.id === playerId); + return { + board: game.board, + currentPlayer: game.currentPlayer, + winner: game.winner, + isDraw: game.isDraw, + yourSymbol: player?.symbol || null, + players: game.players.map(p => ({ id: p.id, symbol: p.symbol })), + queue: game.queue, + }; + } + + handleMessage(roomId: string, playerId: string, message: any, ws: WS): void { + switch (message.type) { + case 'move': + this.handleMove(roomId, playerId, message.position); + break; + case 'reset': + this.handleReset(roomId, playerId); + break; + case 'joinQueue': + this.handleJoinQueue(roomId, playerId); + break; + default: + ws.send(JSON.stringify({ type: 'error', message: 'Unknown message type' })); + } + } + + private handleMove(roomId: string, playerId: string, position: number): void { + const game = getGame(roomId); + if (!game) return; + + const player = game.players.find(p => p.id === playerId); + if (!player || !player.symbol) { + const ws = player?.ws; + if (ws && ws.readyState === 1) { + ws.send(JSON.stringify({ type: 'error', message: 'Player not in game' })); + } + return; + } + + if (game.winner || game.isDraw) { + const ws = player.ws; + if (ws && ws.readyState === 1) { + ws.send(JSON.stringify({ type: 'error', message: 'Game is over' })); + } + return; + } + + if (game.currentPlayer !== player.symbol) { + const ws = player.ws; + if (ws && ws.readyState === 1) { + ws.send(JSON.stringify({ type: 'error', message: 'Not your turn' })); + } + return; + } + + if (game.board[position] !== null) { + const ws = player.ws; + if (ws && ws.readyState === 1) { + ws.send(JSON.stringify({ type: 'error', message: 'Position already taken' })); + } + return; + } + + game.board[position] = player.symbol; + + const winner = checkWinner(game.board); + const isDraw = checkDraw(game.board); + + if (winner) { + game.winner = winner; + } else if (isDraw) { + game.isDraw = true; + } else { + game.currentPlayer = game.currentPlayer === 'X' ? 'O' : 'X'; + } + + // Broadcast current state + this.broadcastGameState(roomId); + + // Auto-start next game if there's a winner and queue + if (winner && game.queue.length > 0) { + setTimeout(() => { + const currentGame = getGame(roomId); + if (!currentGame) return; + + const loser = currentGame.players.find(p => p.symbol !== winner && p.symbol !== null); + if (loser) { + loser.symbol = null; + currentGame.queue.push(loser.id); + + const nextPlayerId = currentGame.queue.shift(); + if (nextPlayerId) { + const nextPlayer = currentGame.players.find(p => p.id === nextPlayerId); + if (nextPlayer) { + currentGame.board = Array(9).fill(null); + currentGame.currentPlayer = 'X'; + currentGame.winner = null; + currentGame.isDraw = false; + nextPlayer.symbol = winner === 'X' ? 'O' : 'X'; + + this.broadcastGameState(roomId); + } + } + } + }, 100); + } + // Auto-rematch on draw + else if (isDraw) { + setTimeout(() => { + const currentGame = getGame(roomId); + if (!currentGame) return; + + currentGame.board = Array(9).fill(null); + currentGame.currentPlayer = 'X'; + currentGame.winner = null; + currentGame.isDraw = false; + + this.broadcastGameState(roomId); + }, 1500); + } + } + + private handleReset(roomId: string, resettingPlayerId: string): void { + const game = getGame(roomId); + if (!game) return; + + const activePlayers = game.players.filter(p => p.symbol !== null); + const previousWinner = game.winner; + const wasDraw = game.isDraw; + const previousLoser = previousWinner ? activePlayers.find(p => p.symbol !== previousWinner && p.symbol !== null) : null; + const hasQueue = game.queue.length > 0; + + game.board = Array(9).fill(null); + game.currentPlayer = 'X'; + game.winner = null; + game.isDraw = false; + + if (previousWinner && hasQueue && previousLoser) { + previousLoser.symbol = null; + game.queue.push(previousLoser.id); + + const nextPlayerId = game.queue.shift(); + if (nextPlayerId) { + const nextPlayer = game.players.find(p => p.id === nextPlayerId); + if (nextPlayer) { + nextPlayer.symbol = previousWinner === 'X' ? 'O' : 'X'; + } + } + } else if (wasDraw && activePlayers.length === 2) { + // Rematch - players keep their symbols + } else if (activePlayers.length > 2) { + activePlayers.forEach(p => { + p.symbol = null; + game.queue.push(p.id); + }); + + const newPlayer1 = game.queue.shift(); + const newPlayer2 = game.queue.shift(); + + if (newPlayer1) { + const p1 = game.players.find(p => p.id === newPlayer1); + if (p1) p1.symbol = 'X'; + } + if (newPlayer2) { + const p2 = game.players.find(p => p.id === newPlayer2); + if (p2) p2.symbol = 'O'; + } + } + + this.broadcastGameState(roomId); + } + + private handleJoinQueue(roomId: string, playerId: string): void { + const game = getGame(roomId); + if (!game) return; + + const player = game.players.find(p => p.id === playerId); + if (!player) return; + + if (game.queue.includes(playerId)) { + return; + } + + if (player.symbol !== null) { + const ws = player.ws; + if (ws && ws.readyState === 1) { + ws.send(JSON.stringify({ type: 'error', message: 'Cannot join queue while playing' })); + } + return; + } + + game.queue.push(playerId); + this.broadcastGameState(roomId); + } + + private broadcastGameState(roomId: string): void { + const game = getGame(roomId); + if (!game) return; + + game.players.forEach(player => { + if (player.ws.readyState === 1) { + player.ws.send(JSON.stringify({ + type: 'gameState', + game: { + board: game.board, + currentPlayer: game.currentPlayer, + winner: game.winner, + isDraw: game.isDraw, + yourSymbol: player.symbol, + players: game.players.map(p => ({ id: p.id, symbol: p.symbol })), + queue: game.queue, + }, + })); + } + }); + } +} diff --git a/backend/src/games/types.ts b/backend/src/games/types.ts new file mode 100644 index 0000000..bb7035a --- /dev/null +++ b/backend/src/games/types.ts @@ -0,0 +1,7 @@ +export type GameType = 'tic-tac-toe' | 'connect-four' | 'checkers'; + +export interface GameMessage { + type: 'move' | 'reset' | 'joinQueue' | 'init'; + gameType?: GameType; + [key: string]: any; +} diff --git a/backend/src/services/websocket.service.ts b/backend/src/services/websocket.service.ts index fccd327..eb9b1a6 100644 --- a/backend/src/services/websocket.service.ts +++ b/backend/src/services/websocket.service.ts @@ -1,21 +1,37 @@ import { WebSocketServer, WebSocket as WS } from 'ws'; import { createServer } from 'http'; -import { getGame, addPlayer, removePlayer, makeMove, resetGame, joinQueue, broadcastToRoom } from './game.service.js'; +import { gameManager } from '../games/game-manager.js'; +import { TicTacToeHandler } from '../games/tic-tac-toe/tic-tac-toe.handler.js'; +import type { GameType } from '../games/types.js'; let wss: WebSocketServer | null = null; +// Register game handlers +gameManager.register('tic-tac-toe', new TicTacToeHandler()); + export function createWebSocketServer(server: any) { wss = new WebSocketServer({ server, path: '/api/ws' }); wss.on('connection', (ws: WS, req) => { const url = new URL(req.url || '', `http://${req.headers.host}`); + const gameType = (url.searchParams.get('gameType') || 'tic-tac-toe') as GameType; const roomId = url.searchParams.get('room') || 'default'; const playerId = url.searchParams.get('playerId') || `player-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - console.log(`[WebSocket] Player ${playerId} connected to room ${roomId}`); + console.log(`[WebSocket] Player ${playerId} connected to room ${roomId} (game: ${gameType})`); + + // Get game handler + let gameHandler; + try { + gameHandler = gameManager.getHandler(gameType); + } catch (error) { + ws.send(JSON.stringify({ type: 'error', message: `Unknown game type: ${gameType}` })); + ws.close(); + return; + } // Add player to game - const result = addPlayer(roomId, playerId, ws); + const result = gameHandler.addPlayer(roomId, playerId, ws); if (!result.success) { ws.send(JSON.stringify({ type: 'error', message: result.error })); @@ -24,217 +40,22 @@ export function createWebSocketServer(server: any) { } // Send initial game state - const initialGame = getGame(roomId); - if (initialGame) { + if (result.gameState) { ws.send(JSON.stringify({ type: 'gameState', - game: { - board: initialGame.board, - currentPlayer: initialGame.currentPlayer, - winner: initialGame.winner, - isDraw: initialGame.isDraw, - yourSymbol: result.symbol, - players: initialGame.players.map(p => ({ id: p.id, symbol: p.symbol })), - queue: initialGame.queue, - }, + game: result.gameState, })); } - // Broadcast player joined to other players - const joinGame = getGame(roomId); - if (joinGame) { - joinGame.players.forEach(player => { - if (player.id !== playerId && player.ws.readyState === 1) { - player.ws.send(JSON.stringify({ - type: 'playerJoined', - playerId, - symbol: result.symbol, - game: { - board: joinGame.board, - currentPlayer: joinGame.currentPlayer, - winner: joinGame.winner, - isDraw: joinGame.isDraw, - yourSymbol: player.symbol, - players: joinGame.players.map(p => ({ id: p.id, symbol: p.symbol })), - queue: joinGame.queue, - }, - })); - } - }); - } + // Handler will broadcast player joined internally // Handle messages ws.on('message', (data: Buffer) => { try { const message = JSON.parse(data.toString()); - switch (message.type) { - case 'move': - if (typeof message.position !== 'number' || message.position < 0 || message.position > 8) { - ws.send(JSON.stringify({ type: 'error', message: 'Invalid position' })); - return; - } - - const moveResult = makeMove(roomId, playerId, message.position); - if (!moveResult.success) { - ws.send(JSON.stringify({ type: 'error', message: moveResult.error })); - return; - } - - const updatedGame = getGame(roomId); - if (updatedGame) { - // Broadcast current game state (with winner if game ended) - updatedGame.players.forEach(player => { - if (player.ws.readyState === 1) { // WebSocket.OPEN - player.ws.send(JSON.stringify({ - type: 'gameState', - game: { - board: updatedGame.board, - currentPlayer: updatedGame.currentPlayer, - winner: updatedGame.winner, - isDraw: updatedGame.isDraw, - yourSymbol: player.symbol, - players: updatedGame.players.map(p => ({ id: p.id, symbol: p.symbol })), - queue: updatedGame.queue, - }, - })); - } - }); - - // Auto-rematch on draw - same two players play again - if (moveResult.autoRematch && updatedGame.isDraw) { - // Reset board, keep same players and symbols - updatedGame.board = Array(9).fill(null); - updatedGame.currentPlayer = 'X'; - updatedGame.winner = null; - updatedGame.isDraw = false; - // Players keep their symbols, so no change needed - - // Broadcast new game state immediately - setTimeout(() => { - const rematchGame = getGame(roomId); - if (rematchGame) { - rematchGame.players.forEach(player => { - if (player.ws.readyState === 1) { - player.ws.send(JSON.stringify({ - type: 'gameState', - game: { - board: rematchGame.board, - currentPlayer: rematchGame.currentPlayer, - winner: rematchGame.winner, - isDraw: rematchGame.isDraw, - yourSymbol: player.symbol, - players: rematchGame.players.map(p => ({ id: p.id, symbol: p.symbol })), - queue: rematchGame.queue, - }, - })); - } - }); - } - }, 1500); // Small delay to show draw message before rematch - } - // Auto-start next game if there's a winner and queue - else if (moveResult.autoStartNext && updatedGame.winner && updatedGame.queue.length > 0) { - const winner = updatedGame.winner; - const loser = updatedGame.players.find(p => p.symbol !== winner && p.symbol !== null); - - if (loser) { - // Move loser to end of queue - loser.symbol = null; - updatedGame.queue.push(loser.id); - - // Promote next player from queue - const nextPlayerId = updatedGame.queue.shift(); - if (nextPlayerId) { - const nextPlayer = updatedGame.players.find(p => p.id === nextPlayerId); - if (nextPlayer) { - // Reset board and assign opposite symbol to new player - updatedGame.board = Array(9).fill(null); - updatedGame.currentPlayer = 'X'; - updatedGame.winner = null; - updatedGame.isDraw = false; - nextPlayer.symbol = winner === 'X' ? 'O' : 'X'; - // Winner keeps their symbol, so no change needed - - // Broadcast new game state immediately - updatedGame.players.forEach(player => { - if (player.ws.readyState === 1) { - player.ws.send(JSON.stringify({ - type: 'gameState', - game: { - board: updatedGame.board, - currentPlayer: updatedGame.currentPlayer, - winner: updatedGame.winner, - isDraw: updatedGame.isDraw, - yourSymbol: player.symbol, - players: updatedGame.players.map(p => ({ id: p.id, symbol: p.symbol })), - queue: updatedGame.queue, - }, - })); - } - }); - } - } - } - } - } - break; - - case 'reset': - resetGame(roomId, playerId); - const resetGameState = getGame(roomId); - if (resetGameState) { - // Broadcast to all players with their individual symbols - resetGameState.players.forEach(player => { - if (player.ws.readyState === 1) { // WebSocket.OPEN - player.ws.send(JSON.stringify({ - type: 'gameState', - game: { - board: resetGameState.board, - currentPlayer: resetGameState.currentPlayer, - winner: resetGameState.winner, - isDraw: resetGameState.isDraw, - yourSymbol: player.symbol, - players: resetGameState.players.map(p => ({ id: p.id, symbol: p.symbol })), - queue: resetGameState.queue, - }, - })); - } - }); - } - break; - - case 'joinQueue': - const joinResult = joinQueue(roomId, playerId); - if (!joinResult.success) { - ws.send(JSON.stringify({ type: 'error', message: joinResult.error })); - return; - } - const queueGameState = getGame(roomId); - if (queueGameState) { - // Broadcast updated game state to all players - queueGameState.players.forEach(player => { - if (player.ws.readyState === 1) { // WebSocket.OPEN - player.ws.send(JSON.stringify({ - type: 'gameState', - game: { - board: queueGameState.board, - currentPlayer: queueGameState.currentPlayer, - winner: queueGameState.winner, - isDraw: queueGameState.isDraw, - yourSymbol: player.symbol, - players: queueGameState.players.map(p => ({ id: p.id, symbol: p.symbol })), - queue: queueGameState.queue, - }, - })); - } - }); - } - break; - - default: - ws.send(JSON.stringify({ type: 'error', message: 'Unknown message type' })); - } + // Route message to game handler + gameHandler.handleMessage(roomId, playerId, message, ws); } catch (error) { console.error('[WebSocket] Error handling message:', error); ws.send(JSON.stringify({ type: 'error', message: 'Invalid message format' })); @@ -244,29 +65,8 @@ export function createWebSocketServer(server: any) { // Handle disconnect ws.on('close', () => { console.log(`[WebSocket] Player ${playerId} disconnected from room ${roomId}`); - removePlayer(roomId, playerId); - - const game = getGame(roomId); - if (game) { - // Broadcast to all remaining players with their individual symbols - game.players.forEach(player => { - if (player.ws.readyState === 1) { // WebSocket.OPEN - player.ws.send(JSON.stringify({ - type: 'playerLeft', - playerId, - game: { - board: game.board, - currentPlayer: game.currentPlayer, - winner: game.winner, - isDraw: game.isDraw, - yourSymbol: player.symbol, - players: game.players.map(p => ({ id: p.id, symbol: p.symbol })), - queue: game.queue, - }, - })); - } - }); - } + gameHandler.removePlayer(roomId, playerId); + // Handler will broadcast player left internally }); ws.on('error', (error) => { diff --git a/frontend/src/pages/TicTacToeApp.tsx b/frontend/src/pages/TicTacToeApp.tsx index f9facb4..b9747c3 100644 --- a/frontend/src/pages/TicTacToeApp.tsx +++ b/frontend/src/pages/TicTacToeApp.tsx @@ -22,7 +22,7 @@ export function TicTacToeApp() { const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000/api'; const wsProtocol = apiUrl.startsWith('https') ? 'wss:' : 'ws:'; const wsHost = apiUrl.replace(/^https?:\/\//, '').replace('/api', ''); - const wsUrl = `${wsProtocol}//${wsHost}/api/ws?room=default&playerId=${playerIdRef.current}`; + const wsUrl = `${wsProtocol}//${wsHost}/api/ws?gameType=tic-tac-toe&room=default&playerId=${playerIdRef.current}`; const ws = new WebSocket(wsUrl); wsRef.current = ws;