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

View file

@ -1,5 +1,5 @@
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';
interface GameConfigPanelProps {
@ -263,6 +263,17 @@ export const GameConfigPanel: React.FC<GameConfigPanelProps> = ({
</div>
</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
icon={<Shuffle size={20} />}
iconActive={config.shuffleQuestions}

View file

@ -2,6 +2,7 @@ import React from 'react';
import { Question, AnswerOption, GameState, GameRole } from '../types';
import { COLORS, SHAPES } from '../constants';
import { motion, AnimatePresence } from 'framer-motion';
import { StopCircle } from 'lucide-react';
interface GameScreenProps {
question?: Question;
@ -14,6 +15,8 @@ interface GameScreenProps {
hasAnswered: boolean;
lastPointsEarned: number | null;
hostPlays?: boolean;
timerEnabled?: boolean;
onEndQuestion?: () => void;
}
export const GameScreen: React.FC<GameScreenProps> = ({
@ -26,13 +29,15 @@ export const GameScreen: React.FC<GameScreenProps> = ({
onAnswer,
hasAnswered,
hostPlays = true,
timerEnabled = true,
onEndQuestion,
}) => {
const isClient = role === 'CLIENT';
const isSpectator = role === 'HOST' && !hostPlays;
const displayOptions = question?.options || [];
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 timerTextColor = isUrgent ? 'text-red-500' : 'text-theme-primary';
const timerAnimation = isUrgent ? 'animate-ping' : '';
@ -45,11 +50,26 @@ export const GameScreen: React.FC<GameScreenProps> = ({
{currentQuestionIndex + 1} / {totalQuestions}
</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={`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`}>
{timeLeftSeconds}
</div>
{timerEnabled ? (
<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`}>
{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 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>
</div>
);
};
};

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

View file

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

View file

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

View file

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

View file

@ -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 () => {

View file

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