Initial commit: Tetris Battle Royale multiplayer game
Features: - 2-8 player multiplayer via Socket.io WebSocket - Real-time board synchronization - all players see all boards - Battle royale mechanic: clearing rows sends garbage to opponents - Classic Tetris gameplay with all 7 tetrominoes - Retro visual styling with CRT scanlines and pixel font - Automatic level progression and speed increase - Player elimination and winner announcement Files: - server/index.js: Node.js + Socket.io game server - public/js/: Frontend game logic, rendering, network, and UI - public/css/style.css: Retro Tetris styling - README.md: Setup and usage instructions - PLAN.md: Implementation plan with all phases completed
This commit is contained in:
+401
@@ -0,0 +1,401 @@
|
||||
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')));
|
||||
|
||||
// Game state storage
|
||||
const rooms = 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';
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
console.log('Player connected:', socket.id);
|
||||
|
||||
socket.on('join-room', ({ roomName, playerName }) => {
|
||||
if (!rooms.has(roomName)) {
|
||||
rooms.set(roomName, {
|
||||
name: roomName,
|
||||
players: new Map(),
|
||||
gameStarted: false,
|
||||
gameInterval: null
|
||||
});
|
||||
}
|
||||
|
||||
const room = rooms.get(roomName);
|
||||
socket.join(roomName);
|
||||
socket.data.roomName = roomName;
|
||||
socket.data.playerName = playerName;
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
room.players.set(socket.id, player);
|
||||
|
||||
io.to(roomName).emit('player-joined', {
|
||||
player: { id: player.id, name: player.name },
|
||||
players: getPlayersList(room)
|
||||
});
|
||||
|
||||
console.log(`${playerName} joined room ${roomName}`);
|
||||
});
|
||||
|
||||
socket.on('ready', () => {
|
||||
const roomName = socket.data.roomName;
|
||||
if (!roomName) return;
|
||||
|
||||
const room = rooms.get(roomName);
|
||||
const player = room.players.get(socket.id);
|
||||
player.ready = true;
|
||||
|
||||
io.to(roomName).emit('player-joined', {
|
||||
player: { id: player.id, name: player.name, ready: player.ready },
|
||||
players: getPlayersList(room)
|
||||
});
|
||||
|
||||
if (room.players.size >= 2 && room.players.size <= 8) {
|
||||
const allReady = Array.from(room.players.values()).every(p => p.ready);
|
||||
if (allReady) {
|
||||
startGame(room);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('player-move', ({ playerId, direction }) => {
|
||||
const roomName = socket.data.roomName;
|
||||
if (!roomName) return;
|
||||
const room = rooms.get(roomName);
|
||||
if (!room || !room.gameStarted) return;
|
||||
|
||||
const player = room.players.get(playerId);
|
||||
if (!player || player.eliminated) 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(room);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('player-rotate', ({ playerId }) => {
|
||||
const roomName = socket.data.roomName;
|
||||
if (!roomName) return;
|
||||
const room = rooms.get(roomName);
|
||||
if (!room || !room.gameStarted) return;
|
||||
|
||||
const player = room.players.get(playerId);
|
||||
if (!player || player.eliminated) 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(room);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('player-drop', ({ playerId, hard }) => {
|
||||
const roomName = socket.data.roomName;
|
||||
if (!roomName) return;
|
||||
const room = rooms.get(roomName);
|
||||
if (!room || !room.gameStarted) return;
|
||||
|
||||
const player = room.players.get(playerId);
|
||||
if (!player || player.eliminated) 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(room, 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(room);
|
||||
} else {
|
||||
lockPiece(room, player);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
const roomName = socket.data.roomName;
|
||||
if (!roomName) return;
|
||||
|
||||
const room = rooms.get(roomName);
|
||||
if (room) {
|
||||
const player = room.players.get(socket.id);
|
||||
if (player) {
|
||||
console.log(`${player.name} left room ${roomName}`);
|
||||
|
||||
if (room.gameStarted) {
|
||||
player.eliminated = true;
|
||||
broadcastState(room);
|
||||
checkGameOver(room);
|
||||
}
|
||||
|
||||
room.players.delete(socket.id);
|
||||
io.to(roomName).emit('player-left', {
|
||||
playerId: socket.id,
|
||||
players: getPlayersList(room)
|
||||
});
|
||||
|
||||
if (room.players.size === 0) {
|
||||
if (room.gameInterval) clearInterval(room.gameInterval);
|
||||
rooms.delete(roomName);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function createEmptyBoard() {
|
||||
return Array(BOARD_HEIGHT).fill(null).map(() => Array(BOARD_WIDTH).fill(0));
|
||||
}
|
||||
|
||||
function getPlayersList(room) {
|
||||
return Array.from(room.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 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 >= BOARD_HEIGHT) return false;
|
||||
if (newY >= 0 && 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) {
|
||||
player.currentPiece = player.nextPiece;
|
||||
player.nextPiece = getRandomPiece();
|
||||
return isValidPosition(player.currentPiece, player.currentPiece.x, player.currentPiece.y, player.board);
|
||||
}
|
||||
|
||||
function lockPiece(room, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (player.currentPiece.y <= 0) player.eliminated = true;
|
||||
|
||||
const rowsCleared = clearRows(player);
|
||||
|
||||
if (!spawnPiece(player)) player.eliminated = true;
|
||||
|
||||
if (rowsCleared > 0) sendGarbage(room, player, rowsCleared);
|
||||
|
||||
broadcastState(room);
|
||||
checkGameOver(room);
|
||||
}
|
||||
|
||||
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(room, sender, rowsCleared) {
|
||||
const garbageRows = rowsCleared >= 4 ? rowsCleared : Math.max(1, rowsCleared - 1);
|
||||
const opponents = Array.from(room.players.values()).filter(p => p.id !== sender.id && !p.eliminated);
|
||||
if (opponents.length === 0) return;
|
||||
for (let i = 0; i < garbageRows; i++) {
|
||||
const target = opponents[Math.floor(Math.random() * opponents.length)];
|
||||
addGarbageToPlayer(target);
|
||||
}
|
||||
}
|
||||
|
||||
function addGarbageToPlayer(player) {
|
||||
player.board.pop();
|
||||
const garbageRow = Array(BOARD_WIDTH).fill(GARBAGE_COLOR);
|
||||
garbageRow[Math.floor(Math.random() * BOARD_WIDTH)] = 0;
|
||||
player.board.unshift(garbageRow);
|
||||
|
||||
for (let row = 0; row < 2; row++) {
|
||||
for (let col = 0; col < BOARD_WIDTH; col++) {
|
||||
if (player.board[row][col] !== 0) {
|
||||
player.eliminated = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startGame(room) {
|
||||
room.gameStarted = true;
|
||||
|
||||
for (const player of room.players.values()) {
|
||||
player.board = createEmptyBoard();
|
||||
player.score = 0;
|
||||
player.lines = 0;
|
||||
player.level = 1;
|
||||
player.eliminated = false;
|
||||
player.dropInterval = 1000;
|
||||
player.currentPiece = getRandomPiece();
|
||||
player.nextPiece = getRandomPiece();
|
||||
}
|
||||
|
||||
room.gameInterval = setInterval(() => gameTick(room), 50);
|
||||
|
||||
io.to(room.name).emit('game-started', {
|
||||
players: getPlayersList(room),
|
||||
states: getStates(room)
|
||||
});
|
||||
|
||||
console.log(`Game started in room ${room.name} with ${room.players.size} players`);
|
||||
}
|
||||
|
||||
function gameTick(room) {
|
||||
for (const player of room.players.values()) {
|
||||
if (player.eliminated) 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(room, player);
|
||||
}
|
||||
}
|
||||
}
|
||||
broadcastState(room);
|
||||
checkGameOver(room);
|
||||
}
|
||||
|
||||
function broadcastState(room) {
|
||||
io.to(room.name).emit('state-update', getStates(room));
|
||||
}
|
||||
|
||||
function getStates(room) {
|
||||
return Array.from(room.players.values()).map(p => ({
|
||||
playerId: p.id,
|
||||
board: JSON.parse(JSON.stringify(p.board)),
|
||||
currentPiece: p.currentPiece ? JSON.parse(JSON.stringify(p.currentPiece)) : null,
|
||||
nextPiece: p.nextPiece ? JSON.parse(JSON.stringify(p.nextPiece)) : null,
|
||||
score: p.score,
|
||||
lines: p.lines,
|
||||
level: p.level,
|
||||
eliminated: p.eliminated
|
||||
}));
|
||||
}
|
||||
|
||||
function checkGameOver(room) {
|
||||
const activePlayers = Array.from(room.players.values()).filter(p => !p.eliminated);
|
||||
if (activePlayers.length <= 1) {
|
||||
io.to(room.name).emit('game-over', { states: getStates(room) });
|
||||
if (room.gameInterval) {
|
||||
clearInterval(room.gameInterval);
|
||||
room.gameInterval = null;
|
||||
}
|
||||
room.gameStarted = false;
|
||||
}
|
||||
}
|
||||
|
||||
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!`);
|
||||
});
|
||||
Generated
+1100
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "tetris-battle-royale",
|
||||
"version": "1.0.0",
|
||||
"description": "Multiplayer Tetris Battle Royale",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "node index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"socket.io": "^4.7.2",
|
||||
"uuid": "^9.0.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user