Files
battle-royal-tetris/server/index.js
T
jozamudi aeacf9e68f Add performance improvements documentation and game-logic enhancements
- Add IMPROVEMENTS.md with detailed analysis of performance issues and bugs
- Update CLAUDE.md with negative Y overflow explanation
- Update README.md with socket events documentation
- Enhance game-logic.js with improved comments and validation
- Improve server/index.js with better documentation and edge case handling
2026-03-24 20:46:34 +00:00

657 lines
20 KiB
JavaScript

/**
* 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
*/
/**
* @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<string, Player>} players - Active players (socketId -> Player)
* @property {Map<string, Spectator>} 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<string, number>} 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: []
};
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('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);
const rowsCleared = result.rowsCleared;
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);
}
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 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;
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);
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!`);
});