/** * Tetris Battle Royale Server * * Express + Socket.io server that handles all game logic authoritatively. * Manages a single global lobby with 2-8 players competing in real-time. * * @module server */ const express = require('express'); const http = require('http'); const { Server } = require('socket.io'); const path = require('path'); // Import pure game logic functions const { BOARD_WIDTH, BOARD_HEIGHT, GARBAGE_COLOR, TETROMINOS, TETROMINO_KEYS, createEmptyBoard, getPieceFromType, isValidPosition, getGhostY, checkBoardOverflow, addGarbageRow, lockPieceToBoard, clearRows: clearRowsFromBoard, generatePieceBag, createPieceQueue } = require('./game-logic'); const app = express(); const server = http.createServer(app); const io = new Server(server); // Serve static files app.use(express.static(path.join(__dirname, '../public'))); /** * @typedef {Object} Player * @property {string} id - Socket.io connection ID * @property {string} name - Player's display name * @property {number} score - Current score * @property {number} lines - Total lines cleared * @property {number} level - Current level (affects drop speed) * @property {number[][]} board - 20x10 game board * @property {object|null} currentPiece - Currently falling piece * @property {object|null} nextPiece - Next piece to spawn * @property {object|null} holdPiece - Held piece (for hold mechanic) * @property {boolean} canHold - Whether hold is available this turn * @property {boolean} eliminated - Whether player has been eliminated * @property {boolean} ready - Whether player is ready to start * @property {number} dropCounter - Frame counter for auto-drop * @property {number} dropInterval - Milliseconds between auto-drops * @property {object[]} garbageReceived - History of garbage received * @property {number} zoneMeter - Zone meter (0-100), increases by line clears * @property {boolean} zoneActive - Whether Zone is currently active * @property {number} zoneStartTime - Timestamp when Zone started * @property {number} zoneLineCount - Lines cleared during current Zone * @property {number} zoneTotalDuration - Total Zone duration in ms (20000ms = 20 seconds) */ /** * @typedef {Object} Spectator * @property {string} id - Socket.io connection ID * @property {string} name - Spectator's display name */ /** * Global lobby state - holds all game state server-side * * @type {Object} * @property {Map} players - Active players (socketId -> Player) * @property {Map} spectators - Spectators (socketId -> Spectator) * @property {boolean} gameStarted - Whether game is currently running * @property {NodeJS.Timeout|null} gameInterval - setInterval reference for game tick * @property {string[]} pieceQueue - Shared piece queue (7-bag system) * @property {Map} playerSequenceIndex - Each player's position in piece queue */ const lobby = { players: new Map(), spectators: new Map(), gameStarted: false, gameInterval: null, pieceQueue: [], playerSequenceIndex: new Map() }; const PORT = process.env.PORT || 3000; const LOBBY_ROOM = 'global-lobby'; io.on('connection', (socket) => { console.log('Player connected:', socket.id); /** * Handle player joining the lobby * @event join-lobby * @param {object} data - Event data * @param {string} data.playerName - Name of the player joining */ socket.on('join-lobby', ({ playerName }) => { // Add socket to lobby room so they receive lobby events socket.join(LOBBY_ROOM); socket.data.playerName = playerName; // If game is in progress, add as spectator instead if (lobby.gameStarted) { const spectator = { id: socket.id, name: playerName }; lobby.spectators.set(socket.id, spectator); // Notify all clients about new spectator io.to(LOBBY_ROOM).emit('spectator-joined', { spectator: { id: spectator.id, name: spectator.name }, spectators: getSpectatorsList() }); // Send current game state to spectator only socket.emit('forced-spectator', { spectatorId: socket.id, states: getStates(), players: getPlayersList() }); console.log(`${playerName} joined as spectator (${lobby.spectators.size} spectators)`); return; } const player = { id: socket.id, name: playerName, score: 0, lines: 0, level: 1, board: createEmptyBoard(), currentPiece: null, nextPiece: null, eliminated: false, ready: false, dropCounter: 0, dropInterval: 1000, garbageReceived: [], zoneMeter: 0, zoneActive: false, zoneStartTime: null, zoneLineCount: 0, zoneTotalDuration: 20000 }; lobby.players.set(socket.id, player); // Broadcast only to players in the lobby room io.to(LOBBY_ROOM).emit('player-joined', { player: { id: player.id, name: player.name }, players: getPlayersList() }); console.log(`${playerName} joined global lobby (${lobby.players.size} players)`); }); /** * Handle player marking themselves as ready * @event ready */ socket.on('ready', () => { const player = lobby.players.get(socket.id); if (!player) return; player.ready = true; // Broadcast only to players in the lobby room io.to(LOBBY_ROOM).emit('player-joined', { player: { id: player.id, name: player.name, ready: player.ready }, players: getPlayersList() }); // Check if all players ready and min 2 players (spectators don't count) if (lobby.players.size >= 2 && lobby.players.size <= 8) { const allReady = Array.from(lobby.players.values()).every(p => p.ready); if (allReady) { startGame(); } } }); /** * Handle player marking themselves as not ready * @event unready */ socket.on('unready', () => { const player = lobby.players.get(socket.id); if (!player) return; player.ready = false; // Broadcast only to players in the lobby room io.to(LOBBY_ROOM).emit('player-joined', { player: { id: player.id, name: player.name, ready: player.ready }, players: getPlayersList() }); }); /** * Handle player moving piece left or right * @event player-move * @param {object} data - Event data * @param {string} data.playerId - ID of the player * @param {'left'|'right'} data.direction - Direction to move */ socket.on('player-move', ({ playerId, direction }) => { if (!lobby.gameStarted) return; const player = lobby.players.get(playerId); if (!player || player.eliminated || !player.currentPiece) return; const newX = player.currentPiece.x + (direction === 'left' ? -1 : 1); if (isValidPosition(player.currentPiece, newX, player.currentPiece.y, player.board)) { player.currentPiece.x = newX; broadcastState(); } }); socket.on('player-rotate', ({ playerId }) => { if (!lobby.gameStarted) return; const player = lobby.players.get(playerId); if (!player || player.eliminated || !player.currentPiece) return; const originalShape = player.currentPiece.shape; const rows = originalShape.length; const cols = originalShape[0].length; const rotated = Array(cols).fill(null).map(() => Array(rows).fill(0)); for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { rotated[col][rows - 1 - row] = originalShape[row][col]; } } const kicks = [0, -1, 1, -2, 2]; for (const kick of kicks) { if (isValidPosition(player.currentPiece, player.currentPiece.x + kick, player.currentPiece.y, player.board, rotated)) { player.currentPiece.shape = rotated; player.currentPiece.x += kick; broadcastState(); return; } } }); socket.on('player-drop', ({ playerId, hard }) => { if (!lobby.gameStarted) return; const player = lobby.players.get(playerId); if (!player || player.eliminated || !player.currentPiece) return; if (hard) { let dropped = 0; while (isValidPosition(player.currentPiece, player.currentPiece.x, player.currentPiece.y + 1, player.board)) { player.currentPiece.y++; dropped++; } player.score += dropped * 2; lockPiece(player); } else { const newY = player.currentPiece.y + 1; if (isValidPosition(player.currentPiece, player.currentPiece.x, newY, player.board)) { player.currentPiece.y = newY; player.score += 1; broadcastState(); } else { lockPiece(player); } } }); socket.on('player-hold', ({ playerId }) => { if (!lobby.gameStarted) return; const player = lobby.players.get(playerId); if (!player || player.eliminated || !player.currentPiece) return; if (!player.canHold) return; if (player.holdPiece === null) { // First hold - store current piece and get next from queue player.holdPiece = { type: player.currentPiece.type, shape: JSON.parse(JSON.stringify(player.currentPiece.shape)), color: player.currentPiece.color }; player.currentPiece = player.nextPiece; // Advance sequence index since we consumed nextPiece const idx = lobby.playerSequenceIndex.get(playerId); lobby.playerSequenceIndex.set(playerId, idx + 1); // Get new next piece from queue const nextType = lobby.pieceQueue[idx]; if (nextType) { player.nextPiece = getPieceFromType(nextType); } else { // Regenerate queue if exhausted lobby.pieceQueue = createPieceQueue(14); player.nextPiece = getPieceFromType(lobby.pieceQueue[0]); lobby.playerSequenceIndex.set(playerId, 1); } } else { // Swap with held piece - current piece goes to hold, held piece becomes current // Don't advance sequence - the current piece is just going into hold const temp = { type: player.currentPiece.type, shape: JSON.parse(JSON.stringify(player.currentPiece.shape)), color: player.currentPiece.color }; player.currentPiece = { ...player.holdPiece, shape: JSON.parse(JSON.stringify(player.holdPiece.shape)), x: Math.floor(BOARD_WIDTH / 2) - Math.floor(player.holdPiece.shape[0].length / 2), y: 0 }; player.holdPiece = temp; // Update nextPiece to be the piece that was just held player.nextPiece = { type: temp.type, shape: JSON.parse(JSON.stringify(temp.shape)), color: temp.color, x: Math.floor(BOARD_WIDTH / 2) - Math.floor(temp.shape[0].length / 2), y: 0 }; } player.canHold = false; broadcastState(); }); socket.on('zone-activate', ({ playerId }) => { if (!lobby.gameStarted) return; const player = lobby.players.get(playerId); if (!player || player.eliminated || player.zoneActive) return; // Only activate if Zone meter is full if (player.zoneMeter < 100) return; player.zoneActive = true; player.zoneStartTime = Date.now(); player.zoneLineCount = 0; player.zoneMeter = 0; console.log(`[ZONE] ${player.name} activated Zone!`); broadcastState(); }); socket.on('disconnect', () => { const player = lobby.players.get(socket.id); if (player) { if (lobby.gameStarted) { player.eliminated = true; console.log(`[ELIMINATION] ${player.name} eliminated - player disconnected during game`); broadcastState(); checkGameOver(); } console.log(`${player.name} disconnected (${lobby.players.size - 1} remaining)`); lobby.players.delete(socket.id); io.to(LOBBY_ROOM).emit('player-left', { playerId: socket.id, players: getPlayersList() }); // If lobby empty and game running, stop game if (lobby.players.size === 0 && lobby.gameStarted) { if (lobby.gameInterval) clearInterval(lobby.gameInterval); lobby.gameStarted = false; } return; } // Handle spectator disconnect const spectator = lobby.spectators.get(socket.id); if (spectator) { console.log(`${spectator.name} (spectator) disconnected`); lobby.spectators.delete(socket.id); io.to(LOBBY_ROOM).emit('spectator-left', { spectatorId: socket.id, spectators: getSpectatorsList() }); } }); }); function getPlayersList() { return Array.from(lobby.players.values()).map(p => ({ id: p.id, name: p.name, score: p.score, lines: p.lines, level: p.level, eliminated: p.eliminated, ready: p.ready })); } function getSpectatorsList() { return Array.from(lobby.spectators.values()).map(s => ({ id: s.id, name: s.name })); } function getRandomPiece() { const key = TETROMINO_KEYS[Math.floor(Math.random() * TETROMINO_KEYS.length)]; const tetromino = TETROMINOS[key]; return { type: key, shape: JSON.parse(JSON.stringify(tetromino.shape)), color: tetromino.color, x: Math.floor(BOARD_WIDTH / 2) - Math.floor(tetromino.shape[0].length / 2), y: 0 }; } function spawnPiece(player) { const idx = lobby.playerSequenceIndex.get(player.id); const pieceType = lobby.pieceQueue[idx]; if (!pieceType) { // Regenerate queue if exhausted lobby.pieceQueue = createPieceQueue(14); lobby.playerSequenceIndex.set(player.id, 0); return spawnPiece(player); } // Advance sequence index for next spawn lobby.playerSequenceIndex.set(player.id, idx + 1); player.currentPiece = player.nextPiece; player.nextPiece = getPieceFromType(pieceType); if (!player.currentPiece) return false; return isValidPosition(player.currentPiece, player.currentPiece.x, player.currentPiece.y, player.board); } function lockPiece(player) { if (!player.currentPiece) return; // Lock piece into board for (let row = 0; row < player.currentPiece.shape.length; row++) { for (let col = 0; col < player.currentPiece.shape[row].length; col++) { if (player.currentPiece.shape[row][col]) { const boardY = player.currentPiece.y + row; const boardX = player.currentPiece.x + col; if (boardY >= 0 && boardY < BOARD_HEIGHT && boardX >= 0 && boardX < BOARD_WIDTH) { player.board[boardY][boardX] = player.currentPiece.color; } } } } const rowsCleared = clearRows(player); // Check if board overflowed (top 2 rows have blocks) - this means player is eliminated if (checkBoardOverflow(player.board)) { player.eliminated = true; console.log(`[ELIMINATION] ${player.name} eliminated - board overflowed`); broadcastState(); checkGameOver(); return; } const spawnResult = spawnPiece(player); if (!spawnResult) { player.eliminated = true; console.log(`[ELIMINATION] ${player.name} eliminated - piece could not spawn`); } // Reset canHold for the new piece player.canHold = true; if (rowsCleared > 0) sendGarbage(player, rowsCleared); broadcastState(); checkGameOver(); } function clearRows(player) { const result = clearRowsFromBoard(player.board); let rowsCleared = result.rowsCleared; // During Zone, lines cascade to bottom instead of disappearing - reapply them if (player.zoneActive && rowsCleared > 0) { player.zoneLineCount += rowsCleared; // Keep the lines on board for cascade effect (they stay, don't disappear) player.board = result.board; // Add cleared lines back to bottom for visual cascade for (let i = 0; i < rowsCleared; i++) { player.board.push(Array(BOARD_WIDTH).fill('#999999')); // Cleared line color } // Shift other lines up to make room player.board = player.board.slice(-BOARD_HEIGHT); return rowsCleared; // Still count for scoring } player.board = result.board; if (rowsCleared > 0) { player.lines += rowsCleared; const points = [0, 100, 300, 500, 800]; player.score += points[rowsCleared] * player.level; player.level = Math.floor(player.lines / 10) + 1; player.dropInterval = Math.max(100, 1000 - (player.level - 1) * 100); // Fill Zone meter: different amounts based on lines cleared const zoneMeterIncrease = [0, 15, 25, 35, 50][Math.min(4, rowsCleared)] || 0; player.zoneMeter = Math.min(100, player.zoneMeter + zoneMeterIncrease); } return rowsCleared; } function sendGarbage(sender, rowsCleared) { // Number of garbage rows equals number of lines cleared const garbageLog = []; for (let i = 0; i < rowsCleared; i++) { // Re-compute opponents each iteration to exclude players eliminated by previous garbage const opponents = Array.from(lobby.players.values()).filter(p => p.id !== sender.id && !p.eliminated); if (opponents.length === 0) { break; } const target = opponents[Math.floor(Math.random() * opponents.length)]; garbageLog.push(target.name); addGarbageToPlayer(target, sender.name); } if (garbageLog.length > 0) { console.log(`[GARBAGE] ${sender.name} cleared ${rowsCleared} row(s) -> sent garbage to: ${garbageLog.join(', ')}`); } else { console.log(`[GARBAGE] ${sender.name} cleared ${rowsCleared} row(s) but no opponents to send garbage to`); } } function sendZoneAttack(sender, zoneLineCount) { // Zone attack sends bonus garbage based on lines cleared during Zone // Formula: lines * 1.5 (rounded) = garbage rows // Example: 4 Zone lines = 6 garbage rows, 8 Zone lines = 12 garbage rows const totalGarbageLines = Math.round(zoneLineCount * 1.5); const garbageLog = []; for (let i = 0; i < totalGarbageLines; i++) { const opponents = Array.from(lobby.players.values()).filter(p => p.id !== sender.id && !p.eliminated); if (opponents.length === 0) break; const target = opponents[Math.floor(Math.random() * opponents.length)]; garbageLog.push(target.name); addGarbageToPlayer(target, `${sender.name} (ZONE)`); } if (garbageLog.length > 0) { console.log(`[ZONE ATTACK] ${sender.name} sent ${totalGarbageLines} garbage rows from ${zoneLineCount} Zone lines -> targets: ${garbageLog.join(', ')}`); } } function addGarbageToPlayer(player, senderName) { // Use the pure function to add garbage row const result = addGarbageRow(player.board, player.currentPiece); player.board = result.board; player.currentPiece = result.currentPiece; // Track garbage received player.garbageReceived.push({ rows: 1, sender: senderName }); } function startGame() { lobby.gameStarted = true; // Initialize shared piece queue lobby.pieceQueue = createPieceQueue(14); // 98 pieces (~14 bags) // Reset all player variables for the new game for (const player of lobby.players.values()) { player.board = createEmptyBoard(); player.score = 0; player.lines = 0; player.level = 1; player.eliminated = false; player.dropInterval = 1000; player.dropCounter = 0; player.holdPiece = null; player.canHold = true; player.garbageReceived = []; player.currentPiece = null; player.nextPiece = null; player.zoneMeter = 0; player.zoneActive = false; player.zoneStartTime = null; player.zoneLineCount = 0; lobby.playerSequenceIndex.set(player.id, 0); } // Assign first two pieces from queue to all players const firstPieceType = lobby.pieceQueue[0]; const secondPieceType = lobby.pieceQueue[1]; for (const player of lobby.players.values()) { player.currentPiece = getPieceFromType(firstPieceType); player.nextPiece = getPieceFromType(secondPieceType); // Advance all players to index 2 since we've used first two pieces lobby.playerSequenceIndex.set(player.id, 2); } lobby.gameInterval = setInterval(() => gameTick(), 50); io.to(LOBBY_ROOM).emit('game-started', { players: getPlayersList(), states: getStates() }); console.log(`Game started with ${lobby.players.size} players`); } function gameTick() { for (const player of lobby.players.values()) { if (player.eliminated || !player.currentPiece) continue; // Handle Zone time management if (player.zoneActive) { const elapsedTime = Date.now() - player.zoneStartTime; if (elapsedTime >= player.zoneTotalDuration) { // Zone ended - convert zone lines to attack player.zoneActive = false; console.log(`[ZONE END] ${player.name} cleared ${player.zoneLineCount} lines during Zone`); if (player.zoneLineCount > 0) { sendZoneAttack(player, player.zoneLineCount); } player.zoneLineCount = 0; } else { // During Zone - gravity doesn't apply, let pieces sort themselves out // Players still control manually, pieces still lock after a delay if touching stack continue; } } // Normal gravity (not in Zone) player.dropCounter += 50; if (player.dropCounter >= player.dropInterval) { player.dropCounter = 0; if (isValidPosition(player.currentPiece, player.currentPiece.x, player.currentPiece.y + 1, player.board)) { player.currentPiece.y++; } else { lockPiece(player); } } } broadcastState(); checkGameOver(); } function broadcastState() { io.to(LOBBY_ROOM).emit('state-update', getStates()); } function getStates() { return Array.from(lobby.players.values()).map(p => ({ playerId: p.id, board: JSON.parse(JSON.stringify(p.board)), currentPiece: p.currentPiece ? JSON.parse(JSON.stringify(p.currentPiece)) : null, ghostY: p.currentPiece ? getGhostY(p.currentPiece, p.board) : null, nextPiece: p.nextPiece ? JSON.parse(JSON.stringify(p.nextPiece)) : null, holdPiece: p.holdPiece ? JSON.parse(JSON.stringify(p.holdPiece)) : null, canHold: p.canHold, score: p.score, lines: p.lines, level: p.level, eliminated: p.eliminated, sequenceIndex: lobby.playerSequenceIndex.get(p.id) || 0, garbageReceived: p.garbageReceived ? JSON.parse(JSON.stringify(p.garbageReceived)) : [], zoneMeter: p.zoneMeter, zoneActive: p.zoneActive, zoneTimeRemaining: p.zoneActive ? Math.max(0, p.zoneTotalDuration - (Date.now() - p.zoneStartTime)) : 0, zoneLineCount: p.zoneLineCount })); } function checkGameOver() { const activePlayers = Array.from(lobby.players.values()).filter(p => !p.eliminated); console.log(`[GAMEOVER CHECK] Active players: ${activePlayers.length} (${activePlayers.map(p => p.name).join(', ')})`); if (activePlayers.length <= 1) { console.log(`[GAME OVER] ${activePlayers.length === 1 ? `Winner: ${activePlayers[0].name}` : 'No winner (all eliminated)'}`); // Stop the game interval but keep game state visible for viewing if (lobby.gameInterval) { clearInterval(lobby.gameInterval); lobby.gameInterval = null; } lobby.gameStarted = false; // Set all players to not ready for next game (but keep their final state) for (const player of lobby.players.values()) { player.ready = false; } // Move spectators to players for next round for (const [id, spectator] of lobby.spectators.entries()) { const player = { id: spectator.id, name: spectator.name, score: 0, lines: 0, level: 1, board: createEmptyBoard(), currentPiece: null, nextPiece: null, holdPiece: null, canHold: true, eliminated: false, ready: false, dropCounter: 0, dropInterval: 1000, garbageReceived: [] }; lobby.players.set(id, player); lobby.playerSequenceIndex.set(id, 0); spectator.socket = io.sockets.sockets.get(id); if (spectator.socket) { spectator.socket.data.playerName = spectator.name; } } lobby.spectators.clear(); // Broadcast game over with final states io.to(LOBBY_ROOM).emit('game-over', { states: getStates() }); // Broadcast updated player list io.to(LOBBY_ROOM).emit('player-joined', { player: null, players: getPlayersList() }); console.log(`Game over. Reset ${lobby.players.size} players for next round`); } } server.listen(PORT, () => { console.log(`Tetris Battle Royale server running on port ${PORT}`); console.log(`Open http://localhost:${PORT} in 2-8 browser tabs to play!`); });