When you put live trivia on a stream, the hard part is not writing questions. It is keeping every screen agreeing on what is happening right now. The approach I landed on with Quizo was to make the overlay a thin renderer and give one server-owned state machine the only opinion about the round. Everything downstream just draws what it is told.
The problem with many screens
A live quiz is never one display. There is the on-stream overlay the audience sees, a session control page the streamer keeps open to read ahead, a QA preview, and sometimes a backup machine. If each browser computes its own view of the round, they drift. One screen thinks the answer window has five seconds left while another already shows results. For a solo streamer running a fast round in front of chat, even half a second of disagreement looks broken.
Rather than chase drift with patches, I wanted a design where disagreement could not really be represented in the first place. So I moved every decision about the round off the browser and onto the server.
One authoritative state machine
The Fastify server owns a QuizEngineSession modeled as an explicit state machine. A round walks a fixed set of statuses, and the type itself is the contract:
type SessionStatus =
| 'idle' | 'answering' | 'results'
| 'leaderboard' | 'countdown' | 'finished' | 'paused';
Each status has exactly one private transition method: transitionToResults(), transitionToLeaderboard(), transitionToCountdownOrIdle(), and so on. Those methods are the only place the round can change. A browser cannot advance a phase, score an answer, or run a clock. When a question's answer window expires, a server-side setTimeout fires the transition; the round drives itself.
Scoring lives here too, not in the view. When the answer window closes, the engine pulls the correct answers ordered by timestamp, fastest first, and awards points by rank using a small fixed table: [3, 2, 1, 0, 0]. So the top three correct answerers get three, two, and one point. Ties are broken purely by arrival time, which keeps the rule easy to explain on stream.
A full snapshot, not a diff
From that machine the server builds a single OverlayState object that describes exactly what should be on screen, then pushes the whole thing to every connected client over WebSockets. I deliberately send a complete snapshot on every change rather than a delta. Deltas are smaller, but they force every client to maintain its own copy and apply patches in order, which is exactly the drift I was trying to avoid. A fresh full state is idempotent: applying it twice, or applying it after missing one, lands you in the same place.
interface OverlayState {
status: SessionStatus;
sessionUuid: string;
currentQuestion: Question | null;
questionIndex: number;
totalQuestions: number; // -1 means unlimited mode
answerCounts: { A: number; B: number; C: number; D: number };
timeRemaining: number;
phaseEndsAt?: number; // ms-since-epoch deadline
correctAnswer?: 'A' | 'B' | 'C' | 'D';
topAnswerers?: TopAnswerer[];
leaderboard?: LeaderboardEntry[];
}
The engine exposes a small listener API. The WebSocket layer subscribes with onStateChange, and on every transition the engine calls each listener with a freshly built state. The client side is almost embarrassingly small: an AnimatePresence block keyed on status that maps each phase to a panel and lets Framer Motion handle the cross-fade. The browser renders, the server decides. That is the whole agreement.
The timer that skipped: an OBS gotcha
The most interesting bug was not in the architecture, it was in where the overlay runs. OBS embeds Chromium as a browser source, and to save resources it aggressively throttles background timers and can starve a tab that is not visibly painting. A naive countdown that re-renders a number every second through React would tick fine in a normal browser tab and then visibly skip, say 8 then 5 then 2, inside OBS.
Two changes fixed it, and both lean on the same idea: do not trust the client's wall clock or React's scheduler to keep time. First, every state carries phaseEndsAt, an absolute deadline in milliseconds since epoch. The client never counts down on its own; it computes remaining time from that deadline, so a missed tick self-corrects on the next frame.
// remaining seconds, derived from the server's deadline
const calcRemaining = () =>
Math.max(0, Math.ceil((phaseEndsAt - Date.now()) / 1000));
Second, the visible countdown bypasses React entirely. A small hook updates the DOM node's textContent directly on a 100ms interval, and a continuously animating Framer Motion progress bar keeps the renderer awake so OBS does not park the tab.
useEffect(() => {
const el = ref.current;
if (!el || !phaseEndsAt) return;
el.textContent = format(calcRemaining());
const id = setInterval(() => {
el.textContent = format(calcRemaining());
}, 100);
return () => clearInterval(id);
}, [phaseEndsAt, ref]);
It feels slightly heretical to reach past React and poke the DOM by hand. But the deadline math is the real timekeeper, so the only job left is painting frequently enough that OBS keeps the tab alive. Direct text updates plus an always-moving bar did exactly that.
Why a thin renderer helped
- No drift. Two screens cannot disagree about a value neither one owns. They both display the last snapshot the server sent.
- New surfaces are nearly free. The control page and the QA preview consume the same
OverlayStateas the overlay, so adding a display meant subscribing to the socket, not reimplementing logic. - One place to test. Because phase and scoring logic live in the engine and not the view, the interesting behavior sits in a server module I can drive directly in unit tests, with no browser in the loop.
The visual layer never has to reason about timing or scoring. It just animates whatever phase the server says it is in, and counts down to a deadline the server set.
Zero-friction sessions, and surviving a drop
The model only works if a streamer can get going without a fight. Quizo addresses each session by a UUID URL with no login. You create a session, get a unique link, and drop it into OBS as a browser source. No account, no password, no ceremony.
But streaming is hostile to long-lived connections. Browser sources reload, machines hiccup, and even the deploy process restarts the server mid-round. Two mechanisms keep that from showing on stream. The WebSocket layer runs a ping/pong heartbeat: every 30 seconds it pings each client, and a socket that misses its pong gets terminated and cleaned up so dead listeners do not pile up. On the client, onclose schedules a reconnect after two seconds, and the very first thing the server sends a fresh connection is the current full state. A reconnect is just another client asking for the latest snapshot; nothing is reconstructed from guesswork.
Server restarts are the harder case, and this is where persistence earns its keep. Session status, current question index, and crucially phase_ends_at are written to SQLite on every transition. When the engine loads a session back into memory after a restart, it compares that stored deadline to the current time:
if (deadline > now) {
// still time left: resume the timer with the remainder
resumeTimer(Math.ceil((deadline - now) / 1000));
} else {
// the window elapsed while we were down: catch up now
handleExpiredDeadline();
}
If the answer window was still open, it resumes with the correct remaining seconds. If the window quietly expired while the process was down, the engine immediately runs the transition that should have already happened, so the round catches up instead of freezing on a stale question. Persisting the authoritative state, not the client view, is what makes reconnects and restarts boring.
What I learned
- If multiple clients must agree, give one of them ownership and let the rest render. Disagreement you cannot represent is disagreement you never have to debug.
- Broadcasting a full, idempotent snapshot beats diffing when correctness matters more than bandwidth, especially across flaky connections.
- Send a deadline, not a countdown. Let each client derive remaining time, so a missed tick self-heals instead of accumulating into a visible skip.
- Know where your code actually runs. The OBS browser source throttles timers, and the fix had nothing to do with my architecture and everything to do with its rendering environment.
- Persist the source of truth, not the view. A stored deadline turned both reconnects and server restarts into routine catch-up rather than recovery puzzles.