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:
2026-03-20 00:34:06 -07:00
commit 5da6033704
13 changed files with 3203 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
*.log
.DS_Store
+184
View File
@@ -0,0 +1,184 @@
# Tetris Battle Royale - 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
```
---
## Phase 1: Server Setup & Multiplayer Infrastructure [DONE]
**Goal**: Get a working Node.js server with Socket.io that handles rooms and player connections.
### 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
**Success criteria**: Can open 2+ browser tabs, join same room, see player list update.
---
## Phase 2: Core Tetris Game Logic [DONE]
**Goal**: Implement single-player Tetris mechanics on the client side.
### 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)
**Success criteria**: Single-player Tetris works smoothly with all piece types and controls.
---
## Phase 3: Multiplayer Game State Sync [DONE]
**Goal**: Connect multiple players and sync game state in real-time.
### 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
**Success criteria**: 2+ players can play simultaneously, all see each other's boards and piece movements in real-time.
---
## Phase 4: Garbage Battle System [DONE]
**Goal**: Implement the battle royale mechanic - clearing rows sends garbage to opponents.
### 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
**Success criteria**: Clearing rows visibly adds garbage rows to opponent boards.
---
## Phase 5: Retro UI Polish [IN PROGRESS]
**Goal**: Classic Tetris aesthetic with multi-board layout.
### 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
**Success criteria**: Game looks like classic Tetris with all boards visible and polished UI.
---
## 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
+86
View File
@@ -0,0 +1,86 @@
# Tetris Battle Royale
A multiplayer Tetris Battle Royale game built with HTML, CSS, JavaScript, and Node.js.
## Features
- **2-8 Player Multiplayer**: Real-time battles via WebSocket
- **Battle Royale Mechanics**: Clear rows to send garbage to opponents
- **Classic Tetris Gameplay**: All 7 tetrominoes with standard rotation
- **Retro Visuals**: CRT scanlines, pixel art font, classic colors
- **Multiple Boards View**: See all players' boards simultaneously
## How It Works
When you clear rows, garbage rows are sent to random opponents:
- 2 lines cleared → 1 garbage row sent
- 3 lines cleared → 2 garbage rows sent
- 4 lines (Tetris) → 4 garbage rows sent
The last player standing wins!
## Setup
1. Install dependencies:
```bash
cd server
npm install
```
2. Start the server:
```bash
node index.js
```
3. Open `http://localhost:3000` in 2-8 browser tabs
4. Enter a room name and your name, then join
5. Click "READY" when all players are ready
## Controls
- **Arrow Left/Right**: Move piece
- **Arrow Up**: Rotate piece
- **Arrow Down**: Soft drop
- **Space**: Hard drop
## Game Flow
1. **Lobby**: Players join a room and wait
2. **Ready**: All players click READY to start
3. **Game**: Play Tetris, clear rows to attack opponents
4. **Elimination**: Players are eliminated when pieces stack to the top
5. **Victory**: Last player standing wins
## File Structure
```
tetris-battle-royale/
├── server/
│ ├── index.js # Node.js + Socket.io server
│ └── package.json
├── public/
│ ├── index.html # Main HTML
│ ├── css/
│ │ └── style.css # Retro styling with CRT effects
│ └── js/
│ ├── game.js # Tetris game logic
│ ├── renderer.js # Canvas rendering
│ ├── network.js # Socket.io client
│ ├── ui.js # UI management
│ └── app.js # Main application
└── README.md
```
## Technology Stack
- **Frontend**: Vanilla JavaScript, HTML5 Canvas, CSS3
- **Backend**: Node.js, Express, Socket.io
- **No frameworks** - pure vanilla implementation
## Troubleshooting
- **Port already in use**: Change PORT in `server/index.js`
- **Can't connect**: Make sure server is running on port 3000
- **Game not starting**: Need at least 2 players ready
+303
View File
@@ -0,0 +1,303 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: #1a1a2e;
color: #fff;
font-family: 'Press Start 2P', cursive;
min-height: 100vh;
overflow-x: hidden;
}
/* CRT Scanline Effect */
body::before {
content: "";
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.15),
rgba(0, 0, 0, 0.15) 1px,
transparent 1px,
transparent 2px
);
pointer-events: none;
z-index: 1000;
}
#app {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.screen {
display: none;
flex-direction: column;
align-items: center;
gap: 20px;
width: 100%;
max-width: 1200px;
}
.screen.active {
display: flex;
}
h1 {
font-size: 2rem;
text-align: center;
color: #0ff;
text-shadow: 4px 4px 0 #ff00ff;
margin-bottom: 30px;
}
h2 {
font-size: 1.2rem;
color: #0f0;
text-shadow: 2px 2px 0 #004400;
}
h3 {
font-size: 1rem;
color: #ff0;
text-shadow: 2px 2px 0 #444400;
}
.form-group {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 15px;
}
label {
font-size: 0.7rem;
color: #888;
}
input {
background: #0a0a1a;
border: 2px solid #333;
color: #fff;
font-family: 'Press Start 2P', cursive;
font-size: 0.8rem;
padding: 15px;
text-align: center;
outline: none;
}
input:focus {
border-color: #0ff;
}
button {
background: #ff00ff;
border: 4px solid #fff;
color: #fff;
font-family: 'Press Start 2P', cursive;
font-size: 1rem;
padding: 15px 30px;
cursor: pointer;
text-transform: uppercase;
transition: transform 0.1s;
}
button:hover {
transform: scale(1.05);
background: #0ff;
color: #000;
}
button:active {
transform: scale(0.95);
}
#player-list {
display: flex;
flex-direction: column;
gap: 10px;
margin: 20px 0;
min-height: 150px;
}
.player-item {
background: #0a0a1a;
border: 2px solid #333;
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
}
.player-item .name {
color: #0ff;
}
.player-item .status {
color: #888;
}
.player-item .status.ready {
color: #0f0;
}
/* Battle Grid - Responsive Layout */
#battle-grid {
display: grid;
gap: 20px;
justify-content: center;
margin-top: 20px;
}
#battle-grid.grid-2x2 {
grid-template-columns: repeat(2, 1fr);
}
#battle-grid.grid-2x4 {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
}
.player-board {
background: #000;
border: 4px solid #333;
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.player-board.active {
border-color: #0ff;
}
.player-board.eliminated {
border-color: #f00;
opacity: 0.5;
}
.player-board canvas {
display: block;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.board-info {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 5px;
font-size: 0.6rem;
}
.board-info .name {
color: #0ff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.board-info .stats {
color: #888;
}
.next-piece-container {
display: flex;
align-items: center;
gap: 5px;
}
.next-piece-container canvas {
border: 1px solid #333;
}
#game-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 10px 20px;
background: #0a0a1a;
border: 2px solid #333;
margin-bottom: 20px;
}
#game-header span {
font-size: 0.8rem;
color: #0f0;
}
#game-header button {
font-size: 0.7rem;
padding: 10px 15px;
}
#game-status {
margin-top: 20px;
padding: 15px;
background: #0a0a1a;
border: 2px solid #333;
text-align: center;
font-size: 0.8rem;
color: #ff0;
}
#winner-display {
margin: 20px 0;
font-size: 1.2rem;
}
#final-scores {
display: flex;
flex-direction: column;
gap: 10px;
margin: 20px 0;
width: 100%;
max-width: 400px;
}
#final-scores .score-item {
background: #0a0a1a;
border: 2px solid #333;
padding: 10px 15px;
display: flex;
justify-content: space-between;
}
#final-scores .score-item.winner {
border-color: #0f0;
color: #0f0;
}
/* Garbage row animation */
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.player-board.shake {
animation: shake 0.2s ease-in-out;
}
/* Flash effect for row clear */
@keyframes flash {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.player-board.flash {
animation: flash 0.3s ease-in-out;
}
+61
View File
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tetris Battle Royale</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div id="app">
<!-- Room Selection 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>
</div>
<!-- Lobby Screen -->
<div id="lobby-screen" class="screen">
<h2>Room: <span id="lobby-room-name"></span></h2>
<div id="player-list"></div>
<button id="ready-btn">READY</button>
</div>
<!-- Game Screen -->
<div id="game-screen" class="screen">
<div id="game-header">
<span id="game-room-name"></span>
<button id="leave-btn">LEAVE</button>
</div>
<div id="battle-grid"></div>
<div id="game-status"></div>
</div>
<!-- Game Over Screen -->
<div id="gameover-screen" class="screen">
<h2>GAME OVER</h2>
<h3 id="winner-display"></h3>
<div id="final-scores"></div>
<button id="back-to-lobby">BACK TO LOBBY</button>
</div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="js/network.js"></script>
<script src="js/game.js"></script>
<script src="js/renderer.js"></script>
<script src="js/ui.js"></script>
<script src="js/app.js"></script>
</body>
</html>
+171
View File
@@ -0,0 +1,171 @@
// Main Application - Ties everything together
let localGame = null;
let renderer = null;
let lastTime = 0;
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
// Connect to server
network.connect();
// Initialize renderer
renderer = new TetrisRenderer('battle-grid');
// Setup network listeners
setupNetworkListeners();
// Setup keyboard controls
setupKeyboardControls();
});
function setupNetworkListeners() {
// Player joined lobby
network.setListener('player-joined', (player) => {
ui.updatePlayerList(network.getAllPlayers());
});
// Player left lobby
network.setListener('player-left', (playerId) => {
ui.updatePlayerList(network.getAllPlayers());
});
// Game started
network.setListener('game-started', (players, states) => {
ui.showScreen('game');
ui.displays.gameRoomName.textContent = network.currentRoom;
// Clear old boards
renderer.clearAll();
// Create boards for all players
states.forEach((state) => {
const player = network.getPlayer(state.playerId);
renderer.createPlayerBoard(state.playerId, player.name);
// Initialize local game for current player
if (state.playerId === network.currentPlayerId) {
localGame = new TetrisGame(state.playerId);
localGame.loadState(state);
}
});
// Set up battle grid layout
updateBattleGridLayout(players.length);
// Start game loop
lastTime = performance.now();
requestAnimationFrame(gameLoop);
});
// State update during game
network.setListener('state-update', (states) => {
// Update local game if it's our state
const localState = states.find(s => s.playerId === network.currentPlayerId);
if (localState) {
localGame.loadState(localState);
}
// Check for game over
const allStates = network.getAllGameStates();
const activePlayers = Object.values(allStates).filter(s => !s.eliminated);
if (activePlayers.length <= 1) {
endGame(allStates);
}
});
// Game over
network.setListener('game-over', (data) => {
endGame(data.states);
});
}
function setupKeyboardControls() {
document.addEventListener('keydown', (e) => {
if (ui.screens.game.classList.contains('active') && localGame) {
switch (e.key) {
case 'ArrowLeft':
network.sendMove('left');
e.preventDefault();
break;
case 'ArrowRight':
network.sendMove('right');
e.preventDefault();
break;
case 'ArrowDown':
network.sendDrop();
e.preventDefault();
break;
case 'ArrowUp':
network.sendRotate();
e.preventDefault();
break;
case ' ':
network.sendHardDrop();
e.preventDefault();
break;
}
}
});
}
function updateBattleGridLayout(playerCount) {
ui.displays.battleGrid.classList.remove('grid-2x2', 'grid-2x4');
if (playerCount <= 4) {
ui.displays.battleGrid.classList.add('grid-2x2');
} else {
ui.displays.battleGrid.classList.add('grid-2x4');
}
}
function endGame(states) {
// Find winner
const activePlayers = Object.values(states).filter(s => !s.eliminated);
const eliminatedPlayers = Object.values(states).filter(s => s.eliminated);
let winner = null;
let scores = {};
if (activePlayers.length === 1) {
const winnerState = activePlayers[0];
const winnerPlayer = network.getPlayer(winnerState.playerId);
winner = winnerPlayer.name;
}
// Build scores list
Object.values(states).forEach(state => {
const player = network.getPlayer(state.playerId);
if (player) {
scores[player.name] = state.score;
}
});
ui.showGameOver(winner, scores);
}
function gameLoop(currentTime) {
if (!ui.screens.game.classList.contains('active')) {
return;
}
const deltaTime = currentTime - lastTime;
lastTime = currentTime;
// Update local game
if (localGame) {
localGame.update(deltaTime);
}
// Render all players
const allStates = network.getAllGameStates();
Object.values(allStates).forEach(state => {
renderer.renderPlayer(state.playerId, state);
});
// Set active player highlight
renderer.setActivePlayer(network.currentPlayerId);
requestAnimationFrame(gameLoop);
}
+324
View File
@@ -0,0 +1,324 @@
// Core Tetris Game Logic
const BOARD_WIDTH = 10;
const BOARD_HEIGHT = 20;
const CELL_SIZE = 24;
// Tetromino definitions with colors
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 GARbage_COLOR = '#666666';
class TetrisGame {
constructor(playerId) {
this.playerId = playerId;
this.board = this.createEmptyBoard();
this.currentPiece = null;
this.nextPiece = null;
this.score = 0;
this.lines = 0;
this.level = 1;
this.gameOver = false;
this.eliminated = false;
// Game speed (milliseconds per drop)
this.dropInterval = 1000;
this.lastDrop = 0;
// Input lock for delay hits
this.inputLocked = false;
this.lockUntil = 0;
}
createEmptyBoard() {
return Array(BOARD_HEIGHT).fill(null).map(() => Array(BOARD_WIDTH).fill(0));
}
initialize() {
this.board = this.createEmptyBoard();
this.score = 0;
this.lines = 0;
this.level = 1;
this.gameOver = false;
this.eliminated = false;
this.dropInterval = 1000;
this.currentPiece = this.spawnPiece();
this.nextPiece = this.getRandomPiece();
if (!this.currentPiece) {
this.gameOver = true;
}
}
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
};
}
spawnPiece() {
const piece = this.nextPiece;
this.nextPiece = this.getRandomPiece();
// Check if spawn position is valid
if (!this.isValidPosition(piece.x, piece.y, piece.shape)) {
return null;
}
return piece;
}
isValidPosition(x, y, shape) {
for (let row = 0; row < shape.length; row++) {
for (let col = 0; col < shape[row].length; col++) {
if (shape[row][col]) {
const newX = x + col;
const newY = y + row;
// Check bounds
if (newX < 0 || newX >= BOARD_WIDTH || newY >= BOARD_HEIGHT) {
return false;
}
// Check collision with locked pieces (only if on board)
if (newY >= 0 && this.board[newY][newX]) {
return false;
}
}
}
}
return true;
}
move(direction) {
if (this.gameOver || this.inputLocked) return false;
const newX = this.currentPiece.x + (direction === 'left' ? -1 : 1);
if (this.isValidPosition(newX, this.currentPiece.y, this.currentPiece.shape)) {
this.currentPiece.x = newX;
return true;
}
return false;
}
rotate() {
if (this.gameOver || this.inputLocked) return false;
const originalShape = this.currentPiece.shape;
const rows = originalShape.length;
const cols = originalShape[0].length;
// Rotate 90 degrees clockwise
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];
}
}
// Try rotation with wall kicks
const kicks = [0, -1, 1, -2, 2];
for (const kick of kicks) {
if (this.isValidPosition(this.currentPiece.x + kick, this.currentPiece.y, rotated)) {
this.currentPiece.shape = rotated;
this.currentPiece.x += kick;
return true;
}
}
return false;
}
drop() {
if (this.gameOver || this.inputLocked) return false;
const newY = this.currentPiece.y + 1;
if (this.isValidPosition(this.currentPiece.x, newY, this.currentPiece.shape)) {
this.currentPiece.y = newY;
return true;
}
// Lock the piece
return this.lockPiece();
}
hardDrop() {
if (this.gameOver || this.inputLocked) return 0;
let dropped = 0;
while (this.isValidPosition(this.currentPiece.x, this.currentPiece.y + 1, this.currentPiece.shape)) {
this.currentPiece.y++;
dropped++;
}
this.lockPiece();
return dropped;
}
lockPiece() {
if (!this.currentPiece) return false;
// Lock piece into board
for (let row = 0; row < this.currentPiece.shape.length; row++) {
for (let col = 0; col < this.currentPiece.shape[row].length; col++) {
if (this.currentPiece.shape[row][col]) {
const boardY = this.currentPiece.y + row;
const boardX = this.currentPiece.x + col;
if (boardY >= 0 && boardY < BOARD_HEIGHT && boardX >= 0 && boardX < BOARD_WIDTH) {
this.board[boardY][boardX] = this.currentPiece.color;
}
}
}
}
// Check for game over (piece locked above visible area)
if (this.currentPiece.y <= 0) {
this.gameOver = true;
this.eliminated = true;
}
// Clear completed rows
const cleared = this.clearRows();
// Spawn new piece
this.currentPiece = this.spawnPiece();
if (!this.currentPiece) {
this.gameOver = true;
this.eliminated = true;
}
return cleared;
}
clearRows() {
let rowsCleared = 0;
for (let row = BOARD_HEIGHT - 1; row >= 0; row--) {
if (this.board[row].every(cell => cell !== 0)) {
// Remove the row
this.board.splice(row, 1);
// Add empty row at top
this.board.unshift(Array(BOARD_WIDTH).fill(0));
rowsCleared++;
row++; // Check same row again
}
}
if (rowsCleared > 0) {
this.lines += rowsCleared;
this.updateScore(rowsCleared);
this.updateLevel();
}
return rowsCleared;
}
updateScore(rowsCleared) {
const points = [0, 100, 300, 500, 800];
this.score += points[rowsCleared] * this.level;
}
updateLevel() {
this.level = Math.floor(this.lines / 10) + 1;
this.dropInterval = Math.max(100, 1000 - (this.level - 1) * 100);
}
receiveGarbage(rows) {
if (this.gameOver) return;
// Add garbage rows at bottom
for (let i = 0; i < rows; i++) {
// Remove top row (simulating overflow)
this.board.pop();
// Add garbage row with random gap
const garbageRow = Array(BOARD_WIDTH).fill(GARbage_COLOR);
const gap = Math.floor(Math.random() * BOARD_WIDTH);
garbageRow[gap] = 0;
this.board.unshift(garbageRow);
}
// Check if garbage caused game over
for (let row = 0; row < 2; row++) {
for (let col = 0; col < BOARD_WIDTH; col++) {
if (this.board[row][col] !== 0) {
this.gameOver = true;
this.eliminated = true;
return;
}
}
}
}
applyDelayHit(duration) {
this.inputLocked = true;
this.lockUntil = Date.now() + duration;
}
update(deltaTime) {
if (this.gameOver) return;
// Check if input lock has expired
if (this.inputLocked && Date.now() >= this.lockUntil) {
this.inputLocked = false;
}
// Auto drop
if (!this.inputLocked && Date.now() - this.lastDrop >= this.dropInterval) {
this.drop();
this.lastDrop = Date.now();
}
}
getState() {
return {
playerId: this.playerId,
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,
score: this.score,
lines: this.lines,
level: this.level,
gameOver: this.gameOver,
eliminated: this.eliminated
};
}
loadState(state) {
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.score = state.score;
this.lines = state.lines;
this.level = state.level;
this.gameOver = state.gameOver;
this.eliminated = state.eliminated;
this.dropInterval = Math.max(100, 1000 - (this.level - 1) * 100);
}
}
// Export for use in other modules
window.TetrisGame = TetrisGame;
window.TETROMINOS = TETROMINOS;
window.BOARD_WIDTH = BOARD_WIDTH;
window.BOARD_HEIGHT = BOARD_HEIGHT;
window.CELL_SIZE = CELL_SIZE;
window.GARBAGE_COLOR = GARbage_COLOR;
+153
View File
@@ -0,0 +1,153 @@
// Network module - Socket.io client handling
class NetworkManager {
constructor() {
this.socket = null;
this.currentRoom = null;
this.currentPlayerId = null;
this.players = {};
this.gameState = {};
this.listeners = {
onPlayerJoined: null,
onPlayerLeft: null,
onGameStarted: null,
onStateUpdate: null,
onGameOver: null
};
}
connect() {
this.socket = io();
this.socket.on('connect', () => {
console.log('Connected to server');
this.currentPlayerId = this.socket.id;
});
this.socket.on('disconnect', () => {
console.log('Disconnected from server');
this.currentRoom = null;
});
this.socket.on('player-joined', ({ player, players }) => {
this.updatePlayers(players);
if (this.listeners.onPlayerJoined) {
this.listeners.onPlayerJoined(player);
}
});
this.socket.on('player-left', ({ playerId, players }) => {
delete this.players[playerId];
this.updatePlayers(players);
if (this.listeners.onPlayerLeft) {
this.listeners.onPlayerLeft(playerId);
}
});
this.socket.on('game-started', ({ players, states }) => {
this.updatePlayers(players);
this.updateStates(states);
if (this.listeners.onGameStarted) {
this.listeners.onGameStarted(players, states);
}
});
this.socket.on('state-update', (states) => {
this.updateStates(states);
if (this.listeners.onStateUpdate) {
this.listeners.onStateUpdate(states);
}
});
this.socket.on('game-over', (data) => {
if (this.listeners.onGameOver) {
this.listeners.onGameOver(data);
}
});
}
joinRoom(roomName, playerName) {
if (!this.socket) return;
this.currentRoom = roomName;
this.socket.emit('join-room', { roomName, playerName });
}
leaveRoom() {
if (!this.socket) return;
if (this.currentRoom) {
this.socket.leave(this.currentRoom);
}
this.currentRoom = null;
}
ready() {
if (!this.socket) return;
this.socket.emit('ready');
}
sendMove(direction) {
if (!this.socket || !this.currentPlayerId) return;
this.socket.emit('player-move', { playerId: this.currentPlayerId, direction });
}
sendRotate() {
if (!this.socket || !this.currentPlayerId) return;
this.socket.emit('player-rotate', { playerId: this.currentPlayerId });
}
sendDrop() {
if (!this.socket || !this.currentPlayerId) return;
this.socket.emit('player-drop', { playerId: this.currentPlayerId, hard: false });
}
sendHardDrop() {
if (!this.socket || !this.currentPlayerId) return;
this.socket.emit('player-drop', { playerId: this.currentPlayerId, hard: true });
}
updatePlayers(players) {
this.players = {};
players.forEach(p => {
this.players[p.id] = p;
});
}
updateStates(states) {
states.forEach(s => {
this.gameState[s.playerId] = s;
});
}
getPlayer(playerId) {
return this.players[playerId];
}
getGameState(playerId) {
return this.gameState[playerId];
}
getAllPlayers() {
return this.players;
}
getAllGameStates() {
return this.gameState;
}
setListener(event, callback) {
const listenerMap = {
'player-joined': 'onPlayerJoined',
'player-left': 'onPlayerLeft',
'game-started': 'onGameStarted',
'state-update': 'onStateUpdate',
'game-over': 'onGameOver'
};
if (callback) {
this.listeners[listenerMap[event]] = callback;
}
}
}
const network = new NetworkManager();
+264
View File
@@ -0,0 +1,264 @@
// Canvas Renderer for Tetris
class TetrisRenderer {
constructor(containerId) {
this.container = document.getElementById(containerId);
this.boards = new Map();
}
createPlayerBoard(playerId, playerName) {
const boardDiv = document.createElement('div');
boardDiv.className = 'player-board';
boardDiv.id = `board-${playerId}`;
boardDiv.dataset.playerId = playerId;
// Main game canvas
const canvas = document.createElement('canvas');
canvas.id = `canvas-${playerId}`;
canvas.width = BOARD_WIDTH * CELL_SIZE;
canvas.height = BOARD_HEIGHT * CELL_SIZE;
canvas.style.width = `${BOARD_WIDTH * CELL_SIZE}px`;
canvas.style.height = `${BOARD_HEIGHT * CELL_SIZE}px`;
boardDiv.appendChild(canvas);
// Player info
const infoDiv = document.createElement('div');
infoDiv.className = 'board-info';
infoDiv.innerHTML = `
<span class="name">${playerName}</span>
<span class="stats">Lines: <span class="lines">0</span></span>
`;
boardDiv.appendChild(infoDiv);
// Next piece preview
const nextDiv = document.createElement('div');
nextDiv.className = 'board-info';
const nextCanvas = document.createElement('canvas');
nextCanvas.id = `next-${playerId}`;
nextCanvas.width = 80;
nextCanvas.height = 80;
nextCanvas.style.width = '80px';
nextCanvas.style.height = '80px';
nextDiv.innerHTML = '<span>NEXT:</span>';
nextDiv.appendChild(nextCanvas);
boardDiv.appendChild(nextDiv);
this.container.appendChild(boardDiv);
this.boards.set(playerId, {
element: boardDiv,
canvas: canvas,
nextCanvas: nextCanvas,
info: infoDiv,
ctx: canvas.getContext('2d'),
nextCtx: nextCanvas.getContext('2d')
});
return boardDiv;
}
removePlayerBoard(playerId) {
const board = this.boards.get(playerId);
if (board) {
board.element.remove();
this.boards.delete(playerId);
}
}
renderPlayer(playerId, gameState) {
const boardData = this.boards.get(playerId);
if (!boardData) return;
const { ctx, nextCtx, element, info } = boardData;
// Clear canvas
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
// Draw grid lines
this.drawGrid(ctx);
// Draw locked pieces
if (gameState && gameState.board) {
this.drawBoard(ctx, gameState.board);
}
// Draw current piece
if (gameState && gameState.currentPiece && !gameState.gameOver) {
this.drawPiece(ctx, gameState.currentPiece);
}
// Draw next piece
if (gameState && gameState.nextPiece) {
this.drawNextPiece(nextCtx, gameState.nextPiece);
}
// Update stats
if (gameState) {
const linesSpan = info.querySelector('.lines');
if (linesSpan) {
linesSpan.textContent = gameState.lines;
}
}
// Update board state classes
element.classList.remove('active', 'eliminated');
if (gameState && gameState.eliminated) {
element.classList.add('eliminated');
}
}
drawGrid(ctx) {
ctx.strokeStyle = '#222';
ctx.lineWidth = 1;
for (let x = 0; x <= BOARD_WIDTH; x++) {
ctx.beginPath();
ctx.moveTo(x * CELL_SIZE, 0);
ctx.lineTo(x * CELL_SIZE, BOARD_HEIGHT * CELL_SIZE);
ctx.stroke();
}
for (let y = 0; y <= BOARD_HEIGHT; y++) {
ctx.beginPath();
ctx.moveTo(0, y * CELL_SIZE);
ctx.lineTo(BOARD_WIDTH * CELL_SIZE, y * CELL_SIZE);
ctx.stroke();
}
}
drawBoard(ctx, board) {
for (let row = 0; row < BOARD_HEIGHT; row++) {
for (let col = 0; col < BOARD_WIDTH; col++) {
const color = board[row][col];
if (color) {
this.drawCell(ctx, col, row, color);
}
}
}
}
drawPiece(ctx, piece) {
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 = piece.x + col;
const y = piece.y + row;
if (y >= 0) {
this.drawCell(ctx, x, y, piece.color);
}
}
}
}
}
drawCell(ctx, x, y, color) {
const px = x * CELL_SIZE;
const py = y * CELL_SIZE;
// Main fill
ctx.fillStyle = color;
ctx.fillRect(px, py, CELL_SIZE, CELL_SIZE);
// Bevel effect
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.fillRect(px, py, CELL_SIZE, 3);
ctx.fillRect(px, py, 3, CELL_SIZE);
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.fillRect(px, py + CELL_SIZE - 3, CELL_SIZE, 3);
ctx.fillRect(px + CELL_SIZE - 3, py, 3, CELL_SIZE);
// Border
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
ctx.lineWidth = 1;
ctx.strokeRect(px, py, CELL_SIZE, CELL_SIZE);
}
drawNextPiece(ctx, piece) {
// Clear
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
// 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;
ctx.fillRect(x, y, 20, 20);
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 active class from all boards
this.boards.forEach((boardData) => {
boardData.element.classList.remove('active');
});
// Add to current player
const boardData = this.boards.get(playerId);
if (boardData) {
boardData.element.classList.add('active');
}
}
triggerShake(playerId) {
const boardData = this.boards.get(playerId);
if (boardData) {
boardData.element.classList.remove('shake');
void boardData.element.offsetWidth; // Trigger reflow
boardData.element.classList.add('shake');
}
}
triggerFlash(playerId) {
const boardData = this.boards.get(playerId);
if (boardData) {
boardData.element.classList.remove('flash');
void boardData.element.offsetWidth; // Trigger reflow
boardData.element.classList.add('flash');
}
}
updatePlayerInfo(playerId, name, score, lines) {
const boardData = this.boards.get(playerId);
if (boardData) {
const nameSpan = boardData.info.querySelector('.name');
if (nameSpan) nameSpan.textContent = name;
const linesSpan = boardData.info.querySelector('.lines');
if (linesSpan) linesSpan.textContent = lines;
}
}
clearAll() {
this.boards.forEach((boardData) => {
boardData.element.remove();
});
this.boards.clear();
}
}
window.TetrisRenderer = TetrisRenderer;
+138
View File
@@ -0,0 +1,138 @@
// UI Module - Handle screens and user interactions
class UIManager {
constructor() {
this.screens = {
room: document.getElementById('room-screen'),
lobby: document.getElementById('lobby-screen'),
game: document.getElementById('game-screen'),
gameover: document.getElementById('gameover-screen')
};
this.inputs = {
roomName: document.getElementById('room-name'),
playerName: document.getElementById('player-name')
};
this.buttons = {
join: document.getElementById('join-btn'),
ready: document.getElementById('ready-btn'),
leave: document.getElementById('leave-btn'),
backToLobby: document.getElementById('back-to-lobby')
};
this.displays = {
lobbyRoomName: document.getElementById('lobby-room-name'),
gameRoomName: document.getElementById('game-room-name'),
playerList: document.getElementById('player-list'),
battleGrid: document.getElementById('battle-grid'),
gameStatus: document.getElementById('game-status'),
winnerDisplay: document.getElementById('winner-display'),
finalScores: document.getElementById('final-scores')
};
this.bindEvents();
}
bindEvents() {
this.buttons.join.addEventListener('click', () => this.handleJoin());
this.buttons.ready.addEventListener('click', () => this.handleReady());
this.buttons.leave.addEventListener('click', () => this.handleLeave());
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();
});
}
showScreen(screenName) {
Object.values(this.screens).forEach(screen => screen.classList.remove('active'));
this.screens[screenName].classList.add('active');
}
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');
return;
}
// Emit join event
network.setListener('player-joined', () => {
this.showScreen('lobby');
this.displays.lobbyRoomName.textContent = roomName;
});
network.joinRoom(roomName, playerName);
}
handleReady() {
network.ready();
this.buttons.ready.textContent = 'READY!';
this.buttons.ready.disabled = true;
}
handleLeave() {
network.leaveRoom();
this.showScreen('room');
}
handleBackToLobby() {
this.showScreen('room');
}
updatePlayerList(players) {
this.displays.playerList.innerHTML = '';
Object.values(players).forEach(player => {
const item = document.createElement('div');
item.className = 'player-item';
const statusClass = player.ready ? 'ready' : '';
item.innerHTML = `
<span class="name">${this.escapeHtml(player.name)}</span>
<span class="status ${statusClass}">${player.ready ? 'READY' : 'WAITING'}</span>
`;
this.displays.playerList.appendChild(item);
});
}
showMessage(message) {
this.displays.gameStatus.textContent = message;
setTimeout(() => {
this.displays.gameStatus.textContent = '';
}, 3000);
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
showGameOver(winner, scores) {
this.displays.winnerDisplay.textContent = winner
? `Winner: ${winner}!`
: 'Game Over!';
this.displays.winnerDisplay.style.color = winner ? '#0f0' : '#fff';
this.displays.finalScores.innerHTML = '';
Object.entries(scores).forEach(([name, score], index) => {
const item = document.createElement('div');
item.className = 'score-item' + (index === 0 ? ' winner' : '');
item.innerHTML = `
<span>${this.escapeHtml(name)}</span>
<span>${score}</span>
`;
this.displays.finalScores.appendChild(item);
});
this.showScreen('gameover');
}
}
window.ui = new UIManager();
+401
View File
@@ -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!`);
});
+1100
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -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"
}
}