quizbase
Skip to content
Command Palette
Search for a command to run...
QuizBase · Docs
by Maciej Dzierżek · published May 15, 2026 · 25 min read · Beginner
time-pressure-quiz hero illustration
Illustration for: Build a time-pressure trivia quiz in JavaScript · Generated with Nano Banana, brand style

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/random on 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 dedupseenIds: Set capped 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)
  • requestAnimationFrame for 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 requestAnimationFrame to ~1fps in most browsers. The countdown bar visibly freezes, but Date.now() is correct, so on tab-refocus the bar jumps to current elapsed. For a polished UX, pause the game when document.hidden === true and resume on visibilitychange.
  • fetchUniqueQuestion could hit 429 rate limit on chained losses (3 wrong → game-over → restart → another fetch). Free tier 500/day is generous, but throttle visually if you see it.
  • correctAnswer and incorrectAnswers are separate fields — no pre-combined choices array. See response format. You shuffle client-side.
  • Streak persistence requires localStorage — without it, refreshing the page resets your best. Implement: save bestStreak to localStorage.setItem('time-pressure-best', String(bestStreak)) on each update.

What next#

  • Persistent best across sessionslocalStorage.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=trivial or easy, after a streak of 5 switch to medium, after 10 → hard, after 15 → expert. The 5-level LLM-calibrated ladder gives smoother progression than OpenTDB’s 3 levels — trivial to warm up, expert for the final stretch.
  • Category packs — let the player pick a category (?category=science-and-nature vs ?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.