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

@ -106,7 +106,8 @@ function App() {
kickPlayer, kickPlayer,
leaveGame, leaveGame,
connectedPlayerIds, connectedPlayerIds,
setHostName setHostName,
endQuestion,
} = useGame(defaultConfig); } = useGame(defaultConfig);
const handleSaveQuiz = async () => { const handleSaveQuiz = async () => {
@ -275,6 +276,8 @@ function App() {
hasAnswered={hasAnswered} hasAnswered={hasAnswered}
lastPointsEarned={lastPointsEarned} lastPointsEarned={lastPointsEarned}
hostPlays={gameConfig.hostParticipates} hostPlays={gameConfig.hostParticipates}
timerEnabled={gameConfig.timerEnabled}
onEndQuestion={role === 'HOST' ? endQuestion : undefined}
/> />
) : role === 'CLIENT' && hasAnswered ? ( ) : role === 'CLIENT' && hasAnswered ? (
<div className="flex flex-col items-center justify-center h-screen"> <div className="flex flex-col items-center justify-center h-screen">

View file

@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Shuffle, Eye, Flame, TrendingUp, MinusCircle, Award, Info, ChevronDown, ChevronUp, Dices, Users } from 'lucide-react'; import { Shuffle, Eye, Flame, TrendingUp, MinusCircle, Award, Info, ChevronDown, ChevronUp, Dices, Users, Timer } from 'lucide-react';
import type { GameConfig } from '../types'; import type { GameConfig } from '../types';
interface GameConfigPanelProps { interface GameConfigPanelProps {
@ -263,6 +263,17 @@ export const GameConfigPanel: React.FC<GameConfigPanelProps> = ({
</div> </div>
</ValueRow> </ValueRow>
<ToggleRow
icon={<Timer size={20} />}
iconActive={config.timerEnabled}
label="Question Timer"
description="Countdown timer for each question"
checked={config.timerEnabled}
onChange={(v) => update({ timerEnabled: v })}
tooltip="When disabled, questions have no time limit. The host must manually end each question. All correct answers receive maximum points."
/>
<ToggleRow <ToggleRow
icon={<Shuffle size={20} />} icon={<Shuffle size={20} />}
iconActive={config.shuffleQuestions} iconActive={config.shuffleQuestions}

View file

@ -2,6 +2,7 @@ import React from 'react';
import { Question, AnswerOption, GameState, GameRole } from '../types'; import { Question, AnswerOption, GameState, GameRole } from '../types';
import { COLORS, SHAPES } from '../constants'; import { COLORS, SHAPES } from '../constants';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { StopCircle } from 'lucide-react';
interface GameScreenProps { interface GameScreenProps {
question?: Question; question?: Question;
@ -14,6 +15,8 @@ interface GameScreenProps {
hasAnswered: boolean; hasAnswered: boolean;
lastPointsEarned: number | null; lastPointsEarned: number | null;
hostPlays?: boolean; hostPlays?: boolean;
timerEnabled?: boolean;
onEndQuestion?: () => void;
} }
export const GameScreen: React.FC<GameScreenProps> = ({ export const GameScreen: React.FC<GameScreenProps> = ({
@ -26,13 +29,15 @@ export const GameScreen: React.FC<GameScreenProps> = ({
onAnswer, onAnswer,
hasAnswered, hasAnswered,
hostPlays = true, hostPlays = true,
timerEnabled = true,
onEndQuestion,
}) => { }) => {
const isClient = role === 'CLIENT'; const isClient = role === 'CLIENT';
const isSpectator = role === 'HOST' && !hostPlays; const isSpectator = role === 'HOST' && !hostPlays;
const displayOptions = question?.options || []; const displayOptions = question?.options || [];
const timeLeftSeconds = Math.ceil(timeLeft / 1000); const timeLeftSeconds = Math.ceil(timeLeft / 1000);
const isUrgent = timeLeftSeconds <= 5 && timeLeftSeconds > 0; const isUrgent = timerEnabled && timeLeftSeconds <= 5 && timeLeftSeconds > 0;
const timerBorderColor = isUrgent ? 'border-red-500' : 'border-white'; const timerBorderColor = isUrgent ? 'border-red-500' : 'border-white';
const timerTextColor = isUrgent ? 'text-red-500' : 'text-theme-primary'; const timerTextColor = isUrgent ? 'text-red-500' : 'text-theme-primary';
const timerAnimation = isUrgent ? 'animate-ping' : ''; const timerAnimation = isUrgent ? 'animate-ping' : '';
@ -45,11 +50,26 @@ export const GameScreen: React.FC<GameScreenProps> = ({
{currentQuestionIndex + 1} / {totalQuestions} {currentQuestionIndex + 1} / {totalQuestions}
</div> </div>
<div className="relative"> <div className="relative flex flex-col items-center gap-2">
<div className="absolute inset-0 bg-white/20 rounded-full blur-xl animate-pulse"></div> <div className="absolute inset-0 bg-white/20 rounded-full blur-xl animate-pulse"></div>
<div className={`bg-white ${timerTextColor} rounded-full w-14 h-14 md:w-20 md:h-20 flex items-center justify-center text-2xl md:text-4xl font-black shadow-[0_6px_0_rgba(0,0,0,0.2)] border-4 ${timerBorderColor} ${timerAnimation} relative z-10 transition-colors duration-300`}> {timerEnabled ? (
{timeLeftSeconds} <div className={`bg-white ${timerTextColor} rounded-full w-14 h-14 md:w-20 md:h-20 flex items-center justify-center text-2xl md:text-4xl font-black shadow-[0_6px_0_rgba(0,0,0,0.2)] border-4 ${timerBorderColor} ${timerAnimation} relative z-10 transition-colors duration-300`}>
</div> {timeLeftSeconds}
</div>
) : (
<div className="bg-white text-theme-primary rounded-full w-14 h-14 md:w-20 md:h-20 flex items-center justify-center text-3xl md:text-5xl font-black shadow-[0_6px_0_rgba(0,0,0,0.2)] border-4 border-white relative z-10">
∞
</div>
)}
{!timerEnabled && role === 'HOST' && onEndQuestion && (
<button
onClick={onEndQuestion}
className="relative z-10 flex items-center gap-1.5 bg-white/90 hover:bg-white text-red-600 px-3 py-1.5 rounded-full text-xs md:text-sm font-bold shadow-lg hover:shadow-xl transition-all active:scale-95"
>
<StopCircle size={16} />
End Question
</button>
)}
</div> </div>
<div className="bg-white/20 backdrop-blur-md px-3 md:px-6 py-1 md:py-2 rounded-xl md:rounded-2xl font-black text-sm md:text-xl shadow-sm border-2 border-white/10"> <div className="bg-white/20 backdrop-blur-md px-3 md:px-6 py-1 md:py-2 rounded-xl md:rounded-2xl font-black text-sm md:text-xl shadow-sm border-2 border-white/10">
@ -163,4 +183,4 @@ export const GameScreen: React.FC<GameScreenProps> = ({
</AnimatePresence> </AnimatePresence>
</div> </div>
); );
}; };

View file

@ -4,7 +4,7 @@ import { useAuth } from 'react-oidc-context';
import { useAuthenticatedFetch } from './useAuthenticatedFetch'; import { useAuthenticatedFetch } from './useAuthenticatedFetch';
import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG, PointsBreakdown } from '../types'; import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG, PointsBreakdown } from '../types';
import { generateQuiz } from '../services/geminiService'; 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 { Peer, DataConnection, PeerOptions } from 'peerjs';
import { uniqueNamesGenerator, adjectives, animals } from 'unique-names-generator'; import { uniqueNamesGenerator, adjectives, animals } from 'unique-names-generator';
@ -975,6 +975,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
selectedShape: null, selectedShape: null,
assignedName, assignedName,
presenterId: presenterIdRef.current, presenterId: presenterIdRef.current,
timerEnabled: gameConfigRef.current.timerEnabled,
}; };
if (currentQuestion) { if (currentQuestion) {
@ -985,6 +986,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
...o, ...o,
isCorrect: false isCorrect: false
})); }));
welcomePayload.questionTimeLimit = currentQuestion.timeLimit;
} }
if (reconnectedPlayer) { if (reconnectedPlayer) {
@ -1030,7 +1032,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
const breakdown = calculatePointsWithBreakdown({ const breakdown = calculatePointsWithBreakdown({
isCorrect, isCorrect,
timeLeftMs: timeLeftRef.current, timeLeftMs: timeLeftRef.current,
questionTimeMs: QUESTION_TIME_MS, questionTimeMs: (quizRef.current?.questions[currentQuestionIndexRef.current]?.timeLimit || 20) * 1000,
streak: newStreak, streak: newStreak,
playerRank, playerRank,
isFirstCorrect, isFirstCorrect,
@ -1133,7 +1135,6 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
const startQuestion = (isResume: boolean = false) => { const startQuestion = (isResume: boolean = false) => {
setGameState('QUESTION'); setGameState('QUESTION');
setTimeLeft(QUESTION_TIME_MS);
setFirstCorrectPlayerId(null); setFirstCorrectPlayerId(null);
if (isResume) { if (isResume) {
@ -1162,6 +1163,8 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
const options = currentQ.options || []; const options = currentQ.options || [];
const correctOpt = options.find(o => o.isCorrect); const correctOpt = options.find(o => o.isCorrect);
const correctShape = correctOpt?.shape || 'triangle'; const correctShape = correctOpt?.shape || 'triangle';
const questionTimeMs = currentQ.timeLimit * 1000;
setTimeLeft(questionTimeMs);
setCurrentCorrectShape(correctShape); setCurrentCorrectShape(correctShape);
const optionsForClient = options.map(o => ({ const optionsForClient = options.map(o => ({
@ -1169,32 +1172,37 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
isCorrect: false isCorrect: false
})); }));
const timerOn = gameConfigRef.current.timerEnabled;
broadcast({ broadcast({
type: 'QUESTION_START', type: 'QUESTION_START',
payload: { payload: {
totalQuestions: currentQuiz.questions.length, totalQuestions: currentQuiz.questions.length,
currentQuestionIndex: currentIndex, currentQuestionIndex: currentIndex,
timeLimit: QUESTION_TIME, timeLimit: currentQ.timeLimit,
correctShape, correctShape,
questionText: currentQ.text, questionText: currentQ.text,
options: optionsForClient options: optionsForClient,
timerEnabled: timerOn,
} }
}); });
} }
if (timerRef.current) clearInterval(timerRef.current); if (timerRef.current) clearInterval(timerRef.current);
let tickCount = 0;
timerRef.current = setInterval(() => { if (gameConfigRef.current.timerEnabled) {
setTimeLeft(prev => { let tickCount = 0;
if (prev <= 100) { endQuestion(); return 0; } timerRef.current = setInterval(() => {
const newTime = prev - 100; setTimeLeft(prev => {
tickCount++; if (prev <= 100) { endQuestion(); return 0; }
if (tickCount % 10 === 0) { const newTime = prev - 100;
broadcast({ type: 'TIME_SYNC', payload: { timeLeft: newTime } }); tickCount++;
} if (tickCount % 10 === 0) {
return newTime; broadcast({ type: 'TIME_SYNC', payload: { timeLeft: newTime } });
}); }
}, 100); return newTime;
});
}, 100);
}
}; };
const endQuestion = () => { const endQuestion = () => {
@ -1209,7 +1217,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
const breakdown = calculatePointsWithBreakdown({ const breakdown = calculatePointsWithBreakdown({
isCorrect: false, isCorrect: false,
timeLeftMs: 0, timeLeftMs: 0,
questionTimeMs: QUESTION_TIME_MS, questionTimeMs: (quizRef.current?.questions[currentQuestionIndexRef.current]?.timeLimit || 20) * 1000,
streak: 0, streak: 0,
playerRank, playerRank,
isFirstCorrect: false, isFirstCorrect: false,
@ -1463,7 +1471,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
id: `q-${i}`, id: `q-${i}`,
text: payload.questionText, text: payload.questionText,
options: payload.options, options: payload.options,
timeLimit: QUESTION_TIME timeLimit: payload.questionTimeLimit || 20
}); });
} else { } else {
questions.push({ id: `loading-${i}`, text: '', options: [], timeLimit: 0 }); questions.push({ id: `loading-${i}`, text: '', options: [], timeLimit: 0 });
@ -1482,7 +1490,9 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
setGameState(serverGameState); setGameState(serverGameState);
if (!playerHasAnswered && serverGameState === 'QUESTION') { if (!playerHasAnswered && serverGameState === 'QUESTION') {
if (timerRef.current) clearInterval(timerRef.current); 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') { } else if (serverGameState === 'REVEAL') {
setGameState('REVEAL'); setGameState('REVEAL');
@ -1533,7 +1543,9 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
}); });
if (timerRef.current) clearInterval(timerRef.current); 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') { if (data.type === 'RESULT') {
@ -1629,7 +1641,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
const breakdown = calculatePointsWithBreakdown({ const breakdown = calculatePointsWithBreakdown({
isCorrect, isCorrect,
timeLeftMs: timeLeftRef.current, timeLeftMs: timeLeftRef.current,
questionTimeMs: QUESTION_TIME_MS, questionTimeMs: (quizRef.current?.questions[currentQuestionIndexRef.current]?.timeLimit || 20) * 1000,
streak: newStreak, streak: newStreak,
playerRank, playerRank,
isFirstCorrect, isFirstCorrect,
@ -1759,7 +1771,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
return { return {
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, lastAnswerCorrect, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig, role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, lastAnswerCorrect, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig,
pendingQuizToSave, dismissSavePrompt, sourceQuizId, isReconnecting, currentPlayerName, presenterId, connectedPlayerIds, 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 updateQuizFromEditor, startGameFromEditor, backFromEditor, endGame, attemptReconnect, goHomeFromDisconnected, resumeGame, setPresenterPlayer, sendAdvance, kickPlayer, leaveGame, setHostName
}; };
}; };

View file

@ -294,6 +294,8 @@ describe('DefaultConfigModal', () => {
penaltyPercent: 30, penaltyPercent: 30,
firstCorrectBonusEnabled: true, firstCorrectBonusEnabled: true,
firstCorrectBonusPoints: 75, firstCorrectBonusPoints: 75,
timerEnabled: true,
maxPlayers: 10,
}; };
render(<DefaultConfigModal {...defaultProps} config={allEnabledConfig} />); render(<DefaultConfigModal {...defaultProps} config={allEnabledConfig} />);

View file

@ -415,6 +415,8 @@ describe('GameConfigPanel', () => {
penaltyPercent: 30, penaltyPercent: 30,
firstCorrectBonusEnabled: true, firstCorrectBonusEnabled: true,
firstCorrectBonusPoints: 75, firstCorrectBonusPoints: 75,
timerEnabled: true,
maxPlayers: 10,
}; };
render(<GameConfigPanel {...defaultProps} config={allEnabledConfig} />); render(<GameConfigPanel {...defaultProps} config={allEnabledConfig} />);
@ -439,6 +441,8 @@ describe('GameConfigPanel', () => {
penaltyPercent: 25, penaltyPercent: 25,
firstCorrectBonusEnabled: false, firstCorrectBonusEnabled: false,
firstCorrectBonusPoints: 50, firstCorrectBonusPoints: 50,
timerEnabled: true,
maxPlayers: 10,
}; };
render(<GameConfigPanel {...defaultProps} config={allDisabledConfig} />); render(<GameConfigPanel {...defaultProps} config={allDisabledConfig} />);

View file

@ -347,6 +347,8 @@ describe('QuizEditor - Game Config Integration', () => {
penaltyPercent: 30, penaltyPercent: 30,
firstCorrectBonusEnabled: true, firstCorrectBonusEnabled: true,
firstCorrectBonusPoints: 75, firstCorrectBonusPoints: 75,
timerEnabled: true,
maxPlayers: 10,
}, },
}); });

View file

@ -294,6 +294,8 @@ describe('useQuizLibrary', () => {
penaltyPercent: 25, penaltyPercent: 25,
firstCorrectBonusEnabled: false, firstCorrectBonusEnabled: false,
firstCorrectBonusPoints: 50, firstCorrectBonusPoints: 50,
timerEnabled: true,
maxPlayers: 10,
}, },
}); });
@ -1135,6 +1137,8 @@ it('creates blob with correct mime type', async () => {
penaltyPercent: 10, penaltyPercent: 10,
firstCorrectBonusEnabled: true, firstCorrectBonusEnabled: true,
firstCorrectBonusPoints: 25, firstCorrectBonusPoints: 25,
timerEnabled: true,
maxPlayers: 10,
}; };
await act(async () => { await act(async () => {

View file

@ -67,6 +67,7 @@ export interface Question {
} }
export interface GameConfig { export interface GameConfig {
timerEnabled: boolean;
shuffleQuestions: boolean; shuffleQuestions: boolean;
shuffleAnswers: boolean; shuffleAnswers: boolean;
hostParticipates: boolean; hostParticipates: boolean;
@ -84,6 +85,7 @@ export interface GameConfig {
} }
export const DEFAULT_GAME_CONFIG: GameConfig = { export const DEFAULT_GAME_CONFIG: GameConfig = {
timerEnabled: true,
shuffleQuestions: false, shuffleQuestions: false,
shuffleAnswers: false, shuffleAnswers: false,
hostParticipates: true, hostParticipates: true,
@ -205,8 +207,10 @@ export type NetworkMessage =
options?: AnswerOption[]; options?: AnswerOption[];
correctShape?: string; correctShape?: string;
timeLeft?: number; timeLeft?: number;
questionTimeLimit?: number;
assignedName?: string; assignedName?: string;
presenterId?: string | null; presenterId?: string | null;
timerEnabled?: boolean;
} } } }
| { type: 'PLAYER_JOINED'; payload: { player: Player } } | { type: 'PLAYER_JOINED'; payload: { player: Player } }
| { type: 'GAME_START'; payload: {} } | { type: 'GAME_START'; payload: {} }
@ -220,6 +224,7 @@ export type NetworkMessage =
correctShape: string; correctShape: string;
questionText: string; questionText: string;
options: AnswerOption[]; options: AnswerOption[];
timerEnabled?: boolean;
} }
} }
| { type: 'ANSWER'; payload: { playerId: string; isCorrect: boolean; selectedShape: 'triangle' | 'diamond' | 'circle' | 'square' } } | { type: 'ANSWER'; payload: { playerId: string; isCorrect: boolean; selectedShape: 'triangle' | 'diamond' | 'circle' | 'square' } }