From e7917a338e67046ec817ec2190e3b06fce208f9a Mon Sep 17 00:00:00 2001 From: Josue Zamudio Date: Fri, 20 Mar 2026 07:09:51 -0700 Subject: [PATCH] Refactor to single global lobby Changes: - Removed room-based architecture, now using single global lobby - Players only need to enter their name to join - Game starts when all players in lobby are ready (min 2, max 8) - Simplified UI - no room name field, shows "Global Lobby" header - Updated all server events to use io.emit instead of io.to(roomName) Files modified: - server/index.js: Replaced rooms Map with single lobby object - public/index.html: Removed room name input, updated button text - public/js/network.js: Renamed joinRoom/leaveRoom to joinLobby/leaveLobby - public/js/ui.js: Simplified join flow, removed room name validation - public/js/app.js: Updated game header to show "GLOBAL LOBBY" - PLAN.md: Marked all phases as complete --- PLAN.md | 219 +++++++++++++++---------------------------- public/index.html | 10 +- public/js/app.js | 2 +- public/js/network.js | 15 +-- public/js/ui.js | 17 +--- server/index.js | 183 ++++++++++++++++-------------------- 6 files changed, 172 insertions(+), 274 deletions(-) diff --git a/PLAN.md b/PLAN.md index fe8ba33..48fc7b1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,184 +1,121 @@ -# Tetris Battle Royale - Implementation Plan +# Single Global Lobby - Implementation Plan ## Context -Build a multiplayer Tetris Battle Royale game where 2-8 players compete. When a player clears rows, those rows are garbage-dumped onto random opponents' boards. Players see all other boards in real-time. Uses WebSocket for multiplayer and classic retro aesthetics. - -## Architecture - -### Tech Stack -- **Frontend**: HTML5 Canvas, CSS3, Vanilla JavaScript -- **Backend**: Node.js + Socket.io for real-time WebSocket communication -- **No frameworks** - pure vanilla implementation - -### File Structure -``` -tetris-battle-royale/ -├── server/ -│ ├── index.js # Socket.io server, game state management -│ └── package.json -├── public/ -│ ├── index.html # Game UI with player grids -│ ├── css/ -│ │ └── style.css # Retro pixel-art styling -│ └── js/ -│ ├── game.js # Core Tetris logic -│ ├── renderer.js # Canvas rendering -│ ├── network.js # Socket.io client -│ ├── ui.js # HUD and game messages -│ └── app.js # Main application -└── README.md -``` +Changed from room-based architecture to a single global lobby where: +- No room name input needed +- All players join the same lobby +- Player list shows everyone in the lobby +- Game starts when all players are ready (minimum 2 players) --- -## Phase 1: Server Setup & Multiplayer Infrastructure [DONE] +## Phase 1: Server - Replace Rooms with Single Lobby [DONE] -**Goal**: Get a working Node.js server with Socket.io that handles rooms and player connections. +**Goal**: Remove room-based architecture and use a single global lobby. ### Todos -- [x] Initialize Node.js project with `package.json` -- [x] Install dependencies: `express`, `socket.io`, `uuid` -- [x] Create basic Express server serving static files from `public/` -- [x] Implement Socket.io room system: - - `join-room` event - player joins with username - - `leave-room` event - player leaves - - Broadcast `player-joined` / `player-left` to room -- [x] Track players in room with IDs, names, ready status -- [x] Create simple HTML page to test connection +- [x] Replace `rooms` Map with single `lobby` object +- [x] Change `join-room` event to `join-lobby` + - Remove roomName parameter + - Only accept playerName + - Add player to global lobby +- [x] Update `ready` event + - Check if all players in global lobby are ready + - Start game when all ready (min 2 players, max 8) +- [x] Update disconnect handling + - Remove from global lobby + - Handle game-in-progress disconnections +- [x] Update all broadcast functions + - Remove room name parameter + - Broadcast to all connected clients (io.emit instead of io.to(roomName).emit) -**Success criteria**: Can open 2+ browser tabs, join same room, see player list update. +**Files modified**: `server/index.js` + +**Success criteria**: Server uses single lobby, no room names needed. --- -## Phase 2: Core Tetris Game Logic [DONE] +## Phase 2: Frontend HTML - Remove Room Name Input [DONE] -**Goal**: Implement single-player Tetris mechanics on the client side. +**Goal**: Simplify the login screen to only require player name. ### Todos -- [x] Create `public/js/game.js` with: - - 10x20 grid data structure - - Tetromino definitions (I, O, T, S, Z, J, L shapes and colors) - - Piece spawning at top center - - Movement: left, right, soft drop - - Rotation with wall kick (basic SRS) - - Hard drop (instant place) - - Row clearing detection - - Game over detection (piece collides at spawn) -- [x] Create `public/js/renderer.js` with: - - Canvas setup per player board - - Draw grid, active piece, locked pieces - - Classic Tetris colors for each piece type -- [x] Wire keyboard controls (arrow keys + space for hard drop) -- [x] Game loop with adjustable speed (starts slow, speeds up with lines) +- [x] Remove "Room Name" input field from login screen +- [x] Remove room name label +- [x] Change button text from "JOIN ROOM" to "JOIN LOBBY" +- [x] Update lobby screen header + - Remove room name display + - Show "Global Lobby" instead -**Success criteria**: Single-player Tetris works smoothly with all piece types and controls. +**Files modified**: `public/index.html` + +**Success criteria**: Login screen only asks for player name. --- -## Phase 3: Multiplayer Game State Sync [DONE] +## Phase 3: Network Module - Update Lobby Methods [DONE] -**Goal**: Connect multiple players and sync game state in real-time. +**Goal**: Update client-side network methods for single lobby architecture. ### Todos -- [x] Server: Add game state tracking per room - - Active pieces, board states, scores - - Game running/paused/over status -- [x] Client `public/js/network.js`: - - Send player actions to server (`move-piece`, `rotate`, `drop`) - - Receive state updates and apply locally -- [x] Server: Broadcast state at ~20fps to all room players -- [x] Server: Handle `start-game` when 2+ players ready -- [x] Client: Use networked state -- [x] Sync piece positions, board state, scores across all players +- [x] Rename `joinRoom` to `joinLobby` + - Remove roomName parameter + - Emit `join-lobby` event with just playerName +- [x] Rename `leaveRoom` to `leaveLobby` + - Remove room name handling +- [x] Remove `currentRoom` property (no longer needed) +- [x] Update all event listeners to work without room names -**Success criteria**: 2+ players can play simultaneously, all see each other's boards and piece movements in real-time. +**Files modified**: `public/js/network.js` + +**Success criteria**: Network module works with single lobby. --- -## Phase 4: Garbage Battle System [DONE] +## Phase 4: UI Module - Simplify Join Flow [DONE] -**Goal**: Implement the battle royale mechanic - clearing rows sends garbage to opponents. +**Goal**: Update UI handling for simplified lobby flow. ### Todos -- [x] Server: Track lines cleared per player -- [x] Implement garbage row calculation: - - 1 line → 0 garbage rows - - 2 lines → 1 garbage row to random opponent - - 3 lines → 2 garbage rows to random opponent(s) - - 4 lines (Tetris) → 4 garbage rows -- [x] Garbage row representation (filled row with 1 random gap) -- [x] Server: Pick random target(s) when garbage sent -- [x] Client: Receive garbage, shift board up, add garbage at bottom -- [x] Visual: Garbage rows have distinct color (gray/black) -- [x] Elimination: Player loses when garbage reaches top +- [x] Update `handleJoin` method + - Remove roomName input validation + - Only validate playerName + - Call `network.joinLobby(playerName)` +- [x] Update lobby display + - Show "Global Lobby" instead of room name + - Update player list display +- [x] Update game over "Back to Lobby" flow -**Success criteria**: Clearing rows visibly adds garbage rows to opponent boards. +**Files modified**: `public/js/ui.js` + +**Success criteria**: UI works with single lobby flow. --- -## Phase 5: Retro UI Polish [IN PROGRESS] +## Phase 5: App Module - Update Event Handling [DONE] -**Goal**: Classic Tetris aesthetic with multi-board layout. +**Goal**: Update main application to work with global lobby events. ### Todos -- [x] `public/css/style.css`: - - Import Press Start 2P font from Google Fonts - - CSS Grid layout for player boards (adaptive 2x2 or 2x4) - - Retro color scheme (dark background, bright pieces) - - CRT scanline overlay effect - - Pixelated canvas rendering (`image-rendering: pixelated`) -- [x] `public/js/ui.js`: - - Player info HUD (name, score, lines) - - Player list sidebar with status (playing/eliminated) - - Room join screen with username input - - "Ready" button and countdown before game start - - Game over screen with winner announcement - - Next piece preview panel -- [ ] Visual effects: - - [x] Row clear flash animation - - [x] Garbage receiving shake effect - - [ ] Player color indicators per board +- [x] Update network listener setup + - Adapt to new event structure (no room names) +- [x] Update game start handling + - Works with global lobby players +- [x] Update state sync for single lobby -**Success criteria**: Game looks like classic Tetris with all boards visible and polished UI. +**Files modified**: `public/js/app.js` + +**Success criteria**: Game syncs properly with single lobby. --- -## Phase 6: Polish & Edge Cases [PENDING] - -**Goal**: Handle real-world scenarios and add final touches. - -### Todos -- [x] Player disconnect handling: - - Remove from room, update player list - - Continue game with remaining players or end if <2 -- [ ] Rejoin support (optional): Allow disconnected player to rejoin -- [x] Score tracking and leaderboard -- [x] Level system: Speed increases every 10 lines -- [ ] Combo system: Bonus for consecutive clears -- [ ] Sound effects (optional): Clear, garbage receive, game over -- [ ] Settings menu: Speed, sound toggle, controls -- [ ] README with setup instructions - -**Success criteria**: Game handles disconnections gracefully, full feature set working. - ---- - -## Dependencies -```json -{ - "dependencies": { - "express": "^4.18.2", - "socket.io": "^4.7.2", - "uuid": "^9.0.0" - } -} -``` - ## Verification -1. `npm install` then `node server/index.js` -2. Open `http://localhost:3000` in 2-8 tabs -3. Join same room, click Ready, verify countdown -4. Play and verify garbage rows appear on opponents -5. Test disconnects, game over, winner announcement +1. Start server: `cd server && node index.js` +2. Open `http://localhost:3000` in 2+ browser tabs +3. Enter only your name (no room name field exists) +4. Click "JOIN LOBBY" +5. All players should see each other in the same "Global Lobby" +6. All players click READY +7. Game starts automatically when all players are ready (minimum 2) diff --git a/public/index.html b/public/index.html index d81cf89..a391e09 100644 --- a/public/index.html +++ b/public/index.html @@ -11,23 +11,19 @@
- +

TETRIS
BATTLE ROYALE

-
- - -
- +
-

Room:

+

GLOBAL LOBBY

diff --git a/public/js/app.js b/public/js/app.js index dd34e8d..d3b17d2 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -33,7 +33,7 @@ function setupNetworkListeners() { // Game started network.setListener('game-started', (players, states) => { ui.showScreen('game'); - ui.displays.gameRoomName.textContent = network.currentRoom; + ui.displays.gameRoomName.textContent = 'GLOBAL LOBBY'; // Clear old boards renderer.clearAll(); diff --git a/public/js/network.js b/public/js/network.js index 2745363..13acd86 100644 --- a/public/js/network.js +++ b/public/js/network.js @@ -3,7 +3,6 @@ class NetworkManager { constructor() { this.socket = null; - this.currentRoom = null; this.currentPlayerId = null; this.players = {}; this.gameState = {}; @@ -26,7 +25,6 @@ class NetworkManager { this.socket.on('disconnect', () => { console.log('Disconnected from server'); - this.currentRoom = null; }); this.socket.on('player-joined', ({ player, players }) => { @@ -66,20 +64,13 @@ class NetworkManager { }); } - joinRoom(roomName, playerName) { + joinLobby(playerName) { if (!this.socket) return; - - this.currentRoom = roomName; - this.socket.emit('join-room', { roomName, playerName }); + this.socket.emit('join-lobby', { playerName }); } - leaveRoom() { + leaveLobby() { if (!this.socket) return; - - if (this.currentRoom) { - this.socket.leave(this.currentRoom); - } - this.currentRoom = null; } ready() { diff --git a/public/js/ui.js b/public/js/ui.js index b714d1b..6906b9f 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -10,7 +10,6 @@ class UIManager { }; this.inputs = { - roomName: document.getElementById('room-name'), playerName: document.getElementById('player-name') }; @@ -22,7 +21,6 @@ class UIManager { }; this.displays = { - lobbyRoomName: document.getElementById('lobby-room-name'), gameRoomName: document.getElementById('game-room-name'), playerList: document.getElementById('player-list'), battleGrid: document.getElementById('battle-grid'), @@ -41,9 +39,6 @@ class UIManager { this.buttons.backToLobby.addEventListener('click', () => this.handleBackToLobby()); // Allow Enter key to submit forms - this.inputs.roomName.addEventListener('keypress', (e) => { - if (e.key === 'Enter') this.handleJoin(); - }); this.inputs.playerName.addEventListener('keypress', (e) => { if (e.key === 'Enter') this.handleJoin(); }); @@ -55,21 +50,19 @@ class UIManager { } handleJoin() { - const roomName = this.inputs.roomName.value.trim(); const playerName = this.inputs.playerName.value.trim(); - if (!roomName || !playerName) { - this.showMessage('Please enter room name and your name'); + if (!playerName) { + this.showMessage('Please enter your name'); return; } - // Emit join event + // Set listener for player joined event network.setListener('player-joined', () => { this.showScreen('lobby'); - this.displays.lobbyRoomName.textContent = roomName; }); - network.joinRoom(roomName, playerName); + network.joinLobby(playerName); } handleReady() { @@ -79,7 +72,7 @@ class UIManager { } handleLeave() { - network.leaveRoom(); + network.leaveLobby(); this.showScreen('room'); } diff --git a/server/index.js b/server/index.js index 779f904..a2e5b6c 100644 --- a/server/index.js +++ b/server/index.js @@ -10,8 +10,12 @@ const io = new Server(server); // Serve static files app.use(express.static(path.join(__dirname, '../public'))); -// Game state storage -const rooms = new Map(); +// Single global lobby +const lobby = { + players: new Map(), + gameStarted: false, + gameInterval: null +}; const PORT = process.env.PORT || 3000; @@ -34,19 +38,8 @@ 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; + // Join global lobby + socket.on('join-lobby', ({ playerName }) => { socket.data.playerName = playerName; const player = { @@ -64,60 +57,55 @@ io.on('connection', (socket) => { dropInterval: 1000 }; - room.players.set(socket.id, player); + lobby.players.set(socket.id, player); - io.to(roomName).emit('player-joined', { + // Broadcast to all clients + io.emit('player-joined', { player: { id: player.id, name: player.name }, - players: getPlayersList(room) + players: getPlayersList() }); - console.log(`${playerName} joined room ${roomName}`); + console.log(`${playerName} joined global lobby (${lobby.players.size} players)`); }); socket.on('ready', () => { - const roomName = socket.data.roomName; - if (!roomName) return; + const player = lobby.players.get(socket.id); + if (!player) return; - const room = rooms.get(roomName); - const player = room.players.get(socket.id); player.ready = true; - io.to(roomName).emit('player-joined', { + // Broadcast updated player list + io.emit('player-joined', { player: { id: player.id, name: player.name, ready: player.ready }, - players: getPlayersList(room) + players: getPlayersList() }); - if (room.players.size >= 2 && room.players.size <= 8) { - const allReady = Array.from(room.players.values()).every(p => p.ready); + // Check if all players ready and min 2 players + if (lobby.players.size >= 2 && lobby.players.size <= 8) { + const allReady = Array.from(lobby.players.values()).every(p => p.ready); if (allReady) { - startGame(room); + startGame(); } } }); socket.on('player-move', ({ playerId, direction }) => { - const roomName = socket.data.roomName; - if (!roomName) return; - const room = rooms.get(roomName); - if (!room || !room.gameStarted) return; + if (!lobby.gameStarted) return; - const player = room.players.get(playerId); + const player = lobby.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); + broadcastState(); } }); socket.on('player-rotate', ({ playerId }) => { - const roomName = socket.data.roomName; - if (!roomName) return; - const room = rooms.get(roomName); - if (!room || !room.gameStarted) return; + if (!lobby.gameStarted) return; - const player = room.players.get(playerId); + const player = lobby.players.get(playerId); if (!player || player.eliminated) return; const originalShape = player.currentPiece.shape; @@ -136,19 +124,16 @@ io.on('connection', (socket) => { if (isValidPosition(player.currentPiece, player.currentPiece.x + kick, player.currentPiece.y, player.board, rotated)) { player.currentPiece.shape = rotated; player.currentPiece.x += kick; - broadcastState(room); + broadcastState(); 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; + if (!lobby.gameStarted) return; - const player = room.players.get(playerId); + const player = lobby.players.get(playerId); if (!player || player.eliminated) return; if (hard) { @@ -158,45 +143,41 @@ io.on('connection', (socket) => { dropped++; } player.score += dropped * 2; - lockPiece(room, player); + 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(room); + broadcastState(); } else { - lockPiece(room, player); + lockPiece(player); } } }); socket.on('disconnect', () => { - const roomName = socket.data.roomName; - if (!roomName) return; + const player = lobby.players.get(socket.id); + if (player) { + console.log(`${player.name} disconnected (${lobby.players.size - 1} remaining)`); - const room = rooms.get(roomName); - if (room) { - const player = room.players.get(socket.id); - if (player) { - console.log(`${player.name} left room ${roomName}`); + if (lobby.gameStarted) { + player.eliminated = true; + broadcastState(); + checkGameOver(); + } - if (room.gameStarted) { - player.eliminated = true; - broadcastState(room); - checkGameOver(room); - } + lobby.players.delete(socket.id); - room.players.delete(socket.id); - io.to(roomName).emit('player-left', { - playerId: socket.id, - players: getPlayersList(room) - }); + io.emit('player-left', { + playerId: socket.id, + players: getPlayersList() + }); - if (room.players.size === 0) { - if (room.gameInterval) clearInterval(room.gameInterval); - rooms.delete(roomName); - } + // If lobby empty and game running, stop game + if (lobby.players.size === 0 && lobby.gameStarted) { + if (lobby.gameInterval) clearInterval(lobby.gameInterval); + lobby.gameStarted = false; } } }); @@ -206,8 +187,8 @@ 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 => ({ +function getPlayersList() { + return Array.from(lobby.players.values()).map(p => ({ id: p.id, name: p.name, score: p.score, @@ -251,7 +232,7 @@ function spawnPiece(player) { return isValidPosition(player.currentPiece, player.currentPiece.x, player.currentPiece.y, player.board); } -function lockPiece(room, player) { +function lockPiece(player) { if (!player.currentPiece) return; for (let row = 0; row < player.currentPiece.shape.length; row++) { @@ -272,10 +253,10 @@ function lockPiece(room, player) { if (!spawnPiece(player)) player.eliminated = true; - if (rowsCleared > 0) sendGarbage(room, player, rowsCleared); + if (rowsCleared > 0) sendGarbage(player, rowsCleared); - broadcastState(room); - checkGameOver(room); + broadcastState(); + checkGameOver(); } function clearRows(player) { @@ -298,9 +279,9 @@ function clearRows(player) { return rowsCleared; } -function sendGarbage(room, sender, rowsCleared) { +function sendGarbage(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); + 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 < garbageRows; i++) { const target = opponents[Math.floor(Math.random() * opponents.length)]; @@ -324,10 +305,10 @@ function addGarbageToPlayer(player) { } } -function startGame(room) { - room.gameStarted = true; +function startGame() { + lobby.gameStarted = true; - for (const player of room.players.values()) { + for (const player of lobby.players.values()) { player.board = createEmptyBoard(); player.score = 0; player.lines = 0; @@ -338,18 +319,18 @@ function startGame(room) { player.nextPiece = getRandomPiece(); } - room.gameInterval = setInterval(() => gameTick(room), 50); + lobby.gameInterval = setInterval(() => gameTick(), 50); - io.to(room.name).emit('game-started', { - players: getPlayersList(room), - states: getStates(room) + io.emit('game-started', { + players: getPlayersList(), + states: getStates() }); - console.log(`Game started in room ${room.name} with ${room.players.size} players`); + console.log(`Game started with ${lobby.players.size} players`); } -function gameTick(room) { - for (const player of room.players.values()) { +function gameTick() { + for (const player of lobby.players.values()) { if (player.eliminated) continue; player.dropCounter += 50; @@ -358,20 +339,20 @@ function gameTick(room) { if (isValidPosition(player.currentPiece, player.currentPiece.x, player.currentPiece.y + 1, player.board)) { player.currentPiece.y++; } else { - lockPiece(room, player); + lockPiece(player); } } } - broadcastState(room); - checkGameOver(room); + broadcastState(); + checkGameOver(); } -function broadcastState(room) { - io.to(room.name).emit('state-update', getStates(room)); +function broadcastState() { + io.emit('state-update', getStates()); } -function getStates(room) { - return Array.from(room.players.values()).map(p => ({ +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, @@ -383,15 +364,15 @@ function getStates(room) { })); } -function checkGameOver(room) { - const activePlayers = Array.from(room.players.values()).filter(p => !p.eliminated); +function checkGameOver() { + const activePlayers = Array.from(lobby.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; + io.emit('game-over', { states: getStates() }); + if (lobby.gameInterval) { + clearInterval(lobby.gameInterval); + lobby.gameInterval = null; } - room.gameStarted = false; + lobby.gameStarted = false; } }