From 4a49c76cdcd6a7d31a622e1bfe32de5065c97fa9 Mon Sep 17 00:00:00 2001 From: Josue Zamudio Date: Fri, 20 Mar 2026 08:50:52 -0700 Subject: [PATCH] Add hold piece feature - Added holdPiece and canHold state to TetrisGame class - Implemented hold() method to swap current piece with held piece - Added player-hold socket event on server - Added HOLD preview canvas showing held piece (grayed when unavailable) - Added C key keyboard shortcut and touch button for hold - Fixed canHold reset on piece spawn for proper swap functionality Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 73 +++++++++++++++++++++++++ public/css/style.css | 121 ++++++++++++++++++++++++++++++++++++++++++ public/index.html | 25 ++++++--- public/js/app.js | 37 +++++++++++++ public/js/game.js | 43 +++++++++++++++ public/js/network.js | 5 ++ public/js/renderer.js | 71 ++++++++++++++++++++++++- server/index.js | 43 +++++++++++++++ 8 files changed, 410 insertions(+), 8 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..711668a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,73 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Multiplayer Tetris Battle Royale game with 2-8 player real-time battles via WebSocket. Players clear rows to send garbage to opponents; last player standing wins. + +## Commands + +```bash +# Install dependencies +cd server && npm install + +# Start server +cd server && npm start +# or +cd server && node index.js + +# Server runs on http://localhost:3000 (or PORT env variable) +``` + +## Architecture + +### Server-Client Model + +- **Server** (`server/index.js`): Express + Socket.io handles all game logic authoritatively + - Manages single global lobby with `lobby.players` Map + - Game tick runs at 50ms intervals via `gameTick()` + - Broadcasts state updates to all connected clients via `broadcastState()` + - Tetromino definitions and board constants are duplicated on server + +- **Client** (`public/js/`): Vanilla JavaScript with module pattern + - `network.js`: Socket.io client wrapper, manages player/game state caching + - `game.js`: `TetrisGame` class for local game state (mirrors server) + - `renderer.js`: `TetrisRenderer` class - Canvas rendering for all player boards + - `ui.js`: `UIManager` class - screen transitions, DOM manipulation + - `app.js`: Entry point, ties modules together, game loop via `requestAnimationFrame` + +### Game Flow + +1. **Lobby**: Players join global lobby via `join-lobby` socket event +2. **Ready**: All players click READY; game starts when all ready (2-8 players) +3. **Game**: Server authoritative - client inputs sent via socket, server broadcasts state +4. **Elimination**: Player eliminated when piece locks at top or garbage fills board +5. **Victory**: Game ends when 1 active player remains + +### Key Constants + +- `BOARD_WIDTH = 10`, `BOARD_HEIGHT = 20`, `CELL_SIZE = 24` +- `LOBBY_ROOM = 'global-lobby'` - Socket.io room for all players +- Garbage rules: 2 lines cleared -> 1 garbage row, 3 -> 2, 4 (Tetris) -> 4 rows + +### Socket Events + +| Event | Direction | Payload | +|-------|-----------|---------| +| `join-lobby` | Client->Server | `{ playerName }` | +| `ready` | Client->Server | - | +| `player-move` | Client->Server | `{ playerId, direction }` | +| `player-rotate` | Client->Server | `{ playerId }` | +| `player-drop` | Client->Server | `{ playerId, hard }` | +| `player-joined` | Server->Client | `{ player, players }` | +| `player-left` | Server->Client | `{ playerId, players }` | +| `game-started` | Server->Client | `{ players, states }` | +| `state-update` | Server->Client | `states[]` | +| `game-over` | Server->Client | `{ states }` | + +### Rendering + +- Each player gets a dynamically created board with canvas + info divs +- `renderer.setActivePlayer()` marks current player's board as `.main`, others as `.spectator` +- Battle grid layout classes: `.single-player`, `.two-players`, `.multi-player` diff --git a/public/css/style.css b/public/css/style.css index 56fb028..67bf86d 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -289,6 +289,19 @@ button:active { border: 1px solid #333; } +/* Hold and Next piece previews side by side */ +.board-info:has(canvas) { + justify-content: center; + gap: 20px; + padding: 8px 5px; +} + +.board-info canvas { + border: 1px solid #333; + background: #000; +} +} + #game-header { display: flex; justify-content: space-between; @@ -399,3 +412,111 @@ button:active { .player-board.flash { animation: flash 0.3s ease-in-out; } + +/* Touch Controls */ +#touch-controls { + display: none; + width: 100%; + max-width: 400px; + margin-top: 20px; + gap: 20px; + justify-content: center; + align-items: flex-end; + padding-bottom: 20px; +} + +#touch-dpad { + display: grid; + grid-template-columns: repeat(3, 70px); + grid-template-rows: repeat(2, 70px); + gap: 8px; +} + +#btn-left { + grid-column: 1; + grid-row: 2; +} + +#btn-down { + grid-column: 2; + grid-row: 2; +} + +#btn-right { + grid-column: 3; + grid-row: 2; +} + +#touch-actions { + display: grid; + grid-template-columns: repeat(2, 70px); + grid-template-rows: repeat(2, 70px); + gap: 8px; +} + +#btn-rotate { + grid-column: 1; + grid-row: 1; +} + +#btn-drop { + grid-column: 2; + grid-row: 1; +} + +#btn-hold { + grid-column: 1; + grid-row: 2; +} + +.touch-btn { + width: 70px; + height: 70px; + font-size: 1.5rem; + background: #333; + border: 3px solid #555; + border-radius: 10px; + padding: 0; + display: flex; + justify-content: center; + align-items: center; + user-select: none; + -webkit-user-select: none; + touch-action: manipulation; +} + +.touch-btn:active { + background: #0ff; + color: #000; + transform: scale(0.95); +} + +.touch-btn.action-btn { + background: #444; +} + +.touch-btn.drop-btn { + background: #ff00ff; + border-color: #ff00ff; +} + +.touch-btn.hold-btn { + background: #ff8800; + border-color: #ff8800; + font-size: 0.9rem; +} + +/* Show touch controls on mobile only */ +@media (max-width: 768px) and (hover: none) { + #touch-controls { + display: flex; + } + + #battle-grid { + height: 60vh; + } + + .player-board.spectator { + display: none; + } +} diff --git a/public/index.html b/public/index.html index f42d62d..3a38cdb 100644 --- a/public/index.html +++ b/public/index.html @@ -7,7 +7,7 @@ - +
@@ -36,6 +36,19 @@
+ +
+
+ + + +
+
+ + + +
+
@@ -48,10 +61,10 @@ - - - - - + + + + + diff --git a/public/js/app.js b/public/js/app.js index e8ab217..8863d64 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -109,9 +109,46 @@ function setupKeyboardControls() { network.sendHardDrop(); e.preventDefault(); break; + case 'c': + case 'C': + network.sendHold(); + e.preventDefault(); + break; } } }); + + // Touch controls for mobile + const btnLeft = document.getElementById('btn-left'); + const btnRight = document.getElementById('btn-right'); + const btnDown = document.getElementById('btn-down'); + const btnRotate = document.getElementById('btn-rotate'); + const btnDrop = document.getElementById('btn-drop'); + const btnHold = document.getElementById('btn-hold'); + + const handleTouch = (e, action) => { + e.preventDefault(); + e.stopPropagation(); + if (ui.screens.game.classList.contains('active')) { + action(); + } + }; + + // Use pointerdown for better touch response + btnLeft.addEventListener('pointerdown', (e) => handleTouch(e, () => network.sendMove('left'))); + btnRight.addEventListener('pointerdown', (e) => handleTouch(e, () => network.sendMove('right'))); + btnDown.addEventListener('pointerdown', (e) => handleTouch(e, () => network.sendDrop())); + btnRotate.addEventListener('pointerdown', (e) => handleTouch(e, () => network.sendRotate())); + btnDrop.addEventListener('pointerdown', (e) => handleTouch(e, () => network.sendHardDrop())); + btnHold.addEventListener('pointerdown', (e) => handleTouch(e, () => network.sendHold())); + + // Prevent double-tap zoom + btnLeft.addEventListener('touchstart', (e) => e.preventDefault()); + btnRight.addEventListener('touchstart', (e) => e.preventDefault()); + btnDown.addEventListener('touchstart', (e) => e.preventDefault()); + btnRotate.addEventListener('touchstart', (e) => e.preventDefault()); + btnDrop.addEventListener('touchstart', (e) => e.preventDefault()); + btnHold.addEventListener('touchstart', (e) => e.preventDefault()); } function updateBattleGridLayout(playerCount) { diff --git a/public/js/game.js b/public/js/game.js index 2529a63..99ac2c3 100644 --- a/public/js/game.js +++ b/public/js/game.js @@ -24,6 +24,8 @@ class TetrisGame { this.board = this.createEmptyBoard(); this.currentPiece = null; this.nextPiece = null; + this.holdPiece = null; + this.canHold = true; this.score = 0; this.lines = 0; this.level = 1; @@ -51,6 +53,8 @@ class TetrisGame { this.gameOver = false; this.eliminated = false; this.dropInterval = 1000; + this.holdPiece = null; + this.canHold = true; this.currentPiece = this.spawnPiece(); this.nextPiece = this.getRandomPiece(); @@ -172,6 +176,38 @@ class TetrisGame { return dropped; } + hold() { + if (this.gameOver || this.inputLocked || !this.canHold) return false; + + if (this.holdPiece === null) { + // First hold - store current piece and spawn next + this.holdPiece = { + type: this.currentPiece.type, + shape: JSON.parse(JSON.stringify(this.currentPiece.shape)), + color: this.currentPiece.color + }; + this.currentPiece = this.nextPiece; + this.nextPiece = this.getRandomPiece(); + } else { + // Swap with held piece + const temp = { + type: this.currentPiece.type, + shape: JSON.parse(JSON.stringify(this.currentPiece.shape)), + color: this.currentPiece.color + }; + this.currentPiece = { + ...this.holdPiece, + shape: JSON.parse(JSON.stringify(this.holdPiece.shape)), + x: Math.floor(BOARD_WIDTH / 2) - Math.floor(this.holdPiece.shape[0].length / 2), + y: 0 + }; + this.holdPiece = temp; + } + + this.canHold = false; + return true; + } + lockPiece() { if (!this.currentPiece) return false; @@ -205,6 +241,9 @@ class TetrisGame { this.eliminated = true; } + // Reset canHold for the new piece + this.canHold = true; + return cleared; } @@ -294,6 +333,8 @@ class TetrisGame { board: JSON.parse(JSON.stringify(this.board)), currentPiece: this.currentPiece ? JSON.parse(JSON.stringify(this.currentPiece)) : null, nextPiece: this.nextPiece ? JSON.parse(JSON.stringify(this.nextPiece)) : null, + holdPiece: this.holdPiece ? JSON.parse(JSON.stringify(this.holdPiece)) : null, + canHold: this.canHold, score: this.score, lines: this.lines, level: this.level, @@ -306,6 +347,8 @@ class TetrisGame { this.board = JSON.parse(JSON.stringify(state.board)); this.currentPiece = state.currentPiece ? JSON.parse(JSON.stringify(state.currentPiece)) : null; this.nextPiece = state.nextPiece ? JSON.parse(JSON.stringify(state.nextPiece)) : null; + this.holdPiece = state.holdPiece ? JSON.parse(JSON.stringify(state.holdPiece)) : null; + this.canHold = state.canHold !== undefined ? state.canHold : true; this.score = state.score; this.lines = state.lines; this.level = state.level; diff --git a/public/js/network.js b/public/js/network.js index 13acd86..bb6a28e 100644 --- a/public/js/network.js +++ b/public/js/network.js @@ -98,6 +98,11 @@ class NetworkManager { this.socket.emit('player-drop', { playerId: this.currentPlayerId, hard: true }); } + sendHold() { + if (!this.socket || !this.currentPlayerId) return; + this.socket.emit('player-hold', { playerId: this.currentPlayerId }); + } + updatePlayers(players) { this.players = {}; players.forEach(p => { diff --git a/public/js/renderer.js b/public/js/renderer.js index 043a0db..0b70784 100644 --- a/public/js/renderer.js +++ b/public/js/renderer.js @@ -43,15 +43,30 @@ class TetrisRenderer { nextDiv.appendChild(nextCanvas); boardDiv.appendChild(nextDiv); + // Hold piece preview + const holdDiv = document.createElement('div'); + holdDiv.className = 'board-info'; + const holdCanvas = document.createElement('canvas'); + holdCanvas.id = `hold-${playerId}`; + holdCanvas.width = 80; + holdCanvas.height = 80; + holdCanvas.style.width = '80px'; + holdCanvas.style.height = '80px'; + holdDiv.innerHTML = 'HOLD:'; + holdDiv.appendChild(holdCanvas); + boardDiv.appendChild(holdDiv); + this.container.appendChild(boardDiv); this.boards.set(playerId, { element: boardDiv, canvas: canvas, nextCanvas: nextCanvas, + holdCanvas: holdCanvas, info: infoDiv, ctx: canvas.getContext('2d'), - nextCtx: nextCanvas.getContext('2d') + nextCtx: nextCanvas.getContext('2d'), + holdCtx: holdCanvas.getContext('2d') }); return boardDiv; @@ -69,7 +84,7 @@ class TetrisRenderer { const boardData = this.boards.get(playerId); if (!boardData) return; - const { ctx, nextCtx, element, info } = boardData; + const { ctx, nextCtx, holdCtx, element, info } = boardData; // Clear canvas ctx.fillStyle = '#000'; @@ -93,6 +108,15 @@ class TetrisRenderer { this.drawNextPiece(nextCtx, gameState.nextPiece); } + // Draw hold piece + if (gameState && gameState.holdPiece) { + this.drawHoldPiece(holdCtx, gameState.holdPiece, gameState.canHold); + } else if (holdCtx) { + // Clear hold canvas if no hold piece + holdCtx.fillStyle = '#000'; + holdCtx.fillRect(0, 0, holdCtx.canvas.width, holdCtx.canvas.height); + } + // Update stats if (gameState) { const linesSpan = info.querySelector('.lines'); @@ -222,6 +246,49 @@ class TetrisRenderer { } } + drawHoldPiece(ctx, piece, canHold) { + // Clear + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); + + if (!piece) return; + + // Gray out if cannot hold again this turn + const alpha = canHold ? 1.0 : 0.4; + + // Center the piece + const pieceWidth = piece.shape[0].length * 20; + const pieceHeight = piece.shape.length * 20; + const offsetX = (ctx.canvas.width - pieceWidth) / 2; + const offsetY = (ctx.canvas.height - pieceHeight) / 2; + + for (let row = 0; row < piece.shape.length; row++) { + for (let col = 0; col < piece.shape[row].length; col++) { + if (piece.shape[row][col]) { + const x = offsetX + col * 20; + const y = offsetY + row * 20; + + ctx.fillStyle = piece.color + Math.floor(alpha * 255).toString(16).padStart(2, '0'); + ctx.fillRect(x, y, 20, 20); + + if (canHold) { + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.fillRect(x, y, 20, 3); + ctx.fillRect(x, y, 3, 20); + + ctx.fillStyle = 'rgba(0,0,0,0.3)'; + ctx.fillRect(x, y + 17, 20, 3); + ctx.fillRect(x + 17, y, 3, 20); + + ctx.strokeStyle = 'rgba(0,0,0,0.5)'; + ctx.lineWidth = 1; + ctx.strokeRect(x, y, 20, 20); + } + } + } + } + } + setActivePlayer(playerId) { // Remove main class from all boards this.boards.forEach((boardData, id) => { diff --git a/server/index.js b/server/index.js index cb04953..85a8a8c 100644 --- a/server/index.js +++ b/server/index.js @@ -159,6 +159,42 @@ io.on('connection', (socket) => { } }); + socket.on('player-hold', ({ playerId }) => { + if (!lobby.gameStarted) return; + + const player = lobby.players.get(playerId); + if (!player || player.eliminated) return; + if (!player.canHold) return; + + if (player.holdPiece === null) { + // First hold - store current piece and spawn next + player.holdPiece = { + type: player.currentPiece.type, + shape: JSON.parse(JSON.stringify(player.currentPiece.shape)), + color: player.currentPiece.color + }; + player.currentPiece = player.nextPiece; + player.nextPiece = getRandomPiece(); + } else { + // Swap with held piece + 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; + } + + player.canHold = false; + broadcastState(); + }); + socket.on('disconnect', () => { const player = lobby.players.get(socket.id); if (player) { @@ -254,6 +290,9 @@ function lockPiece(player) { if (!spawnPiece(player)) player.eliminated = true; + // Reset canHold for the new piece + player.canHold = true; + if (rowsCleared > 0) sendGarbage(player, rowsCleared); broadcastState(); @@ -309,6 +348,8 @@ function startGame() { player.level = 1; player.eliminated = false; player.dropInterval = 1000; + player.holdPiece = null; + player.canHold = true; player.currentPiece = getRandomPiece(); player.nextPiece = getRandomPiece(); } @@ -351,6 +392,8 @@ function getStates() { 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, + holdPiece: p.holdPiece ? JSON.parse(JSON.stringify(p.holdPiece)) : null, + canHold: p.canHold, score: p.score, lines: p.lines, level: p.level,