Build a time-pressure trivia quiz in JavaScript#
A five-second timer per question is the smallest mechanic that turns a casual quiz into “one more round”. The pattern is everywhere — Kahoot, HQ Trivia, Slither.io’s last-second moves — because it changes the cognitive load from “what do I know” to “what do I know right now”. Two minutes per session, then the user closes the tab feeling slightly out of breath. That’s retention.
This guide builds it in about 100 lines of React + plain JS: countdown bar, auto-submit on timeout, score streak, three-wrong-in-a-row resets your run. The trivia content comes from QuizBase (one fetch per question, no batching needed for a casual mechanic). About 25 minutes end-to-end.
What you’ll build#
A single-page React app with:
- One question at a time — fetched from
GET /api/v1/questions/randomon mount + after each answer - 5-second countdown bar above the question, visible at all times
- Auto-submit on timeout — counts as wrong, the streak takes the hit
- Streak counter — visible at the top, +1 on correct, resets on 3 wrong in a row
- No-repeat dedup —
seenIds: Setcapped FIFO at 50 entries (same pattern as the Cursor pomodoro extension) - Game-over screen — when streak resets to 0 after starting at 5+, show “Best run: N. Try again.”
The mechanic — seenIds + animationFrame timer#
The seenIds Set deduplicates questions client-side so your 5-minute session doesn’t repeat. The timer uses requestAnimationFrame (smooth 60fps countdown bar) plus an absolute deadline (startedAt + 5000ms) — never tick math that drifts. See /docs/api/questions-by-id for the catalogue of stable-ID client-side mechanics.
const SEEN_LIMIT = 50;
const seenIds = new Set<string>();
async function fetchUnique(retries = 3) {
for (let i = 0; i < retries; i++) {
const q = await fetchRandomQuestion();
if (!seenIds.has(q.id)) {
seenIds.add(q.id);
if (seenIds.size > SEEN_LIMIT) {
const first = seenIds.values().next().value;
if (first !== undefined) seenIds.delete(first);
}
return q;
}
}
throw new Error('No fresh question after 3 retries');
} The seenIds cap matters because long sessions could grow the Set unboundedly. FIFO eviction after 50 entries means after ~50 questions, the oldest answers become eligible again — by then the user has forgotten anyway.
Stack#
- React + Vite (or vanilla JS, this is for clarity)
requestAnimationFramefor the countdown bar (no third-party timer library)- QuizBase publishable key (
qb_pk_*) — free tier 500 req/day = ~100 sessions × 5 questions
Step 1 — Get a QuizBase key and scaffold#
Create a qb_pk_* key. Free tier covers a hobby game; if you reach the daily limit (you’ll know — 429 from the API), upgrade via pricing.
npm create vite@latest time-pressure-quiz -- --template react-ts
cd time-pressure-quiz Step 2 — Fetch + dedup helper#
// src/api.ts
const KEY = import.meta.env.VITE_QUIZBASE_KEY;
export interface Question {
id: string;
text: string;
correctAnswer: string;
incorrectAnswers: string[];
attribution: { author: string; license: string; licenseUrl: string };
}
const seenIds = new Set<string>();
const SEEN_LIMIT = 50;
export async function fetchUniqueQuestion(retries = 3): Promise<Question> {
for (let i = 0; i < retries; i++) {
const r = await fetch('https://quizbase.runriva.com/api/v1/questions/random', {
headers: { 'X-API-Key': KEY }
});
const { data } = (await r.json()) as { data: Question[] };
const q = data[0];
if (q && !seenIds.has(q.id)) {
seenIds.add(q.id);
if (seenIds.size > SEEN_LIMIT) {
const oldest = seenIds.values().next().value;
if (oldest !== undefined) seenIds.delete(oldest);
}
return q;
}
}
throw new Error('No fresh question after 3 retries');
} Step 3 — Countdown bar with absolute deadline#
// src/Countdown.tsx
import { useEffect, useRef, useState } from 'react';
export function Countdown({ durationMs, onTimeout }: { durationMs: number; onTimeout: () => void }) {
const [remaining, setRemaining] = useState(durationMs);
const startedAt = useRef(Date.now());
useEffect(() => {
startedAt.current = Date.now();
let raf: number;
const tick = () => {
const elapsed = Date.now() - startedAt.current;
const rem = Math.max(0, durationMs - elapsed);
setRemaining(rem);
if (rem > 0) raf = requestAnimationFrame(tick);
else onTimeout();
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [durationMs, onTimeout]);
const pct = (remaining / durationMs) * 100;
const color = pct > 50 ? '#4caf50' : pct > 20 ? '#ff9800' : '#f44336';
return (
<div style={{ height: 8, background: '#eee', borderRadius: 4, overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: color, transition: 'width 16ms linear' }} />
</div>
);
} The absolute-deadline approach (startedAt + durationMs) avoids drift if the browser tab is throttled — setInterval based timers slow when the tab is backgrounded, but Date.now() is unaffected. The countdown bar always reflects real elapsed time.
Step 4 — Streak game state with three-wrong reset#
// src/App.tsx
import { useEffect, useState } from 'react';
import { fetchUniqueQuestion, type Question } from './api';
import { Countdown } from './Countdown';
const ROUND_MS = 5000;
const LOSE_AFTER_WRONGS = 3;
export default function App() {
const [question, setQuestion] = useState<Question | null>(null);
const [streak, setStreak] = useState(0);
const [wrongsInRow, setWrongsInRow] = useState(0);
const [bestStreak, setBestStreak] = useState(0);
const [gameOver, setGameOver] = useState(false);
useEffect(() => {
void loadNext();
}, []);
async function loadNext() {
setQuestion(null);
const q = await fetchUniqueQuestion();
setQuestion(q);
}
function handleAnswer(choice: string | null) {
if (!question) return;
const correct = choice === question.correctAnswer;
if (correct) {
setStreak((s) => {
const next = s + 1;
if (next > bestStreak) setBestStreak(next);
return next;
});
setWrongsInRow(0);
} else {
setWrongsInRow((w) => {
const next = w + 1;
if (next >= LOSE_AFTER_WRONGS) {
setGameOver(true);
}
return next;
});
setStreak(0);
}
if (!gameOver) void loadNext();
}
if (gameOver) {
return (
<div>
<h2>Game over</h2>
<p>Best streak this session: {bestStreak}</p>
<button onClick={() => { setStreak(0); setWrongsInRow(0); setBestStreak(bestStreak); setGameOver(false); loadNext(); }}>
Try again
</button>
</div>
);
}
if (!question) return <p>Loading...</p>;
const choices = [question.correctAnswer, ...question.incorrectAnswers].sort(
() => Math.random() - 0.5
);
return (
<div>
<div style={{ fontSize: 18 }}>Streak: {streak} (best: {bestStreak})</div>
<Countdown
key={question.id}
durationMs={ROUND_MS}
onTimeout={() => handleAnswer(null)}
/>
<h2>{question.text}</h2>
{choices.map((c, i) => (
<button key={i} onClick={() => handleAnswer(c)} style={{ display: 'block', margin: 4 }}>
{'ABCD'[i]}. {c}
</button>
))}
<small>
Source: {question.attribution.author} ({question.attribution.license})
</small>
</div>
);
} The <Countdown key={question.id} ...> is intentional — re-keying on each new question forces a full unmount+remount of the timer component, so the deadline resets cleanly. Without the key, React would re-use the same instance and the timer state would carry over (subtle bug).
The <small>Source: ... line at the bottom is required. QuizBase content ships under CC BY-SA / CC BY / MIT and surfacing source is a license requirement. See /data for full rules.
Step 5 — Set up env and run#
# .env
VITE_QUIZBASE_KEY=qb_pk_... npm run dev Open localhost:5173, race the timer, lose three in a row, start over. Two minutes of play per session, then the user closes the tab feeling slightly out of breath. That’s the mechanic.
Pitfalls#
- Background tabs throttle
requestAnimationFrameto ~1fps in most browsers. The countdown bar visibly freezes, butDate.now()is correct, so on tab-refocus the bar jumps to current elapsed. For a polished UX, pause the game whendocument.hidden === trueand resume onvisibilitychange. fetchUniqueQuestioncould hit429rate limit on chained losses (3 wrong → game-over → restart → another fetch). Free tier 500/day is generous, but throttle visually if you see it.correctAnswerandincorrectAnswersare separate fields — no pre-combinedchoicesarray. See response format. You shuffle client-side.- Streak persistence requires localStorage — without it, refreshing the page resets your best. Implement: save
bestStreaktolocalStorage.setItem('time-pressure-best', String(bestStreak))on each update.
What next#
- Persistent best across sessions —
localStorage.setItem('time-pressure-best', ...), read on mount, surface “Today’s best: N | All-time best: M”. - Server-tick variant — for leaderboard-grade competitive play, run the timer on a small backend WebSocket so clients can’t pause. See Multiplayer realtime quiz for the WebSocket pattern (different game state, same approach).
- Difficulty scaling — start with
?difficulty=trivialoreasy, after a streak of 5 switch tomedium, after 10 →hard, after 15 →expert. The 5-level LLM-calibrated ladder gives smoother progression than OpenTDB’s 3 levels —trivialto warm up,expertfor the final stretch. - Category packs — let the player pick a category (
?category=science-and-naturevs?category=film) on the start screen.
Ready to ship? Grab a free publishable key, npm create vite, copy the snippets, play your way to a 20-streak before lunch. Bug in a question? Each round has a stable id — wire a “report this question is wrong” button calling POST /api/v1/report.