a1cbd27a71
- Log elimination reason: board full, garbage collision, garbage overflow, disconnect - Log garbage transfers: who cleared rows, how many, and recipients - Log when no opponents available to send garbage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
633 lines
19 KiB
JavaScript
633 lines
19 KiB
JavaScript
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
|
|
};
|
|
|
|
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);
|
|
|
|
// 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;
|
|
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
|
|
}));
|
|
}
|
|
|
|
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!`);
|
|
});
|