Designing and Engineering a Real-Time Multiplayer Game with Next.js, Socket.io, MongoDB, and OpenAI
2025-08-15

I built a scalable real-time multiplayer trivia game where players join lobbies, select topics, vote under a strict server-enforced timer, and compete in timed rounds. Questions (and some images) are generated on the fly with OpenAI, validated with Zod, and streamed to a slick, animated Next.js UI. The build combined WebSocket event design, absolute-deadline timing, schema-first data modeling, and a careful UX pass to keep the pace fast and fair.
What you'll learn:
- How to architect a real-time game with Socket.io and MongoDB
- Why absolute server deadlines beat client-side timers for fairness
- Prompt + schema strategies for AI-generated MCQs (with optional images)
- Practical Zod validation and guardrails (uniqueness, difficulty enums, whitelisted image hosts)
- Making a responsive, animated UI that stays clickable under heavy socket traffic
Framing the Challenge
On the surface: a multiplayer trivia game where players join a lobby, vote on topics, then speed through timed rounds. Under the hood: orchestrating consistent state across many clients, keeping gameplay fair in the face of clock drift and network jitter, and generating accurate, non-duplicative questions fast.
Two non-negotiables shaped the build: (1) a real-time model with server-enforced deadlines so every player sees the same state and timer, and (2) an AI pipeline that outputs hard, accurate, image-capable MCQs, validated before anything hits the UI.
Core Architecture
The backend handles HTTP for lobby CRUD and Socket.io for the live game loop. MongoDB stores a canonical record of lobbies, players, topics, and generated questions. Socket events move the game through phases while the UI renders a smooth, low-jank experience.
Technology Stack:
- Next.js (App Router) for the frontend, static/SSR pages, and API routes
- Socket.io for real-time, bidirectional events
- Node.js + MongoDB for persistent lobby, player, vote, and question data
- OpenAI (GPT-4o/4o-mini) for on-demand MCQ generation
- Zod for strict validation and normalization
- Tailwind CSS + Framer Motion for responsive, animated UI
1interface Lobby {
2 code: string;
3 name: string;
4 players: { name: string; team: "A" | "B" | null; socketId?: string }[];
5 status: "waiting" | "topic-selection" | "voting" | "playing" | "ended" | "cancelled";
6 topics: string[];
7 votes: { voterName: string; topic: string }[];
8 questions: Question[];
9 mode: "1v1" | "2v2" | "3v3";
10 createdAt: Date;
11}
12
13interface Question {
14 text: string;
15 topic: string;
16 difficulty: "easy" | "medium" | "hard";
17 options: [string, string, string, string];
18 correctIndex: 0 | 1 | 2 | 3;
19 correctAnswer: string;
20 imageUrl?: string | null; // optional
21 imageAlt?: string | null; // optional
22 turnTeam: "A" | "B";
23}Joining and Synchronizing Lobbies
When a player joins, the server validates the lobby code, registers the player, and broadcasts the updated lobby state. Phase changes only move forward (never backward) to avoid UI regressions on reconnect or out-of-order messages.
1socket.on("join-lobby", async ({ lobbyCode, name }, ack) => {
2 const code = lobbyCode.toUpperCase();
3 const lobby = await LobbyModel.findOne({ code });
4 if (!lobby || lobby.status !== "waiting") {
5 return ack?.({ ok: false, reason: "not_joinable" });
6 }
7 const players = lobby.players as Player[];
8 const existing = players.find(p => p.name === name);
9
10 if (!existing) {
11 players.push({ name, socketId: socket.id, team: null, selectedTopics: [] });
12 } else {
13 existing.socketId = socket.id; // reattach
14 }
15
16 await lobby.save();
17 socket.join(code);
18 io.to(code).emit("lobby-update", buildUiState(lobby));
19 ack?.({ ok: true, state: buildUiState(lobby) });
20});Topic Selection & Voting (with Absolute Deadlines)
Topic selection is per-player. Once everyone submits, we open a voting window with a strict server-enforced deadline. Instead of streaming a ticking number every second, the server emits a single `deadlineTs` and `serverNow`. The client calculates remaining time locally, smooth and resilient even if a tick is dropped.
When the deadline passes, the server resolves the vote and advances the phase. This keeps fairness deterministic and avoids drift from client clocks.
1// server: start voting (absolute deadline)
2const VOTE_WINDOW_SEC = 120;
3const serverNow = Date.now();
4const deadlineTs = serverNow + VOTE_WINDOW_SEC * 1000;
5
6io.to(code).emit("voting-started", {
7 topics: lobby.topics,
8 deadlineTs,
9 serverNow,
10});
11
12// client: compute time via useCountdown(deadlineTs, skewMs)
13const skewMs = useRef(0); // serverNow - Date.now()
14socket.on("voting-started", ({ deadlineTs, serverNow }) => {
15 skewMs.current = serverNow - Date.now();
16 setVoteDeadline(deadlineTs);
17});AI-Generated Questions with Images (OpenAI + Zod)
Questions are generated on demand using OpenAI. To guarantee shape and quality, I use structured outputs with a JSON schema and validate again with Zod. I constrain `difficulty_code` to an English enum, enforce exactly four options, ensure no duplicate options per MCQ, and allow optional images (e.g., flags, landmarks) only from whitelisted CDNs.
If the model can’t match the schema on the first pass, a small 'repair' step attempts to fix minor formatting issues, but Zod remains the final gatekeeper. Any failure results in a retry or a player-facing fallback.
Guardrails implemented:
- Structured outputs (`response_format: json_schema`) with strict=true
- Zod schema validation (+ refinements for URLs and uniqueness)
- Whitelisted image hosts (e.g., upload.wikimedia.org, flagcdn.com)
- Difficulty enums and localized labels
- Per-question uniqueness (no duplicate options), and per-batch de-duplication
1// openai: ask for EXACTLY N MCQs with optional images
2const response_format = {
3 type: "json_schema" as const,
4 json_schema: {
5 name: "mcq_batch",
6 strict: true,
7 schema: {
8 type: "object",
9 additionalProperties: false,
10 properties: {
11 questions: {
12 type: "array",
13 minItems: needed,
14 maxItems: needed,
15 items: {
16 type: "object",
17 additionalProperties: false,
18 properties: {
19 topic: { type: "string", minLength: 1 },
20 difficulty_code: { type: "string", enum: ["easy","medium","hard"] },
21 difficulty_label: { type: "string", minLength: 1 },
22 text: { type: "string", minLength: 1 },
23 options: {
24 type: "array",
25 minItems: 4, maxItems: 4,
26 items: { type: "string", minLength: 1 }
27 },
28 correctIndex: { type: "integer", minimum: 0, maximum: 3 },
29 imageUrl: { anyOf: [{ type: "string", pattern: "^https://.+" }, { type: "null" }] },
30 imageAlt: { anyOf: [{ type: "string", minLength: 1 }, { type: "null" }] }
31 },
32 required: ["topic","difficulty_code","difficulty_label","text","options","correctIndex","imageUrl","imageAlt"]
33 }
34 }
35 },
36 required: ["questions"]
37 }
38 }
39};
40
41// zod: final validation + refinements
42const ALLOWED_HOSTS = new Set(["upload.wikimedia.org","flagcdn.com"]);
43const IMG_EXT = /\.(svg|png|jpg|jpeg|webp)$/i;
44
45const Batch = z.object({
46 questions: z.array(z.object({
47 topic: z.string().min(1),
48 difficulty_code: z.enum(["easy","medium","hard"]),
49 difficulty_label: z.string().min(1),
50 text: z.string().min(1),
51 options: z.array(z.string().min(1)).length(4)
52 .refine(arr => new Set(arr.map(s => s.trim().toLowerCase())).size === 4, "Options must be unique"),
53 correctIndex: z.number().int().min(0).max(3),
54 imageUrl: z.string().url().nullable().optional().refine(v => {
55 if (!v) return true;
56 try {
57 const u = new URL(v);
58 return u.protocol === "https:" && ALLOWED_HOSTS.has(u.hostname) && IMG_EXT.test(u.pathname);
59 } catch { return false; }
60 }, "imageUrl must be a direct https file on a trusted host"),
61 imageAlt: z.string().min(1).nullable().optional(),
62 })).length(needed)
63});Real-Time Game Loop (Absolute Deadlines + ACK Safety)
Each question is broadcast once with `deadlineTs` and `serverNow`. The client renders a local countdown and locks in answers with a guarded click path. A short ACK-safety timer prevents the 'stuck button' problem if a callback packet is dropped.
Scoring is deterministic on the server. Clients only render results.
1// server: emit question with absolute deadline
2const seconds = 30;
3const serverNow = Date.now();
4const deadlineTs = serverNow + seconds * 1000;
5
6io.to(code).emit("next-question", {
7 index: qIndex,
8 total: lobby.questions.length,
9 turnTeam,
10 question: { text: q.text, options: q.options, topic: q.topic, difficulty: q.difficulty, imageUrl: q.imageUrl ?? null, imageAlt: q.imageAlt ?? null },
11 timeLimit: seconds,
12 serverNow,
13 deadlineTs
14});
15
16// client: enable answer only if (myTeam turn) && (Date.now()+skew < deadline) && (!lastResult)
17const canClick = myTurn && (Date.now() + skewMs.current < qDeadlineRef.current!) && !answering && selectedIdx === null && !lastResult;
18if (canClick) {
19 setAnswering(true);
20 const safety = window.setTimeout(() => { if (!lastResultRef.current) { setAnswering(false); setSelectedIdx(null); } }, 1500);
21 socket.emit("answer-select", { lobbyCode, optionIndex: i }, (resp) => {
22 window.clearTimeout(safety);
23 if (!resp?.ok) { setAnswering(false); setSelectedIdx(null); }
24 });
25}Data Modeling & Persistence
I modeled lobbies and questions for clarity and future features. Questions persist with their canonical difficulty code and a localized label. Optional image fields are normalized to null when absent.
1const QuestionSchema = new Schema({
2 text: { type: String, required: true },
3 topic: { type: String, required: true },
4 difficulty: { type: String, enum: ["easy","medium","hard"], required: true },
5 difficultyLabel: { type: String, required: true },
6 options: {
7 type: [String],
8 validate: { validator: (arr: string[]) => Array.isArray(arr) && arr.length === 4, message: "Exactly 4 options are required" },
9 required: true
10 },
11 correctIndex: { type: Number, min: 0, max: 3, required: true },
12 correctAnswer: { type: String, required: true },
13 imageUrl: { type: String, default: null },
14 imageAlt: { type: String, default: null },
15 turnTeam: { type: String, enum: ["A","B"], required: true }
16});Keeping the UI Snappy Under Socket Load
I eliminated per-second UI re-renders by sending absolute deadlines instead of 'tick' events. The countdown is computed locally with a tiny hook, and components are memoized to avoid unnecessary work.
Another subtle fix: disabling buttons based on derived conditions only (turn, deadline, result), and adding an ACK timeout so they never stay disabled if a packet is lost.
1// useCountdown(deadlineTs, skewMs, stepMs): returns remaining whole seconds
2export default function useCountdown(deadlineTs: number | null, skewMs: number, stepMs = 250) {
3 const [remaining, setRemaining] = useState(() => compute());
4 function compute() {
5 if (!deadlineTs) return 0;
6 const ms = Math.max(0, deadlineTs - (Date.now() + skewMs));
7 return Math.ceil(ms / 1000);
8 }
9 useEffect(() => {
10 if (!deadlineTs) return;
11 const id = setInterval(() => setRemaining(compute()), stepMs);
12 return () => clearInterval(id);
13 }, [deadlineTs, skewMs, stepMs]);
14 return remaining;
15}Anti-Abuse & API Hygiene
What I added:
- reCAPTCHA on lobby creation to reduce spam
- Basic profanity filter for names and passcodes
- CORS restricted to the known frontend origin
- Next.js API routes marked no-store / dynamic to avoid caching issues
1// Next.js route: disable caching for lobby creation
2export const dynamic = "force-dynamic";
3export const revalidate = 0;
4
5export async function POST(req: NextRequest) {
6 const res = await fetch(`${API_BASE}/api/lobbies`, { method: "POST", body: await req.text(), cache: "no-store" });
7 return new NextResponse(await res.text(), { status: res.status, headers: { "Cache-Control": "no-store" }});
8}Example Architecture Diagram
A high-level view of client, server, sockets, OpenAI, and MongoDB in the flow. The crucial arrows: server-enforced deadlines to clients; OpenAI generation into a Zod gate; and canonical state stored in MongoDB.

You can check The Graph Here as well:
Lessons Learned
Engineering Takeaways:
- Absolute server deadlines + local countdowns keep UIs smooth and fair
- Schema-first AI: structured outputs + Zod removes guesswork
- Guardrails (uniqueness, whitelisted images) reduce bad outputs dramatically
- Avoid per-second socket spam; send immutable facts and let the client derive
- Small UX details (ACK safety, memoized components) make the game feel polished
Conclusion
Combining Next.js, Socket.io, MongoDB, and OpenAI let me build a trivia game that feels instant, fair, and challenging. The same architecture, server-enforced timing, schema-validated AI, and event-driven UIs, applies to collaborative editors, dashboards, and any product where ‘real-time’ really matters.
If you’re building something similar, start with your invariants (fairness, accuracy, responsiveness), codify them in your protocols and schemas, and let everything else flow from there.