Files
battle-royal-tetris/server/index.js
T
jozamudi 372a340024 Fix initial pieces to use shared queue in startGame
All players now receive the same first two pieces from the shared
piece queue, with sequence index starting at 2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 01:41:13 +00:00

615 lines
18 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) {
console.log(`${player.name} disconnected (${lobby.players.size - 1} remaining)`);
if (lobby.gameStarted) {
player.eliminated = true;
broadcastState();
checkGameOver();
}
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);
if (!spawnPiece(player)) player.eliminated = true;
// 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) return;
for (let i = 0; i < rowsCleared; i++) {
const target = opponents[Math.floor(Math.random() * opponents.length)];
addGarbageToPlayer(target);
}
}
function addGarbageToPlayer(player) {
// Remove top row and add garbage to bottom
player.board.shift();
const garbageRow = Array(BOARD_WIDTH).fill(GARBAGE_COLOR);
garbageRow[Math.floor(Math.random() * BOARD_WIDTH)] = 0;
player.board.push(garbageRow);
// Push current piece up by 1 row if it exists (y decreases when moving up)
if (player.currentPiece) {
player.currentPiece.y--;
// Eliminate if the piece is pushed above the board or collides
if (player.currentPiece.y < 0 || !isValidPosition(player.currentPiece, player.currentPiece.x, player.currentPiece.y, player.board)) {
player.eliminated = true;
}
}
}
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!`);
});