Add disable timer setting and fix per-question time limits

Add a 'Question Timer' toggle to game settings that lets the host disable
the countdown timer. When disabled, questions show ∞ instead of a countdown,
the host gets an 'End Question' button to manually advance, and all correct
answers receive maximum points.

Also fix a bug where per-question time limits were ignored — the timer and
scoring always used the hardcoded 20-second default instead of each question's
individual timeLimit.
This commit is contained in:
Joey Yakimowich-Payne 2026-02-23 13:44:12 -07:00
commit d1f82440a1
No known key found for this signature in database
GPG key ID: DDF6AF5B21B407D4
9 changed files with 94 additions and 31 deletions

View file

@ -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
};
};