quizbase
Skip to content
Command Palette
Search for a command to run...
QuizBase · Docs
by Maciej Dzierżek · published May 15, 2026 · 60 min read · Beginner
multiplayer-realtime-quiz hero illustration
Illustration for: Multiplayer realtime quiz with WebSockets tutorial · Generated with Nano Banana, brand style

Multiplayer realtime quiz with WebSockets tutorial#

Two players, same question, first to tap correct wins the round. It’s the trivia-shaped version of every realtime multiplayer minigame that ever shipped — Drawing Together, Skribbl, Codenames Online. The hard part has always been the realtime layer. With QuizBase’s stable question IDs, the game state is one UUID broadcast over WebSocket and the trivia content takes care of itself.

This guide ships a working 1v1 quiz in about 200 lines: a tiny Node WebSocket server (~80 lines) + a React client (~120 lines). Players hit “match” on the landing page, server pairs them, both see the same question, fastest correct gets the point. Best of 7 rounds.

What you’ll build#

A 1v1 realtime quiz with:

  • Match-making lobby — first player to click “Find match” waits; second player joins, game starts
  • Server-coordinated rounds — server fetches GET /api/v1/questions/random, broadcasts the id to both players, each player fetches by ID independently
  • Anti-cheat by design — server never sends correctAnswer to clients; each client checks their own answer against their own fetch
  • Round timer — 8 seconds per question, auto-eliminate slow answers
  • Best-of-7 — first to 4 points wins
  • Reconnect-safe — if a player disconnects mid-game, the other wins by walkover

The mechanic — broadcast ID, fetch by ID, score independently#

This is the core insight that makes the implementation tractable:

  1. Server calls GET /api/v1/questions/random and gets { data: [{ id, ...everything else }], meta }
  2. Server broadcasts only the id to both players
  3. Each player calls GET /api/v1/questions/{id} themselves, gets the full question with correctAnswer server-side hidden from the wire
  4. Each player checks userAnswer === correctAnswer locally and reports { correct: bool, timing: ms } to the server
  5. Server picks the winner (correct first, breaking ties on timing)

The correctAnswer never crosses the server-to-client wire. A cheater would have to MITM their own fetch to quizbase.runriva.com — which they can’t, the answer is whatever QuizBase returns, and tampering with their own request just makes them wrong. Anti-cheat falls out of the data model, no game-server enforcement needed.

This is the same pattern documented at /docs/api/questions-by-id and used in the Discord Activity tutorial (different transport, same pattern).

Stack#

  • Node.js 20+ + ws library — tiny WebSocket server
  • React + Vite — client (any framework works; this is for the snippets)
  • QuizBase publishable key (qb_pk_*) — server uses it; clients call QuizBase directly with their own key (or proxy through server if you want a single key)
  • Hosting — Vercel or Railway for the static client, Railway or Fly.io for the persistent WebSocket server

Step 1 — Set up the WebSocket server#

mkdir multiplayer-quiz && cd multiplayer-quiz
npm init -y
npm install ws
// server.ts
import { WebSocketServer, WebSocket } from 'ws';

const KEY = process.env.QUIZBASE_KEY!;
const port = Number(process.env.PORT ?? 8080);
const wss = new WebSocketServer({ port });

interface Player {
	ws: WebSocket;
	score: number;
}

let waiting: Player | null = null;

async function fetchRandom() {
	const r = await fetch('https://quizbase.runriva.com/api/v1/questions/random', {
		headers: { 'X-API-Key': KEY }
	});
	// API returns an envelope { data: [...], meta } — unwrap the first question
	const body = await r.json();
	return body.data[0];
}

console.log(`Server listening on :${port}`);

Step 2 — Match-making lobby#

wss.on('connection', (ws) => {
	const player: Player = { ws, score: 0 };

	ws.on('message', async (raw) => {
		const msg = JSON.parse(raw.toString());

		if (msg.type === 'find-match') {
			if (waiting) {
				// Pair them up, start the game
				const opponent = waiting;
				waiting = null;
				startGame(player, opponent);
			} else {
				waiting = player;
				ws.send(JSON.stringify({ type: 'waiting' }));
			}
		}
	});

	ws.on('close', () => {
		if (waiting === player) waiting = null;
	});
});

Step 3 — Game loop with broadcast-ID rounds#

async function startGame(p1: Player, p2: Player) {
	const seenIds = new Set<string>();

	while (p1.score < 4 && p2.score < 4) {
		// Fetch unique question
		let q;
		for (let i = 0; i < 3; i++) {
			q = await fetchRandom();
			if (!seenIds.has(q.id)) {
				seenIds.add(q.id);
				break;
			}
		}

		// Broadcast just the id — clients fetch by ID themselves
		const roundStart = Date.now();
		[p1, p2].forEach((p) =>
			p.ws.send(
				JSON.stringify({
					type: 'round',
					questionId: q.id,
					timeoutMs: 8000
				})
			)
		);

		// Wait for answers (or timeout)
		const answers = await collectAnswers(p1, p2, 8000);

		// Score: correct + fastest wins
		const winner = pickWinner(answers, roundStart);
		if (winner === p1) p1.score++;
		else if (winner === p2) p2.score++;

		// Broadcast round result
		[p1, p2].forEach((p) =>
			p.ws.send(
				JSON.stringify({
					type: 'round-result',
					p1Score: p1.score,
					p2Score: p2.score,
					winner: winner === p1 ? 'p1' : winner === p2 ? 'p2' : null
				})
			)
		);
	}

	// Game over
	const winnerName = p1.score >= 4 ? 'p1' : 'p2';
	[p1, p2].forEach((p) => p.ws.send(JSON.stringify({ type: 'game-over', winner: winnerName })));
}

The collectAnswers helper listens for { type: 'answer', correct: bool, ts: number } messages from each player and resolves after both reply or 8s timeout.

Step 4 — Client: fetch by ID, check locally, report#

// client/App.tsx (excerpt)
import { useEffect, useState } from 'react';

const WS_URL = 'wss://your-server.fly.dev';
const KEY = import.meta.env.VITE_QUIZBASE_KEY;

export default function App() {
	const [ws] = useState(() => new WebSocket(WS_URL));
	const [question, setQuestion] = useState<Question | null>(null);
	const [scores, setScores] = useState({ p1: 0, p2: 0 });

	useEffect(() => {
		ws.onmessage = async (e) => {
			const msg = JSON.parse(e.data);
			if (msg.type === 'round') {
				// Server gave us just the ID — we fetch the full question
				const r = await fetch(`https://quizbase.runriva.com/api/v1/questions/${msg.questionId}`, {
					headers: { 'X-API-Key': KEY }
				});
				// by-id returns an envelope { data: {...}, meta } — unwrap the object
				const json = await r.json();
				setQuestion(json.data);
			}
			if (msg.type === 'round-result') {
				setScores({ p1: msg.p1Score, p2: msg.p2Score });
			}
		};
		ws.onopen = () => ws.send(JSON.stringify({ type: 'find-match' }));
	}, [ws]);

	function answer(choice: string) {
		if (!question) return;
		const correct = choice === question.correctAnswer;
		ws.send(JSON.stringify({ type: 'answer', correct, ts: Date.now() }));
		setQuestion(null);
	}

	if (!question) return <p>Score: {scores.p1} – {scores.p2}. Waiting...</p>;

	const choices = [question.correctAnswer, ...question.incorrectAnswers].sort(
		() => Math.random() - 0.5
	);

	return (
		<div>
			<h1>{question.text}</h1>
			{choices.map((c, i) => (
				<button key={i} onClick={() => answer(c)}>
					{'ABCD'[i]}. {c}
				</button>
			))}
			<small>
				Source: {question.attribution.author} ({question.attribution.license})
			</small>
		</div>
	);
}

The <small> attribution is required. QuizBase content ships under CC BY-SA / CC BY / MIT, and surfacing source on every render is a license requirement. See /data.

Step 5 — Deploy#

  • WebSocket server to Fly.io or Railway — both support long-running connections with no per-request timeout (Vercel’s serverless model doesn’t fit WebSocket).
  • React client to Vercel or Cloudflare Pages — static HTML/JS, served from edge.
  • Environment vars: QUIZBASE_KEY on the server (for game-state fetches), VITE_QUIZBASE_KEY on the client build (for by-ID fetches). Same key is fine.

Pitfalls#

  • ws does not retry — if a player’s WebSocket drops mid-game, their WebSocket instance is dead. Implement a heartbeat (server ping every 10s, client pong) and reconnect logic on the client.
  • Both players sharing one publishable key counts as one account’s rate limit — 500 req/day free tier covers ~250 matches × 7 rounds × 2 fetches. For higher concurrent users, upgrade or proxy through your server.
  • seenIds evaporates when the server restarts — for tournaments, persist to Redis. For pickup play (most cases), in-memory is fine.
  • correctAnswer field, not choices — see response format. You must shuffle client-side.

What next#

  • Category-specific lobbies/lobby/science, /lobby/movies, server pre-filters with ?category=
  • Leaderboards — store match results in Postgres, surface ELO-style rating per player
  • Mobile-first UX — React Native or PWA wrapper; the game logic is platform-agnostic
  • Browser PvP variant — see Discord Activity Kahoot-style tutorial for the same pattern inside a Discord voice channel

Ready to wire it? Grab a free publishable key, npm install ws, copy the snippets, ship the server to Fly.io. Bug? POST /api/v1/report accepts feedback by question ID — every round has one, integration is one POST.