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
This commit is contained in:
2026-03-20 07:09:51 -07:00
parent 5da6033704
commit e7917a338e
6 changed files with 172 additions and 274 deletions
+78 -141
View File
@@ -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)
+3 -7
View File
@@ -11,23 +11,19 @@
</head>
<body>
<div id="app">
<!-- Room Selection Screen -->
<!-- Login Screen -->
<div id="room-screen" class="screen active">
<h1>TETRIS<br>BATTLE ROYALE</h1>
<div class="form-group">
<label for="room-name">Room Name</label>
<input type="text" id="room-name" placeholder="Enter room name">
</div>
<div class="form-group">
<label for="player-name">Your Name</label>
<input type="text" id="player-name" placeholder="Enter your name">
</div>
<button id="join-btn">JOIN ROOM</button>
<button id="join-btn">JOIN LOBBY</button>
</div>
<!-- Lobby Screen -->
<div id="lobby-screen" class="screen">
<h2>Room: <span id="lobby-room-name"></span></h2>
<h2>GLOBAL LOBBY</h2>
<div id="player-list"></div>
<button id="ready-btn">READY</button>
</div>
+1 -1
View File
@@ -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();
+3 -12
View File
@@ -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() {
+5 -12
View File
@@ -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');
}
+82 -101
View File
@@ -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;
}
}