From 1ea5c59932bb34159bf66492a2f3aa9fba1ed304 Mon Sep 17 00:00:00 2001 From: LuckyIntegral Date: Tue, 3 Mar 2026 22:25:05 +0000 Subject: [PATCH 1/5] feat: add game modes on server --- server/constants/index.ts | 22 ++ server/constants/tetrominos.ts | 523 ++++++++++++++++++++++++++++++++ server/core/GameManager.ts | 5 +- server/game/Game.ts | 7 +- server/game/GameEngine.ts | 10 +- server/game/contstants.ts | 145 --------- server/net/socketHandlers.ts | 9 +- server/tests/gameEngine.test.ts | 88 +++--- server/tsconfig.json | 1 + server/types/socketEvents.ts | 4 +- 10 files changed, 619 insertions(+), 195 deletions(-) create mode 100644 server/constants/index.ts create mode 100644 server/constants/tetrominos.ts delete mode 100644 server/game/contstants.ts diff --git a/server/constants/index.ts b/server/constants/index.ts new file mode 100644 index 0000000..1fe4986 --- /dev/null +++ b/server/constants/index.ts @@ -0,0 +1,22 @@ +import { CLASSIC_TETROMINOS, CYBER_TETROMINOS, PENTOMINOES } from './tetrominos'; + +const STANDARD_FIRST_SPEED = 20; +const BSP_SCORES: Record = { 0: 0, 1: 40, 2: 100, 3: 300, 4: 1200, 5: 5000 }; +const TICK_INTERVAL_MS = 50; + +type GAME_MODES = 'classic' | 'narrow' | 'pentominoes' | 'cyber'; + +interface GAME_SETUP_ENTRY { + tetrominos: Record; + width: number; + height: number; +} + +const GAME_SETUP: Record = { + classic: { tetrominos: CLASSIC_TETROMINOS, width: 10, height: 20 }, + narrow: { tetrominos: CLASSIC_TETROMINOS, width: 6, height: 22 }, + pentominoes: { tetrominos: PENTOMINOES, width: 10, height: 20 }, + cyber: { tetrominos: CYBER_TETROMINOS, width: 6, height: 16 }, +}; + +export { BSP_SCORES, GAME_MODES, GAME_SETUP, STANDARD_FIRST_SPEED, TICK_INTERVAL_MS }; diff --git a/server/constants/tetrominos.ts b/server/constants/tetrominos.ts new file mode 100644 index 0000000..3b4c5be --- /dev/null +++ b/server/constants/tetrominos.ts @@ -0,0 +1,523 @@ +export const CYBER_TETROMINOS: Record = { + A: [ + [ + [0, 0, 0], + [1, 0, 1], + [0, 1, 0], + ], + [ + [0, 1, 0], + [1, 0, 0], + [0, 1, 0], + ], + [ + [0, 1, 0], + [1, 0, 1], + [0, 0, 0], + ], + [ + [0, 1, 0], + [0, 0, 1], + [0, 1, 0], + ], + ], + B: [ + [ + [0, 0, 0], + [1, 1, 0], + [0, 1, 0], + ], + [ + [0, 1, 0], + [1, 1, 0], + [0, 0, 0], + ], + [ + [0, 1, 0], + [0, 1, 1], + [0, 0, 0], + ], + [ + [0, 0, 0], + [0, 1, 1], + [0, 1, 0], + ], + ], + C: [ + [ + [0, 0, 0], + [1, 1, 0], + [0, 0, 1], + ], + [ + [0, 1, 0], + [0, 1, 0], + [1, 0, 0], + ], + [ + [1, 0, 0], + [0, 1, 1], + [0, 0, 0], + ], + [ + [0, 0, 1], + [0, 1, 0], + [0, 1, 0], + ], + ], + D: [ + [ + [0, 0, 1], + [1, 1, 0], + [0, 0, 0], + ], + [ + [0, 1, 0], + [0, 1, 0], + [0, 0, 1], + ], + [ + [0, 0, 0], + [0, 1, 1], + [1, 0, 0], + ], + [ + [1, 0, 0], + [0, 1, 0], + [0, 1, 0], + ], + ], + E: [ + [ + [0, 0, 0], + [1, 1, 1], + ], + [ + [0, 1], + [0, 1], + [0, 1], + ], + ], + F: [ + [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ], + [ + [0, 0, 1], + [0, 1, 0], + [1, 0, 0], + ], + ], +}; + +export const CLASSIC_TETROMINOS: Record = { + I: [ + [ + [0, 0, 0, 0], + [1, 1, 1, 1], + ], + [ + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + ], + [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [1, 1, 1, 1], + ], + [ + [0, 1], + [0, 1], + [0, 1], + [0, 1], + ], + ], + O: [ + [ + [1, 1], + [1, 1], + ], + ], + T: [ + [ + [0, 1, 0], + [1, 1, 1], + ], + [ + [0, 1, 0], + [0, 1, 1], + [0, 1, 0], + ], + [ + [0, 0, 0], + [1, 1, 1], + [0, 1, 0], + ], + [ + [0, 1], + [1, 1], + [0, 1], + ], + ], + S: [ + [ + [0, 1, 1], + [1, 1, 0], + ], + [ + [0, 1, 0], + [0, 1, 1], + [0, 0, 1], + ], + [ + [0, 0, 0], + [0, 1, 1], + [1, 1, 0], + ], + [ + [1, 0, 0], + [1, 1, 0], + [0, 1, 0], + ], + ], + Z: [ + [ + [1, 1, 0], + [0, 1, 1], + ], + [ + [0, 0, 1], + [0, 1, 1], + [0, 1, 0], + ], + [ + [0, 0, 0], + [1, 1, 0], + [0, 1, 1], + ], + [ + [0, 1, 0], + [1, 1, 0], + [1, 0, 0], + ], + ], + J: [ + [ + [1, 0, 0], + [1, 1, 1], + ], + [ + [0, 1, 1], + [0, 1, 0], + [0, 1, 0], + ], + [ + [0, 0, 0], + [1, 1, 1], + [0, 0, 1], + ], + [ + [0, 1], + [0, 1], + [1, 1], + ], + ], + L: [ + [ + [0, 0, 1], + [1, 1, 1], + ], + [ + [0, 1, 0], + [0, 1, 0], + [0, 1, 1], + ], + [ + [0, 0, 0], + [1, 1, 1], + [1, 0, 0], + ], + [ + [1, 1], + [0, 1], + [0, 1], + ], + ], +}; + +export const PENTOMINOES: Record = { + A: [ + [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [1, 1, 1, 1, 1], + ], + [ + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + ], + ], + B: [ + [ + [0, 0, 0, 0, 0], + [0, 0, 0, 1, 0], + [1, 1, 1, 1, 0], + ], + [ + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 1], + [0, 0, 0, 0], + ], + [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 1, 1, 1, 1], + [0, 1, 0, 0, 0], + ], + [ + [0, 0, 0], + [0, 1, 1], + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + ], + ], + C: [ + [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [1, 1, 1, 1, 0], + [0, 0, 0, 1, 0], + ], + [ + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + [0, 1, 1], + [0, 0, 0], + ], + [ + [0, 0, 0, 0, 0], + [0, 1, 0, 0, 0], + [0, 1, 1, 1, 1], + ], + [ + [0, 0, 0, 0], + [0, 0, 1, 1], + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 0], + ], + ], + D: [ + [ + [0, 0, 0, 0], + [0, 0, 1, 1], + [1, 1, 1, 0], + ], + [ + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 1], + [0, 0, 0, 1], + ], + [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 1, 1, 1], + [0, 1, 1, 0, 0], + ], + [ + [0, 0, 0], + [0, 1, 0], + [0, 1, 1], + [0, 0, 1], + [0, 0, 1], + ], + ], + E: [ + [ + [0, 0, 1], + [0, 0, 1], + [1, 1, 1], + ], + [ + [0, 0, 1, 0, 0], + [0, 0, 1, 0, 0], + [0, 0, 1, 1, 1], + ], + [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 1, 1, 1], + [0, 0, 1, 0, 0], + [0, 0, 1, 0, 0], + ], + [ + [0, 0, 0], + [0, 0, 0], + [1, 1, 1], + [0, 0, 1], + [0, 0, 1], + ], + ], + F: [ + [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 1, 1], + [0, 1, 1, 1], + ], + [ + [0, 0, 0], + [0, 1, 0], + [0, 1, 1], + [0, 1, 1], + ], + [ + [0, 0, 0, 0], + [0, 1, 1, 1], + [0, 1, 1, 0], + ], + [ + [0, 0, 0, 0], + [0, 0, 1, 1], + [0, 0, 1, 1], + [0, 0, 0, 1], + ], + ], + G: [ + [ + [0, 0, 0, 0], + [0, 0, 0, 1], + [0, 0, 1, 1], + [0, 1, 1, 0], + ], + [ + [0, 0, 0, 0], + [0, 1, 0, 0], + [0, 1, 1, 0], + [0, 0, 1, 1], + ], + [ + [0, 0, 0, 0], + [0, 0, 1, 1], + [0, 1, 1, 0], + [0, 1, 0, 0], + ], + [ + [0, 0, 0, 0], + [0, 1, 1, 0], + [0, 0, 1, 1], + [0, 0, 0, 1], + ], + ], + H: [ + [ + [0, 0, 0, 0], + [0, 0, 0, 1], + [0, 1, 1, 1], + [0, 1, 0, 0], + ], + [ + [0, 0, 0, 0], + [0, 1, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 1], + ], + ], + I: [ + [ + [0, 0, 0, 0], + [0, 0, 1, 0], + [0, 1, 1, 1], + [0, 0, 1, 0], + ], + ], + J: [ + [ + [0, 0, 0, 0], + [0, 0, 0, 1], + [0, 1, 1, 1], + [0, 0, 1, 0], + ], + [ + [0, 0, 0, 0], + [0, 0, 1, 0], + [0, 1, 1, 0], + [0, 0, 1, 1], + ], + [ + [0, 0, 0, 0], + [0, 0, 1, 0], + [0, 1, 1, 1], + [0, 1, 0, 0], + ], + [ + [0, 0, 0, 0], + [0, 1, 1, 0], + [0, 0, 1, 1], + [0, 0, 1, 0], + ], + ], + K: [ + [ + [0, 0, 0, 0], + [0, 1, 1, 1], + [0, 0, 1, 0], + [0, 0, 1, 0], + ], + [ + [0, 0, 0, 0], + [0, 0, 0, 1], + [0, 1, 1, 1], + [0, 0, 0, 1], + ], + [ + [0, 0, 0, 0], + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 1, 1, 1], + ], + [ + [0, 0, 0, 0], + [0, 1, 0, 0], + [0, 1, 1, 1], + [0, 1, 0, 0], + ], + ], + L: [ + [ + [0, 0, 0, 0], + [0, 1, 0, 1], + [0, 1, 1, 1], + ], + [ + [0, 0, 0, 0], + [0, 0, 1, 1], + [0, 0, 1, 0], + [0, 0, 1, 1], + ], + [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 1, 1, 1], + [0, 1, 0, 1], + ], + [ + [0, 0, 0], + [0, 1, 1], + [0, 0, 1], + [0, 1, 1], + ], + ], +}; diff --git a/server/core/GameManager.ts b/server/core/GameManager.ts index 94576ee..01190ee 100644 --- a/server/core/GameManager.ts +++ b/server/core/GameManager.ts @@ -1,13 +1,14 @@ import { randomUUID } from 'crypto'; import { Game } from '../game/Game'; import { Player } from './Player'; +import { GAME_MODES } from '../constants'; export class GameManager { games = new Map(); - createNewGame(host: Player): Game { + createNewGame(host: Player, mode: GAME_MODES = 'classic'): Game { const roomId = this.generateRoomCode(); - const game = new Game(roomId, host); + const game = new Game(roomId, host, mode); this.games.set(roomId, game); return game; } diff --git a/server/game/Game.ts b/server/game/Game.ts index 1f4aa65..e60ca89 100644 --- a/server/game/Game.ts +++ b/server/game/Game.ts @@ -1,3 +1,4 @@ +import { GAME_MODES } from '../constants'; import { Player } from '../core/Player'; import { GameEngine } from './GameEngine'; import { GameState, Intent } from './types'; @@ -8,12 +9,14 @@ export class Game { state: GameState; engine: GameEngine; hostId: string; + mode: GAME_MODES; - constructor(id: string, host: Player) { + constructor(id: string, host: Player, mode: GAME_MODES = 'classic') { this.id = id; this.hostId = host.id; this.players.set(host.id, host); - this.engine = new GameEngine(id); + this.mode = mode; + this.engine = new GameEngine(id, mode); this.state = this.engine.createInitialState([host.id]); // ensure player name is present in state if (this.state.playerStates[host.id]) { diff --git a/server/game/GameEngine.ts b/server/game/GameEngine.ts index c56c15d..6912756 100644 --- a/server/game/GameEngine.ts +++ b/server/game/GameEngine.ts @@ -1,5 +1,5 @@ +import { GAME_MODES, GAME_SETUP, BSP_SCORES, STANDARD_FIRST_SPEED } from '../constants'; import { logger } from '../utils/logger'; -import { BSP_SCORES, PLAYGROUND_HEIGHT, PLAYGROUND_WIDTH, STANDARD_FIRST_SPEED, TETROMINOS } from './contstants'; import { PieceGenerator } from './PieceGenerator'; import { GameState, Intent, Piece, PlayerState } from './types'; @@ -22,12 +22,12 @@ export class GameEngine { private width: number; private height: number; - constructor(seed = 'default') { + constructor(seed = 'default', mode: GAME_MODES = 'classic') { this.seed = seed; const h = hashStringToSeed(seed); - this.width = PLAYGROUND_WIDTH; - this.height = PLAYGROUND_HEIGHT; - this.pieceGenerator = new PieceGenerator(h, TETROMINOS); + this.width = GAME_SETUP[mode].width; + this.height = GAME_SETUP[mode].height; + this.pieceGenerator = new PieceGenerator(h, GAME_SETUP[mode].tetrominos); } private cloneState(obj: T): T { diff --git a/server/game/contstants.ts b/server/game/contstants.ts deleted file mode 100644 index ff7d90a..0000000 --- a/server/game/contstants.ts +++ /dev/null @@ -1,145 +0,0 @@ -const TETROMINOS: Record = { - I: [ - [ - [0, 0, 0, 0], - [1, 1, 1, 1], - ], - [ - [0, 0, 1], - [0, 0, 1], - [0, 0, 1], - [0, 0, 1], - ], - [ - [0, 0, 0, 0], - [0, 0, 0, 0], - [1, 1, 1, 1], - ], - [ - [0, 1], - [0, 1], - [0, 1], - [0, 1], - ], - ], - O: [ - [ - [1, 1], - [1, 1], - ], - ], - T: [ - [ - [0, 1, 0], - [1, 1, 1], - ], - [ - [0, 1, 0], - [0, 1, 1], - [0, 1, 0], - ], - [ - [0, 0, 0], - [1, 1, 1], - [0, 1, 0], - ], - [ - [0, 1], - [1, 1], - [0, 1], - ], - ], - S: [ - [ - [0, 1, 1], - [1, 1, 0], - ], - [ - [0, 1, 0], - [0, 1, 1], - [0, 0, 1], - ], - [ - [0, 0, 0], - [0, 1, 1], - [1, 1, 0], - ], - [ - [1, 0, 0], - [1, 1, 0], - [0, 1, 0], - ], - ], - Z: [ - [ - [1, 1, 0], - [0, 1, 1], - ], - [ - [0, 0, 1], - [0, 1, 1], - [0, 1, 0], - ], - [ - [0, 0, 0], - [1, 1, 0], - [0, 1, 1], - ], - [ - [0, 1, 0], - [1, 1, 0], - [1, 0, 0], - ], - ], - J: [ - [ - [1, 0, 0], - [1, 1, 1], - ], - [ - [0, 1, 1], - [0, 1, 0], - [0, 1, 0], - ], - [ - [0, 0, 0], - [1, 1, 1], - [0, 0, 1], - ], - [ - [0, 1], - [0, 1], - [1, 1], - ], - ], - L: [ - [ - [0, 0, 1], - [1, 1, 1], - ], - [ - [0, 1, 0], - [0, 1, 0], - [0, 1, 1], - ], - [ - [0, 0, 0], - [1, 1, 1], - [1, 0, 0], - ], - [ - [1, 1], - [0, 1], - [0, 1], - ], - ], -}; - -const PLAYGROUND_WIDTH = 10; -const PLAYGROUND_HEIGHT = 20; - -const STANDARD_FIRST_SPEED = 20; -const BSP_SCORES: Record = { 0: 0, 1: 40, 2: 100, 3: 300, 4: 1200 }; -const TICK_INTERVAL_MS = 50; - -export { BSP_SCORES, PLAYGROUND_HEIGHT, PLAYGROUND_WIDTH, STANDARD_FIRST_SPEED, TETROMINOS, TICK_INTERVAL_MS }; diff --git a/server/net/socketHandlers.ts b/server/net/socketHandlers.ts index 6157b3a..f282911 100644 --- a/server/net/socketHandlers.ts +++ b/server/net/socketHandlers.ts @@ -1,8 +1,8 @@ import { Server, Socket } from 'socket.io'; +import { GAME_MODES, TICK_INTERVAL_MS } from '../constants'; import { GameManager } from '../core/GameManager'; import GameService from '../core/GameService'; import { Player } from '../core/Player'; -import { TICK_INTERVAL_MS } from '../game/contstants'; import { Game } from '../game/Game'; import { GameEngine } from '../game/GameEngine'; import type { Intent, PlayerState } from '../game/types'; @@ -55,11 +55,12 @@ export function registerSocketHandlers( players: playersObj, winnerId, hostId: game.hostId, + mode: game.mode, }); } } - socket.on('create', async () => { + socket.on('create', async (payload: { mode: GAME_MODES } = { mode: 'classic' }) => { const user = socket.data.user; if (!user) { socket.emit('create_failed', { code: 'NOT_AUTHENTICATED', message: 'Authentication required' }); @@ -69,7 +70,7 @@ export function registerSocketHandlers( const playerId = String(user.userId); const player = new Player(playerId, user.username, socket.id); - const game = manager.createNewGame(player); + const game = manager.createNewGame(player, payload.mode); logger.info(`Game created: room=${game.id} host=${player.id}`); @@ -284,7 +285,7 @@ export function registerSocketHandlers( } // reset engine and state while preserving players and host logger.info(`server: restarting game ${roomId} by host ${playerId}`); - game.engine = new GameEngine(game.id); + game.engine = new GameEngine(game.id, game.mode); const playerIds = game.getPlayers().map((p) => p.id); game.state = game.engine.createInitialState(playerIds); game.state.status = 'waiting'; diff --git a/server/tests/gameEngine.test.ts b/server/tests/gameEngine.test.ts index 081eca7..0cdbbab 100644 --- a/server/tests/gameEngine.test.ts +++ b/server/tests/gameEngine.test.ts @@ -1,13 +1,15 @@ /* eslint-disable @typescript-eslint/unbound-method */ import { expect } from 'chai'; +import { BSP_SCORES, GAME_SETUP } from '../constants'; import { GameEngine } from '../game/GameEngine'; -import { BSP_SCORES, PLAYGROUND_HEIGHT, PLAYGROUND_WIDTH } from '../game/contstants'; import { Piece } from '../game/types'; describe('GameEngine additional coverage', () => { it('collides does not treat negative y as collision when inside x bounds', () => { const e = new GameEngine('c1'); - const board = Array.from({ length: PLAYGROUND_HEIGHT }, () => Array.from({ length: PLAYGROUND_WIDTH }, () => 0)); + const board = Array.from({ length: GAME_SETUP.classic.height }, () => + Array.from({ length: GAME_SETUP.classic.width }, () => 0) + ); const piece = { type: 'O', x: 4, y: -2, rotation: 0 } as Piece; const coll = e.collides(board, piece); expect(coll).to.equal(false); @@ -18,7 +20,9 @@ describe('GameEngine additional coverage', () => { const orig = e.collides; try { e.collides = () => false; - const board = Array.from({ length: PLAYGROUND_HEIGHT }, () => Array.from({ length: PLAYGROUND_WIDTH }, () => 0)); + const board = Array.from({ length: GAME_SETUP.classic.height }, () => + Array.from({ length: GAME_SETUP.classic.width }, () => 0) + ); const piece = { type: 'O', x: 0, y: 0, rotation: 0 } as Piece; const out = e.renderBoardWithGhost(board, piece); @@ -41,22 +45,22 @@ describe('GameEngine additional coverage', () => { it('clearLines returns correct cleared count and keeps board width', () => { const e = new GameEngine('c2'); - const full = Array.from({ length: PLAYGROUND_WIDTH }, () => 1); - const board = Array.from({ length: PLAYGROUND_HEIGHT - 1 }, () => - Array.from({ length: PLAYGROUND_WIDTH }, () => 0) + const full = Array.from({ length: GAME_SETUP.classic.width }, () => 1); + const board = Array.from({ length: GAME_SETUP.classic.height - 1 }, () => + Array.from({ length: GAME_SETUP.classic.width }, () => 0) ); board.push(full.slice()); const res = e.clearLines(board); expect(res.cleared).to.equal(1); - expect(res.board.length).to.equal(PLAYGROUND_HEIGHT); + expect(res.board.length).to.equal(GAME_SETUP.classic.height); }); it('computeLinesClearedReward gives drop bonus when isHard true', () => { const e = new GameEngine('c3'); const s = e.createInitialState(['p1', 'p2']); - const full = Array.from({ length: PLAYGROUND_WIDTH }, () => 1); - s.playerStates.p1.board = Array.from({ length: PLAYGROUND_HEIGHT - 2 }, () => - Array.from({ length: PLAYGROUND_WIDTH }, () => 0) + const full = Array.from({ length: GAME_SETUP.classic.width }, () => 1); + s.playerStates.p1.board = Array.from({ length: GAME_SETUP.classic.height - 2 }, () => + Array.from({ length: GAME_SETUP.classic.width }, () => 0) ); s.playerStates.p1.board.push(full.slice()); s.playerStates.p1.board.push(full.slice()); @@ -77,7 +81,9 @@ describe('GameEngine (cleaned focused tests)', () => { it('renderBoardWithPiece places cells for active piece', () => { const e = new GameEngine('seed'); - const board = Array.from({ length: PLAYGROUND_HEIGHT }, () => Array.from({ length: PLAYGROUND_WIDTH }, () => 0)); + const board = Array.from({ length: GAME_SETUP.classic.height }, () => + Array.from({ length: GAME_SETUP.classic.width }, () => 0) + ); const piece = { type: 'O', x: 4, y: 0, rotation: 0 } as Piece; const out = e.renderBoardWithPiece(board, piece); expect(out[0][4]).to.be.greaterThan(0); @@ -86,7 +92,9 @@ describe('GameEngine (cleaned focused tests)', () => { it('renderBoardWithGhost shows ghost and active piece color', () => { const e = new GameEngine('seed2'); - const board = Array.from({ length: PLAYGROUND_HEIGHT }, () => Array.from({ length: PLAYGROUND_WIDTH }, () => 0)); + const board = Array.from({ length: GAME_SETUP.classic.height }, () => + Array.from({ length: GAME_SETUP.classic.width }, () => 0) + ); const piece = { type: 'O', x: 4, y: 0, rotation: 0 } as Piece; const out = e.renderBoardWithGhost(board, piece); const hasGhost = out.some((row) => row.includes(-2)); @@ -97,7 +105,9 @@ describe('GameEngine (cleaned focused tests)', () => { it('collides allows negative y (spawn partial) without treating as collision', () => { const e = new GameEngine('c1'); - const board = Array.from({ length: PLAYGROUND_HEIGHT }, () => Array.from({ length: PLAYGROUND_WIDTH }, () => 0)); + const board = Array.from({ length: GAME_SETUP.classic.height }, () => + Array.from({ length: GAME_SETUP.classic.width }, () => 0) + ); const piece = { type: 'O', x: 4, y: -2, rotation: 0 } as Piece; const coll = e.collides(board, piece); expect(coll).to.equal(false); @@ -160,22 +170,22 @@ describe('GameEngine (cleaned focused tests)', () => { it('clearLines returns cleared count and preserves board height', () => { const e = new GameEngine('c2'); - const full = Array.from({ length: PLAYGROUND_WIDTH }, () => 1); - const board = Array.from({ length: PLAYGROUND_HEIGHT - 1 }, () => - Array.from({ length: PLAYGROUND_WIDTH }, () => 0) + const full = Array.from({ length: GAME_SETUP.classic.width }, () => 1); + const board = Array.from({ length: GAME_SETUP.classic.height - 1 }, () => + Array.from({ length: GAME_SETUP.classic.width }, () => 0) ); board.push(full.slice()); const res = e.clearLines(board); expect(res.cleared).to.equal(1); - expect(res.board.length).to.equal(PLAYGROUND_HEIGHT); + expect(res.board.length).to.equal(GAME_SETUP.classic.height); }); it('computeLinesClearedReward gives drop bonus when isHard true and propagates to alive players', () => { const e = new GameEngine('c3'); const s = e.createInitialState(['p1', 'p2']); - const full = Array.from({ length: PLAYGROUND_WIDTH }, () => 1); - s.playerStates.p1.board = Array.from({ length: PLAYGROUND_HEIGHT - 2 }, () => - Array.from({ length: PLAYGROUND_WIDTH }, () => 0) + const full = Array.from({ length: GAME_SETUP.classic.width }, () => 1); + s.playerStates.p1.board = Array.from({ length: GAME_SETUP.classic.height - 2 }, () => + Array.from({ length: GAME_SETUP.classic.width }, () => 0) ); s.playerStates.p1.board.push(full.slice()); s.playerStates.p1.board.push(full.slice()); @@ -188,7 +198,9 @@ describe('GameEngine (cleaned focused tests)', () => { describe('GameEngine focused extras', () => { it('collides does not treat negative y as collision when inside x bounds', () => { const e = new GameEngine('c1'); - const board = Array.from({ length: PLAYGROUND_HEIGHT }, () => Array.from({ length: PLAYGROUND_WIDTH }, () => 0)); + const board = Array.from({ length: GAME_SETUP.classic.height }, () => + Array.from({ length: GAME_SETUP.classic.width }, () => 0) + ); const piece = { type: 'O', x: 4, y: -2, rotation: 0 } as Piece; const coll = e.collides(board, piece); expect(coll).to.equal(false); @@ -199,7 +211,9 @@ describe('GameEngine focused extras', () => { const orig = e.collides.bind(e); try { e.collides = () => false; - const board = Array.from({ length: PLAYGROUND_HEIGHT }, () => Array.from({ length: PLAYGROUND_WIDTH }, () => 0)); + const board = Array.from({ length: GAME_SETUP.classic.height }, () => + Array.from({ length: GAME_SETUP.classic.width }, () => 0) + ); const piece = { type: 'O', x: 0, y: 0, rotation: 0 } as Piece; const out = e.renderBoardWithGhost(board, piece); const hasGhost = out.some((row) => row.includes(-2)); @@ -221,22 +235,22 @@ describe('GameEngine focused extras', () => { it('clearLines returns correct cleared count and keeps board width', () => { const e = new GameEngine('c2'); - const full = Array.from({ length: PLAYGROUND_WIDTH }, () => 1); - const board = Array.from({ length: PLAYGROUND_HEIGHT - 1 }, () => - Array.from({ length: PLAYGROUND_WIDTH }, () => 0) + const full = Array.from({ length: GAME_SETUP.classic.width }, () => 1); + const board = Array.from({ length: GAME_SETUP.classic.height - 1 }, () => + Array.from({ length: GAME_SETUP.classic.width }, () => 0) ); board.push(full.slice()); const res = e.clearLines(board); expect(res.cleared).to.equal(1); - expect(res.board.length).to.equal(PLAYGROUND_HEIGHT); + expect(res.board.length).to.equal(GAME_SETUP.classic.height); }); it('computeLinesClearedReward gives drop bonus when isHard true', () => { const e = new GameEngine('c3'); const s = e.createInitialState(['p1', 'p2']); - const full = Array.from({ length: PLAYGROUND_WIDTH }, () => 1); - s.playerStates.p1.board = Array.from({ length: PLAYGROUND_HEIGHT - 2 }, () => - Array.from({ length: PLAYGROUND_WIDTH }, () => 0) + const full = Array.from({ length: GAME_SETUP.classic.width }, () => 1); + s.playerStates.p1.board = Array.from({ length: GAME_SETUP.classic.height - 2 }, () => + Array.from({ length: GAME_SETUP.classic.width }, () => 0) ); s.playerStates.p1.board.push(full.slice()); s.playerStates.p1.board.push(full.slice()); @@ -267,7 +281,9 @@ describe('GameEngine', () => { it('collides returns true when board cells block piece', () => { const e = new GameEngine('block-collide'); - const b = Array.from({ length: PLAYGROUND_HEIGHT }, () => Array.from({ length: PLAYGROUND_WIDTH }, () => 0)); + const b = Array.from({ length: GAME_SETUP.classic.height }, () => + Array.from({ length: GAME_SETUP.classic.width }, () => 0) + ); // place a block under spawn b[1][4] = 1; const c = e.collides(b, { type: 'O', x: 4, y: 0, rotation: 0 } as Piece); @@ -278,9 +294,9 @@ describe('GameEngine', () => { const e = new GameEngine('garbage-target'); const s = e.createInitialState(['a', 'b', 'c']); // make 'a' clear two lines - const full = Array.from({ length: PLAYGROUND_WIDTH }, () => 1); - s.playerStates.a.board = Array.from({ length: PLAYGROUND_HEIGHT - 2 }, () => - Array.from({ length: PLAYGROUND_WIDTH }, () => 0) + const full = Array.from({ length: GAME_SETUP.classic.width }, () => 1); + s.playerStates.a.board = Array.from({ length: GAME_SETUP.classic.height - 2 }, () => + Array.from({ length: GAME_SETUP.classic.width }, () => 0) ); s.playerStates.a.board.push(full.slice()); s.playerStates.a.board.push(full.slice()); @@ -309,9 +325,9 @@ describe('GameEngine', () => { it('computeLinesClearedReward handles isHard dropBonus and multiple other players dead', () => { const e = new GameEngine('cb'); const s = e.createInitialState(['p1', 'p2', 'p3']); - const full = Array.from({ length: PLAYGROUND_WIDTH }, () => 1); - s.playerStates.p1.board = Array.from({ length: PLAYGROUND_HEIGHT - 4 }, () => - Array.from({ length: PLAYGROUND_WIDTH }, () => 0) + const full = Array.from({ length: GAME_SETUP.classic.width }, () => 1); + s.playerStates.p1.board = Array.from({ length: GAME_SETUP.classic.height - 4 }, () => + Array.from({ length: GAME_SETUP.classic.width }, () => 0) ); for (let i = 0; i < 4; i++) { s.playerStates.p1.board.push(full.slice()); diff --git a/server/tsconfig.json b/server/tsconfig.json index ab7edbe..9389223 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -24,6 +24,7 @@ "game/**/*.ts", "net/**/*.ts", "types/**/*.ts", + "constants/**/*.ts", "tests/**/*.test.ts" ], "exclude": ["node_modules", "dist", "**/*.spec.ts"] diff --git a/server/types/socketEvents.ts b/server/types/socketEvents.ts index 1eef950..bfb0824 100644 --- a/server/types/socketEvents.ts +++ b/server/types/socketEvents.ts @@ -1,8 +1,9 @@ +import { GAME_MODES } from '../constants'; import { GameState, Intent, PlayerState } from '../game/types'; import { JwtPayload } from '../utils/jwt'; export interface ClientToServerEvents { - create: () => Promise; + create: (payload: { mode: GAME_MODES }) => Promise; join: (payload: { roomId: string }) => Promise; start: (payload: { roomId: string }) => void; restart: (payload: { roomId: string }) => void; @@ -18,6 +19,7 @@ export interface ServerToClientEvents { players: Record; winnerId: string | null; hostId: string; + mode: GAME_MODES; }) => void; game_over: (payload: { message: string }) => void; join_failed: (payload: { code: string; message: string }) => void; From 4b9dd80743d9a958be18cfdc5ee54fc0110234cd Mon Sep 17 00:00:00 2001 From: LuckyIntegral Date: Tue, 3 Mar 2026 22:41:52 +0000 Subject: [PATCH 2/5] refactor: move to type definition from typeof --- server/constants/index.ts | 6 ++++-- server/core/GameManager.ts | 4 ++-- server/game/Game.ts | 6 +++--- server/game/GameEngine.ts | 4 ++-- server/net/socketHandlers.ts | 5 +++-- server/types/socketEvents.ts | 6 +++--- 6 files changed, 17 insertions(+), 14 deletions(-) diff --git a/server/constants/index.ts b/server/constants/index.ts index 1fe4986..927d845 100644 --- a/server/constants/index.ts +++ b/server/constants/index.ts @@ -4,7 +4,8 @@ const STANDARD_FIRST_SPEED = 20; const BSP_SCORES: Record = { 0: 0, 1: 40, 2: 100, 3: 300, 4: 1200, 5: 5000 }; const TICK_INTERVAL_MS = 50; -type GAME_MODES = 'classic' | 'narrow' | 'pentominoes' | 'cyber'; +const GAME_MODES = ['classic', 'narrow', 'pentominoes', 'cyber'] as const; +type GameMode = (typeof GAME_MODES)[number]; interface GAME_SETUP_ENTRY { tetrominos: Record; @@ -12,7 +13,7 @@ interface GAME_SETUP_ENTRY { height: number; } -const GAME_SETUP: Record = { +const GAME_SETUP: Record = { classic: { tetrominos: CLASSIC_TETROMINOS, width: 10, height: 20 }, narrow: { tetrominos: CLASSIC_TETROMINOS, width: 6, height: 22 }, pentominoes: { tetrominos: PENTOMINOES, width: 10, height: 20 }, @@ -20,3 +21,4 @@ const GAME_SETUP: Record = { }; export { BSP_SCORES, GAME_MODES, GAME_SETUP, STANDARD_FIRST_SPEED, TICK_INTERVAL_MS }; +export type { GameMode }; diff --git a/server/core/GameManager.ts b/server/core/GameManager.ts index 01190ee..d1353d4 100644 --- a/server/core/GameManager.ts +++ b/server/core/GameManager.ts @@ -1,12 +1,12 @@ import { randomUUID } from 'crypto'; +import { GameMode } from '../constants'; import { Game } from '../game/Game'; import { Player } from './Player'; -import { GAME_MODES } from '../constants'; export class GameManager { games = new Map(); - createNewGame(host: Player, mode: GAME_MODES = 'classic'): Game { + createNewGame(host: Player, mode: GameMode = 'classic'): Game { const roomId = this.generateRoomCode(); const game = new Game(roomId, host, mode); this.games.set(roomId, game); diff --git a/server/game/Game.ts b/server/game/Game.ts index e60ca89..2a4252e 100644 --- a/server/game/Game.ts +++ b/server/game/Game.ts @@ -1,4 +1,4 @@ -import { GAME_MODES } from '../constants'; +import { GameMode } from '../constants'; import { Player } from '../core/Player'; import { GameEngine } from './GameEngine'; import { GameState, Intent } from './types'; @@ -9,9 +9,9 @@ export class Game { state: GameState; engine: GameEngine; hostId: string; - mode: GAME_MODES; + mode: GameMode; - constructor(id: string, host: Player, mode: GAME_MODES = 'classic') { + constructor(id: string, host: Player, mode: GameMode = 'classic') { this.id = id; this.hostId = host.id; this.players.set(host.id, host); diff --git a/server/game/GameEngine.ts b/server/game/GameEngine.ts index 6912756..971eb37 100644 --- a/server/game/GameEngine.ts +++ b/server/game/GameEngine.ts @@ -1,4 +1,4 @@ -import { GAME_MODES, GAME_SETUP, BSP_SCORES, STANDARD_FIRST_SPEED } from '../constants'; +import { BSP_SCORES, GAME_SETUP, GameMode, STANDARD_FIRST_SPEED } from '../constants'; import { logger } from '../utils/logger'; import { PieceGenerator } from './PieceGenerator'; import { GameState, Intent, Piece, PlayerState } from './types'; @@ -22,7 +22,7 @@ export class GameEngine { private width: number; private height: number; - constructor(seed = 'default', mode: GAME_MODES = 'classic') { + constructor(seed = 'default', mode: GameMode = 'classic') { this.seed = seed; const h = hashStringToSeed(seed); this.width = GAME_SETUP[mode].width; diff --git a/server/net/socketHandlers.ts b/server/net/socketHandlers.ts index f282911..c90b820 100644 --- a/server/net/socketHandlers.ts +++ b/server/net/socketHandlers.ts @@ -1,5 +1,6 @@ import { Server, Socket } from 'socket.io'; -import { GAME_MODES, TICK_INTERVAL_MS } from '../constants'; +import type { GameMode } from '../constants'; +import { TICK_INTERVAL_MS } from '../constants'; import { GameManager } from '../core/GameManager'; import GameService from '../core/GameService'; import { Player } from '../core/Player'; @@ -60,7 +61,7 @@ export function registerSocketHandlers( } } - socket.on('create', async (payload: { mode: GAME_MODES } = { mode: 'classic' }) => { + socket.on('create', async (payload: { mode: GameMode } = { mode: 'classic' }) => { const user = socket.data.user; if (!user) { socket.emit('create_failed', { code: 'NOT_AUTHENTICATED', message: 'Authentication required' }); diff --git a/server/types/socketEvents.ts b/server/types/socketEvents.ts index bfb0824..03b8942 100644 --- a/server/types/socketEvents.ts +++ b/server/types/socketEvents.ts @@ -1,9 +1,9 @@ -import { GAME_MODES } from '../constants'; +import { GameMode } from '../constants'; import { GameState, Intent, PlayerState } from '../game/types'; import { JwtPayload } from '../utils/jwt'; export interface ClientToServerEvents { - create: (payload: { mode: GAME_MODES }) => Promise; + create: (payload: { mode: GameMode }) => Promise; join: (payload: { roomId: string }) => Promise; start: (payload: { roomId: string }) => void; restart: (payload: { roomId: string }) => void; @@ -19,7 +19,7 @@ export interface ServerToClientEvents { players: Record; winnerId: string | null; hostId: string; - mode: GAME_MODES; + mode: GameMode; }) => void; game_over: (payload: { message: string }) => void; join_failed: (payload: { code: string; message: string }) => void; From 04edeea01a3e6cee577a559ba06a3bdddcc37634 Mon Sep 17 00:00:00 2001 From: LuckyIntegral Date: Tue, 3 Mar 2026 23:04:04 +0000 Subject: [PATCH 3/5] chore: add frame for frontend --- client/app/constants/index.ts | 24 ++ client/app/constants/tetrominos.ts | 523 +++++++++++++++++++++++++++++ client/app/routes/game.tsx | 110 +++--- client/app/types/socket.ts | 2 +- server/constants/index.ts | 4 +- 5 files changed, 619 insertions(+), 44 deletions(-) create mode 100644 client/app/constants/index.ts create mode 100644 client/app/constants/tetrominos.ts diff --git a/client/app/constants/index.ts b/client/app/constants/index.ts new file mode 100644 index 0000000..de4d1a7 --- /dev/null +++ b/client/app/constants/index.ts @@ -0,0 +1,24 @@ +import { CLASSIC_TETROMINOS, CYBER_TETROMINOS, PENTOMINOES } from './tetrominos'; + +const STANDARD_FIRST_SPEED = 20; +const BSP_SCORES: Record = { 0: 0, 1: 40, 2: 100, 3: 300, 4: 1200, 5: 5000 }; +const TICK_INTERVAL_MS = 50; + +const GAME_MODES = ['classic', 'narrow', 'pentominoes', 'cyber'] as const; +type GameMode = (typeof GAME_MODES)[number]; + +interface GAME_SETUP_ENTRY { + tetrominos: Record; + width: number; + height: number; +} + +const GAME_SETUP: Record = { + classic: { tetrominos: CLASSIC_TETROMINOS, width: 10, height: 20 }, + narrow: { tetrominos: CLASSIC_TETROMINOS, width: 6, height: 22 }, + pentominoes: { tetrominos: PENTOMINOES, width: 12, height: 22 }, + cyber: { tetrominos: CYBER_TETROMINOS, width: 8, height: 18 }, +}; + +export { BSP_SCORES, GAME_MODES, GAME_SETUP, STANDARD_FIRST_SPEED, TICK_INTERVAL_MS }; +export type { GameMode }; diff --git a/client/app/constants/tetrominos.ts b/client/app/constants/tetrominos.ts new file mode 100644 index 0000000..3b4c5be --- /dev/null +++ b/client/app/constants/tetrominos.ts @@ -0,0 +1,523 @@ +export const CYBER_TETROMINOS: Record = { + A: [ + [ + [0, 0, 0], + [1, 0, 1], + [0, 1, 0], + ], + [ + [0, 1, 0], + [1, 0, 0], + [0, 1, 0], + ], + [ + [0, 1, 0], + [1, 0, 1], + [0, 0, 0], + ], + [ + [0, 1, 0], + [0, 0, 1], + [0, 1, 0], + ], + ], + B: [ + [ + [0, 0, 0], + [1, 1, 0], + [0, 1, 0], + ], + [ + [0, 1, 0], + [1, 1, 0], + [0, 0, 0], + ], + [ + [0, 1, 0], + [0, 1, 1], + [0, 0, 0], + ], + [ + [0, 0, 0], + [0, 1, 1], + [0, 1, 0], + ], + ], + C: [ + [ + [0, 0, 0], + [1, 1, 0], + [0, 0, 1], + ], + [ + [0, 1, 0], + [0, 1, 0], + [1, 0, 0], + ], + [ + [1, 0, 0], + [0, 1, 1], + [0, 0, 0], + ], + [ + [0, 0, 1], + [0, 1, 0], + [0, 1, 0], + ], + ], + D: [ + [ + [0, 0, 1], + [1, 1, 0], + [0, 0, 0], + ], + [ + [0, 1, 0], + [0, 1, 0], + [0, 0, 1], + ], + [ + [0, 0, 0], + [0, 1, 1], + [1, 0, 0], + ], + [ + [1, 0, 0], + [0, 1, 0], + [0, 1, 0], + ], + ], + E: [ + [ + [0, 0, 0], + [1, 1, 1], + ], + [ + [0, 1], + [0, 1], + [0, 1], + ], + ], + F: [ + [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ], + [ + [0, 0, 1], + [0, 1, 0], + [1, 0, 0], + ], + ], +}; + +export const CLASSIC_TETROMINOS: Record = { + I: [ + [ + [0, 0, 0, 0], + [1, 1, 1, 1], + ], + [ + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + ], + [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [1, 1, 1, 1], + ], + [ + [0, 1], + [0, 1], + [0, 1], + [0, 1], + ], + ], + O: [ + [ + [1, 1], + [1, 1], + ], + ], + T: [ + [ + [0, 1, 0], + [1, 1, 1], + ], + [ + [0, 1, 0], + [0, 1, 1], + [0, 1, 0], + ], + [ + [0, 0, 0], + [1, 1, 1], + [0, 1, 0], + ], + [ + [0, 1], + [1, 1], + [0, 1], + ], + ], + S: [ + [ + [0, 1, 1], + [1, 1, 0], + ], + [ + [0, 1, 0], + [0, 1, 1], + [0, 0, 1], + ], + [ + [0, 0, 0], + [0, 1, 1], + [1, 1, 0], + ], + [ + [1, 0, 0], + [1, 1, 0], + [0, 1, 0], + ], + ], + Z: [ + [ + [1, 1, 0], + [0, 1, 1], + ], + [ + [0, 0, 1], + [0, 1, 1], + [0, 1, 0], + ], + [ + [0, 0, 0], + [1, 1, 0], + [0, 1, 1], + ], + [ + [0, 1, 0], + [1, 1, 0], + [1, 0, 0], + ], + ], + J: [ + [ + [1, 0, 0], + [1, 1, 1], + ], + [ + [0, 1, 1], + [0, 1, 0], + [0, 1, 0], + ], + [ + [0, 0, 0], + [1, 1, 1], + [0, 0, 1], + ], + [ + [0, 1], + [0, 1], + [1, 1], + ], + ], + L: [ + [ + [0, 0, 1], + [1, 1, 1], + ], + [ + [0, 1, 0], + [0, 1, 0], + [0, 1, 1], + ], + [ + [0, 0, 0], + [1, 1, 1], + [1, 0, 0], + ], + [ + [1, 1], + [0, 1], + [0, 1], + ], + ], +}; + +export const PENTOMINOES: Record = { + A: [ + [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [1, 1, 1, 1, 1], + ], + [ + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + ], + ], + B: [ + [ + [0, 0, 0, 0, 0], + [0, 0, 0, 1, 0], + [1, 1, 1, 1, 0], + ], + [ + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 1], + [0, 0, 0, 0], + ], + [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 1, 1, 1, 1], + [0, 1, 0, 0, 0], + ], + [ + [0, 0, 0], + [0, 1, 1], + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + ], + ], + C: [ + [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [1, 1, 1, 1, 0], + [0, 0, 0, 1, 0], + ], + [ + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + [0, 1, 1], + [0, 0, 0], + ], + [ + [0, 0, 0, 0, 0], + [0, 1, 0, 0, 0], + [0, 1, 1, 1, 1], + ], + [ + [0, 0, 0, 0], + [0, 0, 1, 1], + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 0], + ], + ], + D: [ + [ + [0, 0, 0, 0], + [0, 0, 1, 1], + [1, 1, 1, 0], + ], + [ + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 1], + [0, 0, 0, 1], + ], + [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 1, 1, 1], + [0, 1, 1, 0, 0], + ], + [ + [0, 0, 0], + [0, 1, 0], + [0, 1, 1], + [0, 0, 1], + [0, 0, 1], + ], + ], + E: [ + [ + [0, 0, 1], + [0, 0, 1], + [1, 1, 1], + ], + [ + [0, 0, 1, 0, 0], + [0, 0, 1, 0, 0], + [0, 0, 1, 1, 1], + ], + [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 1, 1, 1], + [0, 0, 1, 0, 0], + [0, 0, 1, 0, 0], + ], + [ + [0, 0, 0], + [0, 0, 0], + [1, 1, 1], + [0, 0, 1], + [0, 0, 1], + ], + ], + F: [ + [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 1, 1], + [0, 1, 1, 1], + ], + [ + [0, 0, 0], + [0, 1, 0], + [0, 1, 1], + [0, 1, 1], + ], + [ + [0, 0, 0, 0], + [0, 1, 1, 1], + [0, 1, 1, 0], + ], + [ + [0, 0, 0, 0], + [0, 0, 1, 1], + [0, 0, 1, 1], + [0, 0, 0, 1], + ], + ], + G: [ + [ + [0, 0, 0, 0], + [0, 0, 0, 1], + [0, 0, 1, 1], + [0, 1, 1, 0], + ], + [ + [0, 0, 0, 0], + [0, 1, 0, 0], + [0, 1, 1, 0], + [0, 0, 1, 1], + ], + [ + [0, 0, 0, 0], + [0, 0, 1, 1], + [0, 1, 1, 0], + [0, 1, 0, 0], + ], + [ + [0, 0, 0, 0], + [0, 1, 1, 0], + [0, 0, 1, 1], + [0, 0, 0, 1], + ], + ], + H: [ + [ + [0, 0, 0, 0], + [0, 0, 0, 1], + [0, 1, 1, 1], + [0, 1, 0, 0], + ], + [ + [0, 0, 0, 0], + [0, 1, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 1], + ], + ], + I: [ + [ + [0, 0, 0, 0], + [0, 0, 1, 0], + [0, 1, 1, 1], + [0, 0, 1, 0], + ], + ], + J: [ + [ + [0, 0, 0, 0], + [0, 0, 0, 1], + [0, 1, 1, 1], + [0, 0, 1, 0], + ], + [ + [0, 0, 0, 0], + [0, 0, 1, 0], + [0, 1, 1, 0], + [0, 0, 1, 1], + ], + [ + [0, 0, 0, 0], + [0, 0, 1, 0], + [0, 1, 1, 1], + [0, 1, 0, 0], + ], + [ + [0, 0, 0, 0], + [0, 1, 1, 0], + [0, 0, 1, 1], + [0, 0, 1, 0], + ], + ], + K: [ + [ + [0, 0, 0, 0], + [0, 1, 1, 1], + [0, 0, 1, 0], + [0, 0, 1, 0], + ], + [ + [0, 0, 0, 0], + [0, 0, 0, 1], + [0, 1, 1, 1], + [0, 0, 0, 1], + ], + [ + [0, 0, 0, 0], + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 1, 1, 1], + ], + [ + [0, 0, 0, 0], + [0, 1, 0, 0], + [0, 1, 1, 1], + [0, 1, 0, 0], + ], + ], + L: [ + [ + [0, 0, 0, 0], + [0, 1, 0, 1], + [0, 1, 1, 1], + ], + [ + [0, 0, 0, 0], + [0, 0, 1, 1], + [0, 0, 1, 0], + [0, 0, 1, 1], + ], + [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 1, 1, 1], + [0, 1, 0, 1], + ], + [ + [0, 0, 0], + [0, 1, 1], + [0, 0, 1], + [0, 1, 1], + ], + ], +}; diff --git a/client/app/routes/game.tsx b/client/app/routes/game.tsx index c4b01fe..66c4091 100644 --- a/client/app/routes/game.tsx +++ b/client/app/routes/game.tsx @@ -1,14 +1,13 @@ import { useEffect, useMemo, useRef, useState, type JSX } from 'react'; import { Link } from 'react-router'; import { io } from 'socket.io-client'; -import type { Route } from './+types/game'; import { ProtectedRoute } from '../components/auth/ProtectedRoute'; import { LoadingOverlay } from '../components/LoadingOverlay'; +import { GAME_MODES, GAME_SETUP, type GameMode } from '../constants'; import { authService } from '../services/auth'; import type { GameState, PlayerState, Socket } from '../types/socket'; +import type { Route } from './+types/game'; -const WIDTH = 10; -const HEIGHT = 20; const CELL = 28; export function meta({}: Route.MetaArgs) { @@ -21,6 +20,7 @@ export function meta({}: Route.MetaArgs) { function GameComponent() { const [room, setRoom] = useState(''); const [playerId, setPlayerId] = useState(null); + const [selectedMode, setSelectedMode] = useState('classic'); const [state, setState] = useState(null); const [hostId, setHostId] = useState(null); const [error, setError] = useState(null); @@ -28,6 +28,12 @@ function GameComponent() { const [actionLoading, setActionLoading] = useState(false); const socketRef = useRef(null); + const modes = useMemo(() => Array.from(GAME_MODES), []); + + const setup = GAME_SETUP[selectedMode]; + const WIDTH = setup.width; + const HEIGHT = setup.height; + const computeSpectrumFromBoard = (board: number[][]): number[] => { const cols = Array.from({ length: WIDTH }, () => 0); for (let x = 0; x < WIDTH; x++) { @@ -211,8 +217,8 @@ function GameComponent() { if (!s) return; if (!s.connected) s.connect(); setActionLoading(true); - // server uses authenticated user for creation - s.emit('create'); + // server uses authenticated user for creation; include selected game mode + s.emit('create', { mode: selectedMode }); setError(null); }; @@ -366,34 +372,11 @@ function GameComponent() { }, [state, playerId]); // Next-piece shapes (rotation 0) for preview; pad to 4x4 for consistent rendering - const PIECE_SHAPES: Record = { - I: [[1, 1, 1, 1]], - O: [ - [1, 1], - [1, 1], - ], - T: [ - [0, 1, 0], - [1, 1, 1], - ], - S: [ - [0, 1, 1], - [1, 1, 0], - ], - Z: [ - [1, 1, 0], - [0, 1, 1], - ], - J: [ - [1, 0, 0], - [1, 1, 1], - ], - L: [ - [0, 0, 1], - [1, 1, 1], - ], - }; - const PIECE_COLORS: Record = { + // derive shapes/colors from selected mode's tetrominos when possible + const tetrominos = setup.tetrominos; + const pieceKeys = Object.keys(tetrominos); + + const baseClassicColors: Record = { I: '#0d9488', O: '#ca8a04', T: '#9333ea', @@ -403,15 +386,20 @@ function GameComponent() { L: '#c2410c', }; + const palette = ['#60a5fa', '#f97316', '#e11d48', '#7c3aed', '#059669', '#d946ef', '#facc15', '#06b6d4']; + + const PIECE_SHAPES: Record = {}; + const PIECE_COLORS: Record = {}; + pieceKeys.forEach((k, idx) => { + const mats = tetrominos[k]; + PIECE_SHAPES[k] = mats && mats.length ? mats[0] : [[1]]; + PIECE_COLORS[k] = baseClassicColors[k] ?? palette[idx % palette.length]; + }); + const PIECE_ID_COLORS: string[] = [ - '#000000', // index 0 unused - PIECE_COLORS.I, - PIECE_COLORS.O, - PIECE_COLORS.T, - PIECE_COLORS.S, - PIECE_COLORS.Z, - PIECE_COLORS.J, - PIECE_COLORS.L, + '#000000', + // numeric mapping fallback: map first few pieceKeys to indexes 1..n + ...pieceKeys.slice(0, 15).map((k) => PIECE_COLORS[k] ?? palette[0]), ]; const renderOpponentBoard = (board: number[][] | null, isAlive: boolean, cell = 4) => { @@ -530,6 +518,26 @@ function GameComponent() { /> +
+ + +
+
+
+ + +
+
{!playerId && ( + ); + })} +
+ ); + } + + return ( +
+ {modes.map((m) => { + const info = GAME_MODE_INFO[m]; + const setup = GAME_SETUP[m]; + const isActive = selected === m; + const pieceCount = Object.keys(setup.tetrominos).length; + + return ( + + ); + })} +
+ ); +} diff --git a/client/app/constants/index.ts b/client/app/constants/index.ts index de4d1a7..65afcc0 100644 --- a/client/app/constants/index.ts +++ b/client/app/constants/index.ts @@ -20,5 +20,59 @@ const GAME_SETUP: Record = { cyber: { tetrominos: CYBER_TETROMINOS, width: 8, height: 18 }, }; -export { BSP_SCORES, GAME_MODES, GAME_SETUP, STANDARD_FIRST_SPEED, TICK_INTERVAL_MS }; -export type { GameMode }; +interface GameModeInfo { + label: string; + description: string; + icon: string; + accentColor: string; + accentBg: string; + accentBorder: string; + boardLabel: string; + piecesLabel: string; +} + +const GAME_MODE_INFO: Record = { + classic: { + label: 'Classic', + description: 'The original Tetris experience with standard pieces and a 10×20 board.', + icon: '', + accentColor: 'text-blue-400', + accentBg: 'bg-blue-500/15', + accentBorder: 'border-blue-500/40', + boardLabel: '10 × 20', + piecesLabel: '7 classic tetrominoes', + }, + narrow: { + label: 'Narrow', + description: 'Classic pieces on a tight 6-wide board. Every move counts!', + icon: '', + accentColor: 'text-amber-400', + accentBg: 'bg-amber-500/15', + accentBorder: 'border-amber-500/40', + boardLabel: '6 × 22', + piecesLabel: '7 classic tetrominoes', + }, + pentominoes: { + label: 'Pentominoes', + description: 'Five-cell pieces on a wider board. A whole new level of complexity.', + icon: '', + accentColor: 'text-purple-400', + accentBg: 'bg-purple-500/15', + accentBorder: 'border-purple-500/40', + boardLabel: '12 × 22', + piecesLabel: '18 pentomino shapes', + }, + cyber: { + label: 'Cyber', + description: 'Futuristic custom shapes on a compact board. Expect the unexpected.', + icon: '', + accentColor: 'text-cyan-400', + accentBg: 'bg-cyan-500/15', + accentBorder: 'border-cyan-500/40', + boardLabel: '8 × 18', + piecesLabel: '6 cyber shapes', + }, +}; + +export { BSP_SCORES, GAME_MODES, GAME_MODE_INFO, GAME_SETUP, STANDARD_FIRST_SPEED, TICK_INTERVAL_MS }; +export type { GameMode, GameModeInfo }; diff --git a/client/app/routes/game.tsx b/client/app/routes/game.tsx index b7b4d0c..a1aa388 100644 --- a/client/app/routes/game.tsx +++ b/client/app/routes/game.tsx @@ -2,14 +2,19 @@ import { useEffect, useMemo, useRef, useState, type JSX } from 'react'; import { Link } from 'react-router'; import { io } from 'socket.io-client'; import { ProtectedRoute } from '../components/auth/ProtectedRoute'; +import { GameModeSelector } from '../components/GameModeSelector'; import { LoadingOverlay } from '../components/LoadingOverlay'; -import { GAME_MODES, GAME_SETUP, type GameMode } from '../constants'; +import { GAME_MODE_INFO, GAME_MODES, GAME_SETUP, type GameMode } from '../constants'; import { authService } from '../services/auth'; import type { GameState, PlayerState, Socket } from '../types/socket'; import type { Route } from './+types/game'; const CELL = 28; +// Fixed dimensions based on the widest/tallest mode so layout doesn't shift when switching modes +const MAX_WIDTH = Math.max(...Object.values(GAME_SETUP).map((s) => s.width)); +const MAX_HEIGHT = Math.max(...Object.values(GAME_SETUP).map((s) => s.height)); + export function meta({}: Route.MetaArgs) { return [ { title: 'Red Tetris — Play' }, @@ -28,8 +33,6 @@ function GameComponent() { const [actionLoading, setActionLoading] = useState(false); const socketRef = useRef(null); - const modes = useMemo(() => Array.from(GAME_MODES), []); - const setup = GAME_SETUP[selectedMode]; const WIDTH = setup.width; const HEIGHT = setup.height; @@ -60,15 +63,16 @@ function GameComponent() { // connection established }); - // server may emit either a raw state object or { state, hostId } historically. - s.on('state', (payload: GameState | { state: GameState; hostId?: string }) => { + // server sends a flat state object with mode & hostId + s.on('state', (payload: GameState) => { setActionLoading(false); - // normalize payload to `state` object - const incoming: GameState = 'state' in payload ? payload.state : payload; - setState(incoming); - // hostId may be attached at top-level of payload or inside incoming - const hid = 'hostId' in payload ? (payload.hostId ?? incoming?.hostId ?? null) : (incoming?.hostId ?? null); - setHostId(hid); + setState(payload); + setHostId(payload.hostId ?? null); + // sync selected mode with the room's actual mode + const m = payload.mode; + if (m && (GAME_MODES as readonly string[]).includes(m)) { + setSelectedMode(m as GameMode); + } }); s.on('joined', async (info: { roomId?: string }) => { @@ -392,7 +396,7 @@ function GameComponent() { const PIECE_COLORS: Record = {}; pieceKeys.forEach((k, idx) => { const mats = tetrominos[k]; - PIECE_SHAPES[k] = mats && mats.length ? mats[0] : [[1]]; + PIECE_SHAPES[k] = mats?.length ? mats[0] : [[1]]; PIECE_COLORS[k] = baseClassicColors[k] ?? palette[idx % palette.length]; }); @@ -488,7 +492,7 @@ function GameComponent() { className='min-h-screen bg-slate-900 p-6' style={{ pointerEvents: 'auto' }} > -
+
{/* Room Controls */} -
+

Room Settings

@@ -520,22 +524,12 @@ function GameComponent() {
- + compact + />
@@ -613,9 +607,9 @@ function GameComponent() {
{/* Main Game Layout */} -
+
{/* Left Side: Room Controls (Desktop) */} -
+

Room Settings

@@ -633,22 +627,11 @@ function GameComponent() {
- + />
@@ -730,7 +713,7 @@ function GameComponent() {
{/* Center: Game Area */} -
+
{/* Hold Piece */}

Hold

@@ -745,9 +728,19 @@ function GameComponent() { {/* Main Game Board */}

Your Board

+
+ {GAME_MODE_INFO[selectedMode].icon} + + {GAME_MODE_INFO[selectedMode].label} + + ({GAME_MODE_INFO[selectedMode].boardLabel}) +
-
+
0 && ( -
+

Opponents

diff --git a/client/app/types/socket.ts b/client/app/types/socket.ts index 8b16362..8aa8346 100644 --- a/client/app/types/socket.ts +++ b/client/app/types/socket.ts @@ -35,6 +35,7 @@ export interface GameState { players: Record; winnerId?: string | null; hostId?: string | null; + mode?: string | null; } export interface ClientToServerEvents { @@ -47,7 +48,7 @@ export interface ClientToServerEvents { export interface ServerToClientEvents { created: (payload: { roomId: string }) => void; - state: (payload: GameState | { state: GameState; hostId?: string }) => void; + state: (payload: GameState) => void; game_over: (payload: { message: string }) => void; join_failed: (payload: { code: string; message: string }) => void; start_failed: (payload: { code: string; message: string }) => void;