const express = require('express'); const http = require('http'); const { Server } = require('socket.io'); const path = require('path'); const app = express(); const server = http.createServer(app); const io = new Server(server); // Serve static files app.use(express.static(path.join(__dirname, '../public'))); // Single global lobby const lobby = { players: new Map(), spectators: new Map(), gameStarted: false, gameInterval: null, pieceQueue: [], playerSequenceIndex: new Map() }; const PORT = process.env.PORT || 3000; // Tetromino definitions const TETROMINOS = { I: { shape: [[0,0,0,0],[1,1,1,1],[0,0,0,0],[0,0,0,0]], color: '#00ffff' }, O: { shape: [[1,1],[1,1]], color: '#ffff00' }, T: { shape: [[0,1,0],[1,1,1],[0,0,0]], color: '#800080' }, S: { shape: [[0,1,1],[1,1,0],[0,0,0]], color: '#00ff00' }, Z: { shape: [[1,1,0],[0,1,1],[0,0,0]], color: '#ff0000' }, J: { shape: [[1,0,0],[1,1,1],[0,0,0]], color: '#0000ff' }, L: { shape: [[0,0,1],[1,1,1],[0,0,0]], color: '#ffa500' } }; const TETROMINO_KEYS = Object.keys(TETROMINOS); const BOARD_WIDTH = 10; const BOARD_HEIGHT = 20; const GARBAGE_COLOR = '#666666'; const LOBBY_ROOM = 'global-lobby'; // 7-bag piece generation system function generatePieceBag() { const bag = [...TETROMINO_KEYS]; // Fisher-Yates shuffle for (let i = bag.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [bag[i], bag[j]] = [bag[j], bag[i]]; } return bag; } function createPieceQueue(bagCount = 14) { let queue = []; for (let i = 0; i < bagCount; i++) { queue = queue.concat(generatePieceBag()); } return queue; } function getPieceFromType(pieceType) { const tetromino = TETROMINOS[pieceType]; return { type: pieceType, 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 }; } io.on('connection', (socket) => { console.log('Player connected:', socket.id); // Join global lobby 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: [] }; 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)`); }); 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(); } } }); 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() }); }); 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('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 createEmptyBoard() { return Array(BOARD_HEIGHT).fill(null).map(() => Array(BOARD_WIDTH).fill(0)); } 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 isValidPosition(piece, x, y, board, shape = null) { const s = shape || piece.shape; for (let row = 0; row < s.length; row++) { for (let col = 0; col < s[row].length; col++) { if (s[row][col]) { const newX = x + col; const newY = y + row; if (newX < 0 || newX >= BOARD_WIDTH || newY < 0 || newY >= BOARD_HEIGHT) return false; if (board[newY][newX]) return false; } } } return true; } 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; 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); const spawnResult = spawnPiece(player); if (!spawnResult) { player.eliminated = true; console.log(`[ELIMINATION] ${player.name} eliminated - piece could not spawn (board full at y=${player.currentPiece?.y})`); } // Reset canHold for the new piece player.canHold = true; if (rowsCleared > 0) sendGarbage(player, rowsCleared); broadcastState(); checkGameOver(); } function clearRows(player) { let rowsCleared = 0; for (let row = BOARD_HEIGHT - 1; row >= 0; row--) { if (player.board[row].every(cell => cell !== 0)) { player.board.splice(row, 1); player.board.unshift(Array(BOARD_WIDTH).fill(0)); rowsCleared++; row++; } } 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); } return rowsCleared; } function sendGarbage(sender, rowsCleared) { // Number of garbage rows equals number of lines cleared const opponents = Array.from(lobby.players.values()).filter(p => p.id !== sender.id && !p.eliminated); if (opponents.length === 0) { console.log(`[GARBAGE] ${sender.name} cleared ${rowsCleared} row(s) but no opponents to send garbage to`); return; } const garbageLog = []; for (let i = 0; i < rowsCleared; i++) { const target = opponents[Math.floor(Math.random() * opponents.length)]; garbageLog.push(target.name); addGarbageToPlayer(target, sender.name); } console.log(`[GARBAGE] ${sender.name} cleared ${rowsCleared} row(s) -> sent garbage to: ${garbageLog.join(', ')}`); } function addGarbageToPlayer(player, senderName) { // Remove top row and add garbage to bottom player.board.shift(); const garbageRow = Array(BOARD_WIDTH).fill(GARBAGE_COLOR); const gap = Math.floor(Math.random() * BOARD_WIDTH); garbageRow[gap] = 0; player.board.push(garbageRow); // Track garbage received player.garbageReceived.push({ rows: 1, sender: senderName }); // Push current piece up by 1 row if it exists (y decreases when moving up) if (player.currentPiece) { const oldY = player.currentPiece.y; player.currentPiece.y--; // Eliminate if the piece is pushed above the board or collides if (player.currentPiece.y < 0) { player.eliminated = true; console.log(`[ELIMINATION] ${player.name} eliminated by ${senderName} - piece pushed above board (y=${oldY} -> ${player.currentPiece.y})`); } else if (!isValidPosition(player.currentPiece, player.currentPiece.x, player.currentPiece.y, player.board)) { player.eliminated = true; console.log(`[ELIMINATION] ${player.name} eliminated by ${senderName} - piece collision after garbage (piece at x=${player.currentPiece.x}, y=${player.currentPiece.y})`); } } } function getGhostY(piece, board) { if (!piece) return null; let ghostY = piece.y; while (isValidPosition(piece, piece.x, ghostY + 1, board)) { ghostY++; } return ghostY; } function startGame() { lobby.gameStarted = true; // Initialize shared piece queue lobby.pieceQueue = createPieceQueue(14); // 98 pieces (~14 bags) 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.holdPiece = null; player.canHold = true; player.garbageReceived = []; 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; 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)) : [] })); } function checkGameOver() { const activePlayers = Array.from(lobby.players.values()).filter(p => !p.eliminated); if (activePlayers.length <= 1) { io.to(LOBBY_ROOM).emit('game-over', { states: getStates() }); if (lobby.gameInterval) { clearInterval(lobby.gameInterval); lobby.gameInterval = null; } lobby.gameStarted = 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, eliminated: false, ready: false, dropCounter: 0, dropInterval: 1000 }; lobby.players.set(id, player); spectator.socket = io.sockets.sockets.get(id); if (spectator.socket) { spectator.socket.data.playerName = spectator.name; } } lobby.spectators.clear(); // Broadcast updated player list io.to(LOBBY_ROOM).emit('player-joined', { player: null, players: getPlayersList() }); console.log(`Game over. Moved ${activePlayers.length === 1 ? 0 : lobby.players.size} players to 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!`); });