Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions backend/src/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Bootstrap module — must be imported first in index.ts.
* Loads .env before any other module reads process.env.
*/
import { resolve } from 'path';

import { config } from 'dotenv';

import { logger } from './utils/logger';

const result = config({ path: resolve(__dirname, '../.env') });

if (result.error) {
logger.warn(
'Bootstrap',
'Warning: .env file not found, using default values'
);
} else {
logger.log('Bootstrap', 'Environment variables loaded successfully');
}
2 changes: 1 addition & 1 deletion backend/src/constants/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const DRUM_CONFIG = {
/** Game duration (ms) */
GAME_DURATION_MS: 10000,
/** Max taps to win instantly */
MAX_TAPS: 30,
MAX_TAPS: 60,
} as const;

export const VERDICT_CONFIG = {
Expand Down
88 changes: 79 additions & 9 deletions backend/src/controllers/ws-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import type {
IJoinRoomMessage,
IChatSendMessage,
IDrumTapMessage,
IDrumStartRequestMessage,
IASRTextPushMessage,
IEmojiSendMessage,
ISpeechTurnEndMessage,
Expand All @@ -57,7 +58,7 @@ import type {
} from '../types/websocket';
import { EWSMessageType, EWSErrorCode, EGamePhase } from '../types/websocket';
import { ERoomStatus } from '../models/entities/room';
import { DRUM_CONFIG, WAITING_ROOM_CONFIG } from '../constants/config';
import { DRUM_CONFIG } from '../constants/config';
import { logger } from '../utils/logger';

export class WebSocketController {
Expand Down Expand Up @@ -93,6 +94,13 @@ export class WebSocketController {
);
break;

case EWSMessageType.DrumStartRequest:
WebSocketController.handleDrumStartRequestMessage(
connectionId,
message as IDrumStartRequestMessage
);
break;

case EWSMessageType.AsrTextPush:
WebSocketController.handleASRTextPushMessage(
connectionId,
Expand Down Expand Up @@ -185,11 +193,10 @@ export class WebSocketController {
timestamp: Date.now(),
});

// If room is ready (2 players), start drum game
// If room is ready (2 players), initialize drum game and wait for
// frontend to send DRUM_START_REQUEST before launching
if (result.room.status === ERoomStatus.Ready) {
setTimeout(() => {
WebSocketController.startDrumGame(result.room.roomId);
}, WAITING_ROOM_CONFIG.COUNTDOWN_MS);
WebSocketController.initDrumGame(result.room.roomId);
}
}

Expand Down Expand Up @@ -372,10 +379,11 @@ export class WebSocketController {
}

/**
* Start drum game flow
* Called when room is ready (2 players joined)
* Initialize drum game state and broadcast DRUM_READY
* Called when room reaches READY status (2 players joined).
* Does NOT start the countdown — waits for DRUM_START_REQUEST from client.
*/
private static startDrumGame(roomId: string): void {
private static initDrumGame(roomId: string): void {
const room = roomManager.getRoomById(roomId);
if (!room) {
logger.error('WSController', `Room ${roomId} not found`);
Expand Down Expand Up @@ -410,6 +418,35 @@ export class WebSocketController {
timestamp: Date.now(),
});

logger.log(
'WSController',
`Drum game ${roomId} initialized, waiting for DRUM_START_REQUEST`
);
}

/**
* Launch drum game countdown and scheduling
* Called when frontend sends DRUM_START_REQUEST.
*/
private static launchDrumGame(roomId: string): void {
const game = drumGameManager.getGame(roomId);
if (!game) {
logger.error(
'WSController',
`Cannot launch: game ${roomId} not initialized`
);
return;
}

// Guard: only launch from Waiting phase
if (game.phase !== EGamePhase.Waiting) {
logger.log(
'WSController',
`Game ${roomId} already launched (phase: ${game.phase}), ignoring`
);
return;
}

// Set countdown phase
drumGameManager.setPhase(roomId, EGamePhase.Countdown);

Expand Down Expand Up @@ -441,8 +478,41 @@ export class WebSocketController {

logger.log(
'WSController',
`Started drum game ${roomId} (start: ${startAtMs}, end: ${endAtMs})`
`Launched drum game ${roomId} (start: ${startAtMs}, end: ${endAtMs})`
);
}

/**
* Handle DRUM_START_REQUEST message
* Called when a player signals they are ready to start the drum game.
* Broadcasts DRUM_PLAYER_READY after each signal, then launches the game
* once both players are ready.
*/
private static handleDrumStartRequestMessage(
_connectionId: string,
message: IDrumStartRequestMessage
): void {
const { roomId, userId } = message.data;

const bothReady: boolean = drumGameManager.markPlayerReady(
roomId,
userId
);
const readyCount: number = drumGameManager.getReadyCount(roomId);

// Broadcast ready state to all participants
connectionManager.broadcastToRoom(roomId, {
type: EWSMessageType.DrumPlayerReady,
data: {
roomId,
readyCount,
},
timestamp: Date.now(),
});

if (bothReady) {
WebSocketController.launchDrumGame(roomId);
}
}

/**
Expand Down
22 changes: 8 additions & 14 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
// Now import modules that depend on environment variables
import './bootstrap'; // Must be first — loads .env before other modules read process.env

import http from 'http';

import { loadEnv, validateEnv } from './utils/env-loader';
import app from './app';
import { APP_CONFIG } from './constants/config';
import { logger } from './utils/logger';
let server: http.Server;
import { initWebSocket } from './ws';

async function bootstrap() {
loadEnv();
validateEnv();

const { default: app } = await import('./app');
const { initWebSocket } = await import('./ws');
const { APP_CONFIG } = await import('./constants/config');
let server: http.Server;

function bootstrap(): void {
server = http.createServer(app);
initWebSocket(server);

Expand All @@ -22,10 +19,7 @@ async function bootstrap() {
});
}

bootstrap().catch(e => {
logger.error('Server', 'Bootstrap failed:', e);
process.exit(1);
});
bootstrap();

// Graceful shutdown handler
function handleShutdown(signal: string): void {
Expand Down
48 changes: 47 additions & 1 deletion backend/src/services/websocket/drum-game-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import type { IRoom } from '../../models/entities/room';
import type { IUser } from '../../models/entities/user';
import { EPlayerRole, EGamePhase } from '../../types/websocket/drum';
import { DRUM_CONFIG } from '../../constants/config';
import { logger } from '../../utils/logger';

/**
Expand All @@ -24,6 +25,8 @@ interface IDrumGameState {
joinerScore: number;
startAtMs: number;
endAtMs: number;
readyUserIds: Set<string>;
firstToMaxRole?: EPlayerRole;
}

/**
Expand Down Expand Up @@ -77,6 +80,7 @@ export class DrumGameManager {
joinerScore: 0,
startAtMs: 0,
endAtMs: 0,
readyUserIds: new Set<string>(),
};

this.games.set(roomId, game);
Expand All @@ -96,6 +100,36 @@ export class DrumGameManager {
return this.games.get(roomId);
}

/**
* Get the number of players who have signalled ready.
*/
getReadyCount(roomId: string): number {
return this.games.get(roomId)?.readyUserIds.size ?? 0;
}

/**
* Mark a player as ready to start the drum game.
* Returns true when both players have signalled ready.
*/
markPlayerReady(roomId: string, userId: string): boolean {
const game = this.games.get(roomId);
if (!game) {
return false;
}

game.readyUserIds.add(userId);

const totalPlayers: number = 2;
const bothReady: boolean = game.readyUserIds.size >= totalPlayers;

logger.log(
'DrumGameManager',
`Game ${roomId} ready: ${game.readyUserIds.size}/${totalPlayers} (userId: ${userId})`
);

return bothReady;
}

/**
* Update game phase
*/
Expand Down Expand Up @@ -157,6 +191,15 @@ export class DrumGameManager {
game.joinerScore += delta;
}

// Record who first reaches MAX_TAPS (only once)
if (game.firstToMaxRole === undefined) {
if (game.organizerScore >= DRUM_CONFIG.MAX_TAPS) {
game.firstToMaxRole = EPlayerRole.Organizer;
} else if (game.joinerScore >= DRUM_CONFIG.MAX_TAPS) {
game.firstToMaxRole = EPlayerRole.Joiner;
}
}

logger.log(
'DrumGameManager',
`Game ${roomId} tap: ${role} +${delta} (Organizer: ${game.organizerScore}, Joiner: ${game.joinerScore})`
Expand All @@ -177,7 +220,10 @@ export class DrumGameManager {

let winnerRole: EPlayerRole;

if (game.organizerScore > game.joinerScore) {
if (game.firstToMaxRole !== undefined) {
// Someone reached MAX_TAPS — first to reach it wins
winnerRole = game.firstToMaxRole;
} else if (game.organizerScore > game.joinerScore) {
winnerRole = EPlayerRole.Organizer;
} else if (game.joinerScore > game.organizerScore) {
winnerRole = EPlayerRole.Joiner;
Expand Down
2 changes: 2 additions & 0 deletions backend/src/types/websocket/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export enum EWSMessageType {
// Drum Game (Bidirectional)
DrumReady = 'DRUM_READY',
DrumStart = 'DRUM_START',
DrumStartRequest = 'DRUM_START_REQUEST', // Client → Server
DrumPlayerReady = 'DRUM_PLAYER_READY', // Server → Client
DrumTap = 'DRUM_TAP',
DrumFinish = 'DRUM_FINISH',
DrumResult = 'DRUM_RESULT',
Expand Down
28 changes: 28 additions & 0 deletions backend/src/types/websocket/drum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,34 @@ export interface IDrumResultData {
winnerRole: EPlayerRole;
}

// ==================== Client → Server ====================

/**
* DRUM_START_REQUEST: Client requests to start the drum game
* Sent after both players have navigated to the drum room
*/
export interface IDrumStartRequestMessage extends IWSMessage<IDrumStartRequestData> {
type: EWSMessageType.DrumStartRequest;
}

export interface IDrumStartRequestData {
roomId: string;
userId: string;
}

/**
* DRUM_PLAYER_READY: A player has signalled ready (but game not yet started)
* Broadcast to all participants so UI can show waiting state
*/
export interface IDrumPlayerReadyMessage extends IWSMessage<IDrumPlayerReadyData> {
type: EWSMessageType.DrumPlayerReady;
}

export interface IDrumPlayerReadyData {
roomId: string;
readyCount: number; // How many players are ready so far (1 or 2)
}

// ==================== Bidirectional ====================

/**
Expand Down
Loading
Loading