+
-
- {timeLeftSeconds}
-
+ {timerEnabled ? (
+
+ {timeLeftSeconds}
+
+ ) : (
+
+ ∞
+
+ )}
+ {!timerEnabled && role === 'HOST' && onEndQuestion && (
+
+ )}
@@ -163,4 +183,4 @@ export const GameScreen: React.FC = ({
);
-};
\ No newline at end of file
+};
diff --git a/hooks/useGame.ts b/hooks/useGame.ts
index 0d3f64d..74488fe 100644
--- a/hooks/useGame.ts
+++ b/hooks/useGame.ts
@@ -4,7 +4,7 @@ import { useAuth } from 'react-oidc-context';
import { useAuthenticatedFetch } from './useAuthenticatedFetch';
import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG, PointsBreakdown } from '../types';
import { generateQuiz } from '../services/geminiService';
-import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants';
+import { PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants';
import { Peer, DataConnection, PeerOptions } from 'peerjs';
import { uniqueNamesGenerator, adjectives, animals } from 'unique-names-generator';
@@ -975,6 +975,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
selectedShape: null,
assignedName,
presenterId: presenterIdRef.current,
+ timerEnabled: gameConfigRef.current.timerEnabled,
};
if (currentQuestion) {
@@ -985,6 +986,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
...o,
isCorrect: false
}));
+ welcomePayload.questionTimeLimit = currentQuestion.timeLimit;
}
if (reconnectedPlayer) {
@@ -1030,7 +1032,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
const breakdown = calculatePointsWithBreakdown({
isCorrect,
timeLeftMs: timeLeftRef.current,
- questionTimeMs: QUESTION_TIME_MS,
+ questionTimeMs: (quizRef.current?.questions[currentQuestionIndexRef.current]?.timeLimit || 20) * 1000,
streak: newStreak,
playerRank,
isFirstCorrect,
@@ -1133,7 +1135,6 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
const startQuestion = (isResume: boolean = false) => {
setGameState('QUESTION');
- setTimeLeft(QUESTION_TIME_MS);
setFirstCorrectPlayerId(null);
if (isResume) {
@@ -1162,6 +1163,8 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
const options = currentQ.options || [];
const correctOpt = options.find(o => o.isCorrect);
const correctShape = correctOpt?.shape || 'triangle';
+ const questionTimeMs = currentQ.timeLimit * 1000;
+ setTimeLeft(questionTimeMs);
setCurrentCorrectShape(correctShape);
const optionsForClient = options.map(o => ({
@@ -1169,32 +1172,37 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
isCorrect: false
}));
+ const timerOn = gameConfigRef.current.timerEnabled;
broadcast({
type: 'QUESTION_START',
payload: {
totalQuestions: currentQuiz.questions.length,
currentQuestionIndex: currentIndex,
- timeLimit: QUESTION_TIME,
+ timeLimit: currentQ.timeLimit,
correctShape,
questionText: currentQ.text,
- options: optionsForClient
+ options: optionsForClient,
+ timerEnabled: timerOn,
}
});
}
if (timerRef.current) clearInterval(timerRef.current);
- let tickCount = 0;
- timerRef.current = setInterval(() => {
- setTimeLeft(prev => {
- if (prev <= 100) { endQuestion(); return 0; }
- const newTime = prev - 100;
- tickCount++;
- if (tickCount % 10 === 0) {
- broadcast({ type: 'TIME_SYNC', payload: { timeLeft: newTime } });
- }
- return newTime;
- });
- }, 100);
+
+ if (gameConfigRef.current.timerEnabled) {
+ let tickCount = 0;
+ timerRef.current = setInterval(() => {
+ setTimeLeft(prev => {
+ if (prev <= 100) { endQuestion(); return 0; }
+ const newTime = prev - 100;
+ tickCount++;
+ if (tickCount % 10 === 0) {
+ broadcast({ type: 'TIME_SYNC', payload: { timeLeft: newTime } });
+ }
+ return newTime;
+ });
+ }, 100);
+ }
};
const endQuestion = () => {
@@ -1209,7 +1217,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
const breakdown = calculatePointsWithBreakdown({
isCorrect: false,
timeLeftMs: 0,
- questionTimeMs: QUESTION_TIME_MS,
+ questionTimeMs: (quizRef.current?.questions[currentQuestionIndexRef.current]?.timeLimit || 20) * 1000,
streak: 0,
playerRank,
isFirstCorrect: false,
@@ -1463,7 +1471,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
id: `q-${i}`,
text: payload.questionText,
options: payload.options,
- timeLimit: QUESTION_TIME
+ timeLimit: payload.questionTimeLimit || 20
});
} else {
questions.push({ id: `loading-${i}`, text: '', options: [], timeLimit: 0 });
@@ -1482,7 +1490,9 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
setGameState(serverGameState);
if (!playerHasAnswered && serverGameState === 'QUESTION') {
if (timerRef.current) clearInterval(timerRef.current);
- timerRef.current = setInterval(() => setTimeLeft(prev => Math.max(0, prev - 100)), 100);
+ if (payload.timerEnabled !== false) {
+ timerRef.current = setInterval(() => setTimeLeft(prev => Math.max(0, prev - 100)), 100);
+ }
}
} else if (serverGameState === 'REVEAL') {
setGameState('REVEAL');
@@ -1533,7 +1543,9 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
});
if (timerRef.current) clearInterval(timerRef.current);
- timerRef.current = setInterval(() => setTimeLeft(prev => Math.max(0, prev - 100)), 100);
+ if (data.payload.timerEnabled !== false) {
+ timerRef.current = setInterval(() => setTimeLeft(prev => Math.max(0, prev - 100)), 100);
+ }
}
if (data.type === 'RESULT') {
@@ -1629,7 +1641,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
const breakdown = calculatePointsWithBreakdown({
isCorrect,
timeLeftMs: timeLeftRef.current,
- questionTimeMs: QUESTION_TIME_MS,
+ questionTimeMs: (quizRef.current?.questions[currentQuestionIndexRef.current]?.timeLimit || 20) * 1000,
streak: newStreak,
playerRank,
isFirstCorrect,
@@ -1759,7 +1771,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
return {
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, lastAnswerCorrect, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig,
pendingQuizToSave, dismissSavePrompt, sourceQuizId, isReconnecting, currentPlayerName, presenterId, connectedPlayerIds,
- startQuizGen, startManualCreation, cancelCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard,
+ startQuizGen, startManualCreation, cancelCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard, endQuestion,
updateQuizFromEditor, startGameFromEditor, backFromEditor, endGame, attemptReconnect, goHomeFromDisconnected, resumeGame, setPresenterPlayer, sendAdvance, kickPlayer, leaveGame, setHostName
};
};
diff --git a/tests/components/DefaultConfigModal.test.tsx b/tests/components/DefaultConfigModal.test.tsx
index de57cba..34dea40 100644
--- a/tests/components/DefaultConfigModal.test.tsx
+++ b/tests/components/DefaultConfigModal.test.tsx
@@ -294,6 +294,8 @@ describe('DefaultConfigModal', () => {
penaltyPercent: 30,
firstCorrectBonusEnabled: true,
firstCorrectBonusPoints: 75,
+ timerEnabled: true,
+ maxPlayers: 10,
};
render(
);
diff --git a/tests/components/GameConfigPanel.test.tsx b/tests/components/GameConfigPanel.test.tsx
index 079d1de..b417aa2 100644
--- a/tests/components/GameConfigPanel.test.tsx
+++ b/tests/components/GameConfigPanel.test.tsx
@@ -415,6 +415,8 @@ describe('GameConfigPanel', () => {
penaltyPercent: 30,
firstCorrectBonusEnabled: true,
firstCorrectBonusPoints: 75,
+ timerEnabled: true,
+ maxPlayers: 10,
};
render(
);
@@ -439,6 +441,8 @@ describe('GameConfigPanel', () => {
penaltyPercent: 25,
firstCorrectBonusEnabled: false,
firstCorrectBonusPoints: 50,
+ timerEnabled: true,
+ maxPlayers: 10,
};
render(
);
diff --git a/tests/components/QuizEditorConfig.test.tsx b/tests/components/QuizEditorConfig.test.tsx
index 11b7434..8a4ba31 100644
--- a/tests/components/QuizEditorConfig.test.tsx
+++ b/tests/components/QuizEditorConfig.test.tsx
@@ -347,6 +347,8 @@ describe('QuizEditor - Game Config Integration', () => {
penaltyPercent: 30,
firstCorrectBonusEnabled: true,
firstCorrectBonusPoints: 75,
+ timerEnabled: true,
+ maxPlayers: 10,
},
});
diff --git a/tests/hooks/useQuizLibrary.test.tsx b/tests/hooks/useQuizLibrary.test.tsx
index 0679f72..c0dfe0d 100644
--- a/tests/hooks/useQuizLibrary.test.tsx
+++ b/tests/hooks/useQuizLibrary.test.tsx
@@ -294,6 +294,8 @@ describe('useQuizLibrary', () => {
penaltyPercent: 25,
firstCorrectBonusEnabled: false,
firstCorrectBonusPoints: 50,
+ timerEnabled: true,
+ maxPlayers: 10,
},
});
@@ -1135,6 +1137,8 @@ it('creates blob with correct mime type', async () => {
penaltyPercent: 10,
firstCorrectBonusEnabled: true,
firstCorrectBonusPoints: 25,
+ timerEnabled: true,
+ maxPlayers: 10,
};
await act(async () => {
diff --git a/types.ts b/types.ts
index 3e9d399..b69677f 100644
--- a/types.ts
+++ b/types.ts
@@ -67,6 +67,7 @@ export interface Question {
}
export interface GameConfig {
+ timerEnabled: boolean;
shuffleQuestions: boolean;
shuffleAnswers: boolean;
hostParticipates: boolean;
@@ -84,6 +85,7 @@ export interface GameConfig {
}
export const DEFAULT_GAME_CONFIG: GameConfig = {
+ timerEnabled: true,
shuffleQuestions: false,
shuffleAnswers: false,
hostParticipates: true,
@@ -205,8 +207,10 @@ export type NetworkMessage =
options?: AnswerOption[];
correctShape?: string;
timeLeft?: number;
+ questionTimeLimit?: number;
assignedName?: string;
presenterId?: string | null;
+ timerEnabled?: boolean;
} }
| { type: 'PLAYER_JOINED'; payload: { player: Player } }
| { type: 'GAME_START'; payload: {} }
@@ -220,6 +224,7 @@ export type NetworkMessage =
correctShape: string;
questionText: string;
options: AnswerOption[];
+ timerEnabled?: boolean;
}
}
| { type: 'ANSWER'; payload: { playerId: string; isCorrect: boolean; selectedShape: 'triangle' | 'diamond' | 'circle' | 'square' } }