Opponents
diff --git a/client/app/types/socket.ts b/client/app/types/socket.ts
index 7ef095c..8aa8346 100644
--- a/client/app/types/socket.ts
+++ b/client/app/types/socket.ts
@@ -35,10 +35,11 @@ export interface GameState {
players: Record;
winnerId?: string | null;
hostId?: string | null;
+ mode?: string | null;
}
export interface ClientToServerEvents {
- create: () => Promise | void;
+ create: (payload: { mode: string }) => Promise | void;
join: (payload: { roomId: string }) => Promise | void;
start: (payload: { roomId: string }) => void;
restart: (payload: { roomId: string }) => void;
@@ -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;
diff --git a/server/constants/index.ts b/server/constants/index.ts
new file mode 100644
index 0000000..de4d1a7
--- /dev/null
+++ b/server/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/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..d1353d4 100644
--- a/server/core/GameManager.ts
+++ b/server/core/GameManager.ts
@@ -1,13 +1,14 @@
import { randomUUID } from 'crypto';
+import { GameMode } from '../constants';
import { Game } from '../game/Game';
import { Player } from './Player';
export class GameManager {
games = new Map();
- createNewGame(host: Player): Game {
+ createNewGame(host: Player, mode: GameMode = '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..2a4252e 100644
--- a/server/game/Game.ts
+++ b/server/game/Game.ts
@@ -1,3 +1,4 @@
+import { GameMode } 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: GameMode;
- constructor(id: string, host: Player) {
+ constructor(id: string, host: Player, mode: GameMode = '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..971eb37 100644
--- a/server/game/GameEngine.ts
+++ b/server/game/GameEngine.ts
@@ -1,5 +1,5 @@
+import { BSP_SCORES, GAME_SETUP, GameMode, 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: GameMode = '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..c90b820 100644
--- a/server/net/socketHandlers.ts
+++ b/server/net/socketHandlers.ts
@@ -1,8 +1,9 @@
import { Server, Socket } from 'socket.io';
+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';
-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 +56,12 @@ export function registerSocketHandlers(
players: playersObj,
winnerId,
hostId: game.hostId,
+ mode: game.mode,
});
}
}
- socket.on('create', async () => {
+ 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' });
@@ -69,7 +71,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 +286,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..03b8942 100644
--- a/server/types/socketEvents.ts
+++ b/server/types/socketEvents.ts
@@ -1,8 +1,9 @@
+import { GameMode } from '../constants';
import { GameState, Intent, PlayerState } from '../game/types';
import { JwtPayload } from '../utils/jwt';
export interface ClientToServerEvents {
- create: () => Promise;
+ create: (payload: { mode: GameMode }) => 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: GameMode;
}) => void;
game_over: (payload: { message: string }) => void;
join_failed: (payload: { code: string; message: string }) => void;