Build a Multiplayer Trivia Game
Build a real-time multiplayer trivia game with WebSockets, timed rounds, and live scores.
Introduction
30 Second Summary
You know that rush when a trivia question pops up and you slam the answer before your friend? Now imagine building the technology that makes that real-time race possible.
In this project, you will build a multiplayer trivia game that runs in the browser, where players race against a ticking clock to answer questions and see scores update live.
What You'll Build
Open two browser tabs side by side, type in player names, and watch both players receive the same question at the exact same moment. A 10-second countdown ticks down, scores flash on screen as answers land, and a winner is crowned at the end.
By the end of this project, you'll have:
- A server-authoritative game loop where the server owns the clock, pushes questions to all players simultaneously, and auto-advances rounds when time expires.
- A live WebSocket scoreboard powered by Socket.io that updates for every player the instant someone answers correctly.
- Trivia questions and game results persisted in PostgreSQL via Prisma ORM, so scores survive a server restart and you can query past games.
- Secret Mission: Add a room system with shareable join codes so multiple independent games can run at the same time.
Are there any prerequisites?
You should have Node.js installed and be comfortable with basic TypeScript, Express, and SQL concepts. No prior WebSocket or Socket.io experience is needed.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
You're building a real-time multiplayer trivia game where the server owns the clock, validates every answer, and pushes live updates to all players simultaneously.
Install PostgreSQL and Initialize the Project
A multiplayer trivia game needs somewhere to store its questions and player scores. It also needs a structured project so you can write server logic in TypeScript.
In this step, you will install PostgreSQL as your database, set up a Node.js TypeScript project, and configure Prisma as your ORM to talk to the database.
In this step, get ready to:
- Install PostgreSQL and create a trivia game database.
- Initialize a TypeScript project with Express, Socket.io, and Prisma.
- Define your database models, run a migration, and seed 10 trivia questions.
Install PostgreSQL and create the database
PostgreSQL is a production-grade relational database. You will install it locally via apt and create a dedicated user and database for the trivia game.
- Open your terminal in VS Code by pressing Ctrl+` (backtick key).
- Check if PostgreSQL is already installed by running:
psql --version
✔️ I see a version number
PostgreSQL is already installed. Confirm the service is running:
sudo systemctl status postgresql
You should see active (running) in the output. If it shows inactive, start it with sudo systemctl start postgresql.
ⓧ Command not found
- Install PostgreSQL by running:
sudo apt install postgresql
The installer automatically starts the PostgreSQL service. Confirm it is running:
sudo systemctl status postgresql
You should see active (running) in the output.
Now create a dedicated database user and database for the trivia game. Run these commands one at a time:
- Create the trivia_user database user by running:
sudo -u postgres psql -c "CREATE USER trivia_user WITH PASSWORD 'trivia_pass';"
- Create the trivia_game database owned by that user by running:
sudo -u postgres psql -c "CREATE DATABASE trivia_game OWNER trivia_user;"
- Verify the connection works by running:
PGPASSWORD=trivia_pass psql -U trivia_user -d trivia_game -h localhost -c "SELECT 'Connection OK' AS status;"
You should see a table with Connection OK printed in your terminal.
What do these commands do?
- sudo -u postgres psql runs a SQL command as the built-in postgres superuser that was created during installation.
- CREATE USER makes a new database login. CREATE DATABASE ... OWNER makes a database that the new user fully controls.
- The -h localhost flag forces a TCP connection, which uses password authentication instead of OS-level peer auth.
Seeing a connection refused error?
- Make sure the PostgreSQL service is running: sudo systemctl start postgresql.
- If you see "role trivia_user already exists", that means the user was already created. Skip that command and continue.
Help me troubleshoot my PostgreSQL connection error.
Initialize the Node.js TypeScript project
With your database ready, you need a project folder to hold your server code and dependencies.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir trivia-game
cd trivia-game
- Open this folder in VS Code by running:
code .
What does code . do?
The code . command opens the current directory in VS Code. If a new window opens, switch to it and open its integrated terminal with Ctrl+`.
- Create the package.json file that defines your project. In VS Code, click File then New File.
- Save it as package.json and paste the following content:
{
"name": "trivia-game",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"seed": "tsx prisma/seed.ts"
}
}
What does this file do?
- "type": "module" enables ESM imports (import/export syntax) which Prisma 7 requires.
- The dev script uses tsx to run TypeScript directly with hot reload. No separate compile step needed during development.
- Save package.json.
- Install the runtime dependencies by running this in your terminal:
npm install express@5.2.1 socket.io@4.8.3 @prisma/client@7.8.0 @prisma/adapter-pg@7.8.0 pg@8.13.3 dotenv@16.4.7
You should see output listing the added packages and a node_modules folder appears in your project.
- Install the development dependencies by running:
npm install --save-dev prisma@7.8.0 tsx@4.19.3 typescript@5.7.3 @types/express@5.0.6 @types/node@22.15.0 @types/pg@8.11.11
Runtime vs dev dependencies?
Runtime dependencies (Express, Socket.io, Prisma client) ship with your final app. Dev dependencies (TypeScript compiler, type definitions, tsx) are only needed while you write code.
- Create a tsconfig.json file in your project root with the TypeScript compiler settings:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src/**/*"]
}
What does tsconfig.json configure?
- "module": "ESNext" matches the "type": "module" setting in package.json for consistent ESM handling.
- "rootDir": "src" tells TypeScript your source lives in src/ and "outDir": "dist" puts compiled JavaScript in dist/.
- Save tsconfig.json.
- Confirm your project structure by running:
ls
You should see node_modules, package.json, package-lock.json, and tsconfig.json listed.
Missing node_modules?
- Make sure you are inside the trivia-game folder when you run npm install. Run pwd to check your current directory.
- If package.json is missing, the install will fail. Confirm it exists before retrying.
Help me fix my npm install issue.
Configure Prisma and seed the database
Prisma is an ORM that lets you define your database tables as TypeScript models. You will create the configuration files, define the Question and GameResult tables, migrate the database, and load trivia questions.
- Create a .env file in your project root with the database connection string:
DATABASE_URL="postgresql://trivia_user:trivia_pass@localhost:5432/trivia_game"
- Create a prisma.config.ts file in your project root. This tells Prisma where to find your schema and database:
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations"
},
datasource: {
url: env("DATABASE_URL")
}
});
What is prisma.config.ts?
Prisma 7 requires this configuration file. It reads the DATABASE_URL from .env and passes it to the Prisma CLI for migrations and queries. Without it, Prisma commands will fail.
- Save both files.
- Verify the files exist by running:
ls -a
You should see .env and prisma.config.ts in the listing (-a shows hidden dotfiles).
- Create a prisma folder by running:
mkdir prisma
- Create a schema.prisma file inside the prisma folder with your database models:
generator client {
provider = "prisma-client"
output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
}
model Question {
id Int @id @default(autoincrement())
text String
options String[]
correctIndex Int
createdAt DateTime @default(now())
}
model GameResult {
id Int @id @default(autoincrement())
playerName String
score Int
totalTime Int
playedAt DateTime @default(now())
}
What do these models define?
- Question stores each trivia question with its answer choices and the index of the correct one.
- GameResult records each player's final score after a game ends.
- provider = "prisma-client" is the Prisma 7 generator. The output path tells Prisma where to write the generated TypeScript client.
- Save prisma/schema.prisma.
Before you run the migration, what do you think will happen? The command should read your schema, compare it to the empty database, and create the tables.
- Apply the migration by running:
npx prisma migrate dev --name init
You should see "Your database is now in sync with your schema" in the output.
- Prisma 7 no longer auto-generates the client after migrating. Generate it now by running:
npx prisma generate
This creates the TypeScript client in generated/prisma/ that your server code will import.
Migration failed?
- If you see a connection error, double-check that your .env file has the correct password (trivia_pass) and that PostgreSQL is running.
- If Prisma cannot find prisma.config.ts, make sure the file is in your project root (the same level as package.json).
- If you see a schema parse error, check that prisma/schema.prisma matches the code above exactly.
Help me debug my Prisma migration error.
Now load your trivia questions into the database. The seed file creates 10 questions the game will use.
- Create prisma/seed.ts with the imports and question data:
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../generated/prisma/client.js";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
const prisma = new PrismaClient({ adapter });
const questions = [
{ text: "What planet is known as the Red Planet?", options: ["Venus", "Mars", "Jupiter", "Saturn"], correctIndex: 1 },
{ text: "What is the largest ocean on Earth?", options: ["Atlantic", "Indian", "Arctic", "Pacific"], correctIndex: 3 },
{ text: "Who painted the Mona Lisa?", options: ["Michelangelo", "Da Vinci", "Raphael", "Donatello"], correctIndex: 1 },
{ text: "What is the chemical symbol for gold?", options: ["Go", "Gd", "Au", "Ag"], correctIndex: 2 },
{ text: "Which country has the most time zones?", options: ["Russia", "USA", "France", "China"], correctIndex: 2 },
{ text: "What is the smallest prime number?", options: ["0", "1", "2", "3"], correctIndex: 2 },
{ text: "What year did the Berlin Wall fall?", options: ["1987", "1989", "1991", "1993"], correctIndex: 1 },
{ text: "What is the hardest natural substance?", options: ["Gold", "Iron", "Diamond", "Platinum"], correctIndex: 2 },
{ text: "Which element has the atomic number 1?", options: ["Helium", "Hydrogen", "Lithium", "Carbon"], correctIndex: 1 },
{ text: "What is the capital of Australia?", options: ["Sydney", "Melbourne", "Canberra", "Perth"], correctIndex: 2 },
];
- Below the questions array in the same file, add the main function that inserts the data:
async function main() {
await prisma.question.deleteMany();
for (const q of questions) {
await prisma.question.create({ data: q });
}
console.log("Seeded 10 questions");
}
main()
.catch(console.error)
.finally(() => process.exit());
What does the seed script do?
- deleteMany() clears existing questions so you can re-run the seed without duplicates.
- Each question object has the question text, four answer options, and the zero-based index of the correct answer.
- The Prisma client uses the @prisma/adapter-pg driver adapter, which is required in Prisma 7 for all PostgreSQL connections.
- Save prisma/seed.ts.
- Run the seed script by executing:
npx tsx prisma/seed.ts
You should see Seeded 10 questions printed in your terminal. Your database now has 10 trivia questions ready for the game.
Seeing a module not found error?
- If the error mentions ../generated/prisma/client.js, you need to run npx prisma generate first. This creates the client code that the seed script imports.
- If you see a database connection error, confirm PostgreSQL is running with sudo systemctl status postgresql.
Help me fix my seed script error.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files and that your terminal shows the Seeded 10 questions confirmation.
ⓧ I'd like to double check the full code
Here are the files you should have at this point. Compare each one to your project:
.env:
DATABASE_URL="postgresql://trivia_user:trivia_pass@localhost:5432/trivia_game"
package.json (your dependencies section is filled in by npm):
{
"name": "trivia-game",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@prisma/adapter-pg": "7.8.0",
"@prisma/client": "7.8.0",
"dotenv": "16.4.7",
"express": "5.2.1",
"pg": "8.13.3",
"socket.io": "4.8.3"
},
"devDependencies": {
"@types/express": "5.0.6",
"@types/node": "22.15.0",
"@types/pg": "8.11.11",
"prisma": "7.8.0",
"tsx": "4.19.3",
"typescript": "5.7.3"
}
}
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src/**/*"]
}
prisma.config.ts:
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations"
},
datasource: {
url: env("DATABASE_URL")
}
});
prisma/schema.prisma:
generator client {
provider = "prisma-client"
output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
}
model Question {
id Int @id @default(autoincrement())
text String
options String[]
correctIndex Int
createdAt DateTime @default(now())
}
model GameResult {
id Int @id @default(autoincrement())
playerName String
score Int
totalTime Int
playedAt DateTime @default(now())
}
prisma/seed.ts:
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../generated/prisma/client.js";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
const prisma = new PrismaClient({ adapter });
const questions = [
{ text: "What planet is known as the Red Planet?", options: ["Venus", "Mars", "Jupiter", "Saturn"], correctIndex: 1 },
{ text: "What is the largest ocean on Earth?", options: ["Atlantic", "Indian", "Arctic", "Pacific"], correctIndex: 3 },
{ text: "Who painted the Mona Lisa?", options: ["Michelangelo", "Da Vinci", "Raphael", "Donatello"], correctIndex: 1 },
{ text: "What is the chemical symbol for gold?", options: ["Go", "Gd", "Au", "Ag"], correctIndex: 2 },
{ text: "Which country has the most time zones?", options: ["Russia", "USA", "France", "China"], correctIndex: 2 },
{ text: "What is the smallest prime number?", options: ["0", "1", "2", "3"], correctIndex: 2 },
{ text: "What year did the Berlin Wall fall?", options: ["1987", "1989", "1991", "1993"], correctIndex: 1 },
{ text: "What is the hardest natural substance?", options: ["Gold", "Iron", "Diamond", "Platinum"], correctIndex: 2 },
{ text: "Which element has the atomic number 1?", options: ["Helium", "Hydrogen", "Lithium", "Carbon"], correctIndex: 1 },
{ text: "What is the capital of Australia?", options: ["Sydney", "Melbourne", "Canberra", "Perth"], correctIndex: 2 },
];
async function main() {
await prisma.question.deleteMany();
for (const q of questions) {
await prisma.question.create({ data: q });
}
console.log("Seeded 10 questions");
}
main()
.catch(console.error)
.finally(() => process.exit());
Your database is set up, your project is initialized, and you have 10 trivia questions ready to go. Next up, you will build the Express server, wire up Socket.io, and serve the game page.
Build the Express Server and Game UI
Your database is seeded with 10 trivia questions and all your dependencies are installed. Now you need a running server that serves a web page and establishes WebSocket connections between the server and browser.
In this step, you will create an Express server with Socket.io attached, build the full HTML/CSS game interface, and wire up the client-side connection so the server knows when a player opens the page.
In this step, get ready to:
- Create the Express + Socket.io server that serves a static HTML game page.
- Build the HTML/CSS game UI with join, lobby, question, and results screens.
- Wire up the Socket.io client connection and confirm the handshake works.
Create the Express + Socket.io server
The server needs three pieces: a database client module, a stub game manager class, and the main server file that ties everything together.
- Create a file called src/db.ts and add this database client module:
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../generated/prisma/client.js";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
export const prisma = new PrismaClient({ adapter });
What does this code do?
This creates a shared Prisma client using the @prisma/adapter-pg driver adapter. Prisma 7 requires a driver adapter to connect to the database. Every file that needs database access will import prisma from this module.
- Create a file called src/game.ts with this stub game manager class:
import { Server, Socket } from "socket.io";
import { PrismaClient } from "../generated/prisma/client.js";
// Stub class - we'll implement game logic in the next step
export class GameManager {
private io: Server;
private prisma: PrismaClient;
constructor(io: Server, prisma: PrismaClient) {
this.io = io;
this.prisma = prisma;
}
addPlayer(socket: Socket, name: string) {}
removePlayer(socket: Socket) {}
playerReady(socket: Socket) {}
handleAnswer(socket: Socket, answerIndex: number) {}
reset(socket: Socket) {}
}
Why is this class empty?
The server file needs to import GameManager to compile. You are creating a stub with the method signatures so the server starts cleanly. You will fill in the game logic in Step 3.
- Confirm both files exist by running:
ls src/
You should see db.ts and game.ts listed.
Now for the main server file. It creates an Express app, wraps it in an HTTP server, attaches Socket.io, and serves static files from a public/ folder.
- Create src/server.ts and add the imports and server setup:
import express from "express";
import { createServer } from "node:http";
import { Server } from "socket.io";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { prisma } from "./db.js";
import { GameManager } from "./game.js";
// Derive __dirname for ESM modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Create Express app, wrap in HTTP server, attach Socket.io
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer);
// Serve static files from the public/ folder
app.use(express.static(join(__dirname, "../public")));
const game = new GameManager(io, prisma);
Why wrap Express in createServer?
Socket.io needs access to the raw HTTP server object. Calling app.listen() directly creates a hidden server that Socket.io cannot attach to. By creating the HTTP server explicitly with createServer(app), you can pass it to both Socket.io and call httpServer.listen() yourself.
- Add the connection handler and server start below the code you just wrote:
// Handle new WebSocket connections
io.on("connection", (socket) => {
console.log(`a user connected: ${socket.id}`);
socket.on("join", (playerName: string) => {
game.addPlayer(socket, playerName);
});
socket.on("ready", () => {
game.playerReady(socket);
});
socket.on("answer", (answerIndex: number) => {
game.handleAnswer(socket, answerIndex);
});
socket.on("play_again", () => {
game.reset(socket);
});
socket.on("disconnect", () => {
game.removePlayer(socket);
console.log(`user disconnected: ${socket.id}`);
});
});
const PORT = 3000;
httpServer.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
What does this code do?
Every time a browser connects via WebSocket, Socket.io fires the connection event. Inside that callback, you register listeners for each game action the client can send: joining, readying up, answering, playing again, and disconnecting.
Each listener delegates to the GameManager instance. Right now those methods are empty stubs, but the server will start and log connections.
- Save src/server.ts.
- Start the development server by running:
npm run dev
You should see Server running at http://localhost:3000 in the terminal. The server is now running and watching for file changes.
Seeing a module not found error?
Make sure src/db.ts and src/game.ts both exist. The server imports from both files and will crash if either is missing.
Also check that your .env file is in the project root (not inside src/). The dotenv import in db.ts reads from the working directory.
Help me debug my server startup error.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all three files in src/.
ⓧ I'd like to double check the full code
Here is src/db.ts in full:
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../generated/prisma/client.js";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
export const prisma = new PrismaClient({ adapter });
Here is src/game.ts in full:
import { Server, Socket } from "socket.io";
import { PrismaClient } from "../generated/prisma/client.js";
export class GameManager {
private io: Server;
private prisma: PrismaClient;
constructor(io: Server, prisma: PrismaClient) {
this.io = io;
this.prisma = prisma;
}
addPlayer(socket: Socket, name: string) {}
removePlayer(socket: Socket) {}
playerReady(socket: Socket) {}
handleAnswer(socket: Socket, answerIndex: number) {}
reset(socket: Socket) {}
}
For src/server.ts, scroll up and confirm both chunks are present in order: the imports/setup block, then the connection handler and httpServer.listen call.
Build the HTML/CSS game UI
The server is running, but opening http://localhost:3000 shows nothing because there are no files in public/ yet. You will build the full game interface with four screens: join, lobby, game, and results.
- Create the public folder by running:
mkdir public
- Create public/index.html and add the document head and join screen:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Trivia Game</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<section id="join-screen" class="screen active">
<h1>Trivia Game</h1>
<input type="text" id="name-input" placeholder="Enter your name" maxlength="20">
<button id="join-btn">Join Game</button>
</section>
- Add the lobby, game, and result screens below, then close the document:
<section id="lobby-screen" class="screen">
<h2>Lobby</h2>
<ul id="player-list"></ul>
<button id="ready-btn">Ready</button>
<p id="lobby-status">Waiting for players...</p>
</section>
<section id="game-screen" class="screen">
<div id="game-header">
<span id="question-counter">1/10</span>
<span id="timer">10</span>
</div>
<h2 id="question-text"></h2>
<div id="options"></div>
<div id="feedback"></div>
<div id="scoreboard-mini"></div>
</section>
<section id="result-screen" class="screen">
<h2 id="winner-text"></h2>
<ol id="final-scores"></ol>
<button id="play-again-btn">Play Again</button>
</section>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="client.js"></script>
</body>
</html>
What does this HTML structure do?
The page has four <section> elements, each representing a game screen. Only the one with class active is visible at a time. The client JavaScript will swap which section is active as the game progresses.
The /socket.io/socket.io.js script is auto-served by Socket.io. You do not need to install a separate client package.
- Save public/index.html.
- Open http://localhost:3000 in your browser.
You should see the unstyled join screen with a text input and a "Join Game" button. It looks plain because there is no CSS yet.
Now add the stylesheet. The CSS uses a dark color scheme and a screen-toggle pattern where only the .screen.active section is visible.
- Create public/style.css and add the base layout and typography:
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
#app { width: 100%; max-width: 600px; padding: 2rem; }
.screen { display: none; }
.screen.active { display: block; }
h1 { font-size: 2.5rem; margin-bottom: 1.5rem; text-align: center; color: #e94560; }
h2 { font-size: 1.5rem; margin-bottom: 1rem; }
- Add the form elements and game component styles below:
input {
width: 100%; padding: 0.75rem; font-size: 1rem;
border: 2px solid #333; border-radius: 8px;
background: #16213e; color: #eee; margin-bottom: 1rem;
}
button {
width: 100%; padding: 0.75rem; font-size: 1.1rem;
border: none; border-radius: 8px; cursor: pointer;
background: #e94560; color: white; font-weight: bold;
transition: background 0.2s;
}
button:hover { background: #c23152; }
button:disabled { background: #555; cursor: not-allowed; }
#game-header {
display: flex; justify-content: space-between;
margin-bottom: 1rem; font-size: 1.2rem;
}
#timer {
background: #e94560; padding: 0.25rem 0.75rem;
border-radius: 50%; font-weight: bold;
}
#options { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin: 1.5rem 0; }
.option-btn {
padding: 1rem; font-size: 1rem; border-radius: 8px;
background: #16213e; border: 2px solid #333;
color: #eee; cursor: pointer; transition: all 0.2s;
}
.option-btn:hover { border-color: #e94560; }
.option-btn.correct { background: #2d6a4f; border-color: #40916c; }
.option-btn.wrong { background: #6a2d2d; border-color: #916040; }
.option-btn:disabled { cursor: not-allowed; opacity: 0.7; }
- Save public/style.css and refresh your browser.
You should see a dark-themed page with a red "Trivia Game" heading, a styled input field, and a bold red "Join Game" button.
- Add the remaining utility styles at the bottom of public/style.css:
#feedback { text-align: center; font-size: 1.2rem; min-height: 2rem; margin-bottom: 1rem; }
#player-list { list-style: none; margin: 1rem 0; }
#player-list li { padding: 0.5rem; background: #16213e; margin-bottom: 0.5rem; border-radius: 4px; }
#scoreboard-mini { margin-top: 1rem; font-size: 0.9rem; color: #aaa; }
#final-scores { margin: 1.5rem 0; padding-left: 1.5rem; }
#final-scores li { padding: 0.5rem 0; font-size: 1.1rem; }
#winner-text { text-align: center; color: #e94560; font-size: 2rem; margin-bottom: 1rem; }
#lobby-status { text-align: center; color: #aaa; margin-top: 1rem; }
- Save public/style.css.
Page still looks unstyled?
Check that your index.html has <link rel="stylesheet" href="style.css"> in the <head>. Also confirm the CSS file is named exactly style.css (not styles.css) inside the public/ folder.
Help me figure out why my CSS isn't loading.
✔️ Awesome, I've got everything!
Great. Confirm you have saved both public/index.html and public/style.css.
ⓧ I'd like to double check the full code
For public/index.html, scroll up and confirm both chunks are present in order: the doctype through the join-screen section, then the lobby/game/result screens through the closing </html> tag.
For public/style.css, confirm all three chunks are present in order: base layout/typography, then form elements/game components, then the utility styles ending with #lobby-status.
Wire up the Socket.io client connection
The page loads but nothing interactive happens yet. You need client-side JavaScript that connects to the Socket.io server and wires up all the UI buttons and event handlers.
- Create public/client.js and add the Socket.io connection, screen switcher, and DOM references:
// Connect to the Socket.io server
const socket = io();
// Map screen names to their DOM elements
const screens = {
join: document.getElementById("join-screen"),
lobby: document.getElementById("lobby-screen"),
game: document.getElementById("game-screen"),
result: document.getElementById("result-screen")
};
// Toggle which screen is visible
function showScreen(name) {
Object.values(screens).forEach((s) => s.classList.remove("active"));
screens[name].classList.add("active");
}
// Cache all DOM references
const nameInput = document.getElementById("name-input");
const joinBtn = document.getElementById("join-btn");
const readyBtn = document.getElementById("ready-btn");
const playerList = document.getElementById("player-list");
const lobbyStatus = document.getElementById("lobby-status");
const questionCounter = document.getElementById("question-counter");
const timerEl = document.getElementById("timer");
const questionText = document.getElementById("question-text");
const optionsEl = document.getElementById("options");
const feedback = document.getElementById("feedback");
const scoreboardMini = document.getElementById("scoreboard-mini");
const winnerText = document.getElementById("winner-text");
const finalScores = document.getElementById("final-scores");
const playAgainBtn = document.getElementById("play-again-btn");
let timerInterval = null;
let answered = false;
- Add the button click handlers and lobby/game-start event listeners below:
// Join button: emit player name to server
joinBtn.addEventListener("click", () => {
const name = nameInput.value.trim();
if (!name) return;
socket.emit("join", name);
showScreen("lobby");
});
// Ready button: tell server this player is ready
readyBtn.addEventListener("click", () => {
socket.emit("ready");
readyBtn.disabled = true;
readyBtn.textContent = "Waiting...";
});
// Play Again button: reset and return to lobby
playAgainBtn.addEventListener("click", () => {
socket.emit("play_again");
readyBtn.disabled = false;
readyBtn.textContent = "Ready";
});
// Server sends updated lobby state
socket.on("lobby", ({ players, phase }) => {
if (phase === "lobby") showScreen("lobby");
playerList.innerHTML = players.map((p) => `<li>${p.name} ${p.ready ? "\u2713" : ""}</li>`).join("");
lobbyStatus.textContent = players.length < 2 ? "Need at least 2 players..." : "Click Ready to start!";
});
// Server signals game has started
socket.on("game_start", () => {
showScreen("game");
feedback.textContent = "";
});
What does this code do?
The button handlers emit events to the server when the player clicks Join, Ready, or Play Again. The socket.on listeners react to events the server pushes back: updating the lobby player list or switching to the game screen.
- Save public/client.js and refresh your browser.
- Check your server terminal.
You should see a user connected: followed by a socket ID in the terminal. This confirms the WebSocket handshake is working.
- Now add the question display handler that renders each trivia question:
// Server sends a new question
socket.on("question", ({ index, total, text, options, duration }) => {
answered = false;
questionCounter.textContent = `${index + 1}/${total}`;
questionText.textContent = text;
feedback.textContent = "";
optionsEl.innerHTML = options.map((opt, i) => `<button class="option-btn" data-index="${i}">${opt}</button>`).join("");
document.querySelectorAll(".option-btn").forEach((btn) => {
btn.addEventListener("click", () => {
if (answered) return;
answered = true;
const idx = parseInt(btn.dataset.index);
socket.emit("answer", idx);
document.querySelectorAll(".option-btn").forEach((b) => (b.disabled = true));
});
});
// Start the client-side countdown display
let timeLeft = duration / 1000;
timerEl.textContent = timeLeft;
clearInterval(timerInterval);
timerInterval = setInterval(() => {
timeLeft--;
timerEl.textContent = Math.max(0, timeLeft);
if (timeLeft <= 0) clearInterval(timerInterval);
}, 1000);
});
- Add the remaining event handlers for answer results, time-up, scoring, and game-over:
// Server tells us if our answer was correct
socket.on("answer_result", ({ correct, correctIndex }) => {
feedback.textContent = correct ? "Correct!" : "Wrong!";
feedback.style.color = correct ? "#40916c" : "#e94560";
document.querySelectorAll(".option-btn").forEach((btn, i) => {
if (i === correctIndex) btn.classList.add("correct");
else if (btn.disabled && !correct && i === parseInt(btn.dataset.index)) btn.classList.add("wrong");
});
});
// Server says time is up for this round
socket.on("time_up", ({ correctIndex }) => {
clearInterval(timerInterval);
if (!answered) { feedback.textContent = "Time's up!"; feedback.style.color = "#e94560"; }
document.querySelectorAll(".option-btn").forEach((btn) => {
btn.disabled = true;
if (parseInt(btn.dataset.index) === correctIndex) btn.classList.add("correct");
});
});
// Player submitted after the round closed
socket.on("too_late", () => { feedback.textContent = "Too late!"; feedback.style.color = "#e94560"; });
// Live scoreboard update
socket.on("scoreboard", (scores) => {
scoreboardMini.innerHTML = scores.map((s) => `${s.name}: ${s.score}`).join(" | ");
});
// Game is over - show final results
socket.on("game_over", ({ scoreboard }) => {
showScreen("result");
winnerText.textContent = `${scoreboard[0].name} wins!`;
finalScores.innerHTML = scoreboard.map((s) => `<li>${s.name} - ${s.score} pts</li>`).join("");
});
What do these handlers do?
Each socket.on handler responds to a specific server event. answer_result highlights the correct answer green. time_up stops the countdown and reveals the answer. game_over switches to the results screen with the winner.
These handlers are ready and waiting. They will fire once you implement the game logic in the next step.
- Save public/client.js.
- Refresh your browser at http://localhost:3000.
Before you test, what do you think will happen when you type a name and click "Join Game"?
- Type any name into the input field and click Join Game.
The screen switches to the lobby view showing your name in the player list. The lobby status reads "Need at least 2 players..." because the game manager methods are still empty stubs. The connection is live and working.
Join button does nothing?
Open your browser's developer console (F12 or right-click and select **Inspect**, then click the **Console** tab). Look for JavaScript errors. A common issue is a typo in an element ID that causes getElementById to return null.
Also check that the <script src="client.js"></script> tag is at the bottom of your HTML body, after all the section elements.
Help me debug why clicking Join Game does nothing.
✔️ Awesome, I've got everything!
Great. Confirm you have saved public/client.js with all four chunks in order.
ⓧ I'd like to double check the full code
Scroll up through public/client.js and confirm all four sections are present in order:
- Socket connection, screen map, showScreen function, and DOM references.
- Button click handlers (joinBtn, readyBtn, playAgainBtn) plus lobby and game_start listeners.
- The question handler with option buttons and countdown timer.
- Remaining handlers: answer_result, time_up, too_late, scoreboard, game_over.
Your server is live, the UI is styled, and the WebSocket connection is confirmed. Next up, you will implement the game loop so players can actually receive questions and submit answers.
Implement the Core Game Loop
You have a running server, a complete client UI, and a database full of trivia questions. The missing piece is the brain of the game: the GameManager class that orchestrates everything from player joins to final scores.
Right now src/game.ts is a stub with no real logic. By the end of this step, it will manage players readying up, deal out timed questions, validate answers with a speed bonus, reject late submissions, and persist final scores to PostgreSQL.
In this step, get ready to:
- Build the GameManager class with player tracking, a timed question loop, and server-side answer validation.
- Test the full game flow by playing through 10 questions in two browser tabs.
- Confirm game results are persisted to the database.
Define the GameManager types and class foundation
The game manager needs to track players, questions, scores, and timing state. You will define TypeScript interfaces for this data and then set up the class with all its properties.
- Open src/game.ts in VS Code.
- Select all existing content and delete it.
- Paste the following imports and type definitions at the top of the file:
import { Server, Socket } from "socket.io";
import { PrismaClient } from "../generated/prisma/client.js";
// Shape of a connected player
interface Player {
id: string;
name: string;
score: number;
ready: boolean;
}
// Shape of a trivia question from the database
interface Question {
id: number;
text: string;
options: string[];
correctIndex: number;
}
type GamePhase = "lobby" | "playing" | "reveal" | "finished";
- Directly below the type alias, add the class declaration with its properties and constructor:
export class GameManager {
private io: Server;
private prisma: PrismaClient;
private players: Map<string, Player> = new Map();
private questions: Question[] = [];
private currentIndex = 0;
private phase: GamePhase = "lobby";
private roundTimer: ReturnType<typeof setTimeout> | null = null;
private roundClosed = false;
private roundStartTime = 0;
private readonly ROUND_DURATION = 10000;
private readonly REVEAL_DURATION = 3000;
constructor(io: Server, prisma: PrismaClient) {
this.io = io;
this.prisma = prisma;
}
- Continue inside the class body by adding the player management and ready-up methods:
addPlayer(socket: Socket, name: string) {
this.players.set(socket.id, { id: socket.id, name, score: 0, ready: false });
this.broadcastLobby();
}
removePlayer(socket: Socket) {
this.players.delete(socket.id);
if (this.players.size === 0) {
this.clearTimer();
this.phase = "lobby";
this.currentIndex = 0;
}
this.broadcastLobby();
}
playerReady(socket: Socket) {
const player = this.players.get(socket.id);
if (!player) return;
player.ready = true;
this.broadcastLobby();
const allReady = [...this.players.values()].every((p) => p.ready);
if (allReady && this.players.size >= 2 && this.phase === "lobby") {
this.startGame();
}
}
What does this code do?
The Player interface tracks each connected user's socket ID, name, score, and ready state. GamePhase constrains the game to four states so methods can guard against out-of-sequence actions.
The class properties store all game state in memory. ROUND_DURATION (10 seconds) is how long each question lasts. REVEAL_DURATION (3 seconds) is the pause between questions to show the correct answer.
The playerReady method checks whether ALL players are ready AND at least 2 are connected before starting the game. This prevents a solo player from launching a game alone.
Implement the timed question loop and answer scoring
The game loop loads questions from the database, emits them one at a time, starts a server-side timer for each round, and scores answers with a speed bonus.
- Continue in src/game.ts by adding the startGame method below playerReady:
async startGame() {
// Load 10 questions from PostgreSQL
const dbQuestions = await this.prisma.question.findMany({ take: 10 });
this.questions = dbQuestions.map((q) => ({
id: q.id,
text: q.text,
options: q.options,
correctIndex: q.correctIndex
}));
this.currentIndex = 0;
this.phase = "playing";
for (const p of this.players.values()) { p.score = 0; }
this.io.emit("game_start", { totalQuestions: this.questions.length });
this.sendQuestion();
}
- Add the three methods that drive the timed question cycle below startGame:
private sendQuestion() {
const q = this.questions[this.currentIndex];
this.roundClosed = false;
this.roundStartTime = Date.now();
this.io.emit("question", {
index: this.currentIndex,
total: this.questions.length,
text: q.text,
options: q.options,
duration: this.ROUND_DURATION
});
// Start the server-side countdown
this.roundTimer = setTimeout(() => this.timeUp(), this.ROUND_DURATION);
}
private timeUp() {
this.roundClosed = true;
const q = this.questions[this.currentIndex];
this.io.emit("time_up", { correctIndex: q.correctIndex });
// Brief reveal pause before next question
setTimeout(() => this.advanceRound(), this.REVEAL_DURATION);
}
private advanceRound() {
this.currentIndex++;
if (this.currentIndex >= this.questions.length) {
this.endGame();
} else {
this.sendQuestion();
}
}
- Add the handleAnswer method that validates submissions and awards points:
handleAnswer(socket: Socket, answerIndex: number) {
if (this.phase !== "playing") return;
// Reject answers after the round timer expired
if (this.roundClosed) { socket.emit("too_late"); return; }
const player = this.players.get(socket.id);
if (!player) return;
const q = this.questions[this.currentIndex];
const correct = answerIndex === q.correctIndex;
const elapsed = Date.now() - this.roundStartTime;
if (correct) {
// Faster answers earn a higher time bonus
const timeBonus = Math.max(0, Math.round((this.ROUND_DURATION - elapsed) / 100));
player.score += 10 + timeBonus;
}
socket.emit("answer_result", { correct, correctIndex: q.correctIndex });
this.io.emit("scoreboard", this.getScoreboard());
}
How does the timer enforcement work?
The server is authoritative over time. When sendQuestion fires, it starts a setTimeout for 10 seconds. When that timer fires, roundClosed flips to true.
Any answer arriving after roundClosed is true gets rejected with a too_late event. This means a player cannot cheat by manipulating their local clock. The server decides when time is up.
Correct answers earn 10 base points plus a time bonus. The faster you answer, the higher the bonus (up to 100 extra points if you answer instantly).
Complete the class with persistence and helper methods
- Add the endGame and reset methods below handleAnswer:
private async endGame() {
this.phase = "finished";
this.clearTimer();
const scoreboard = this.getScoreboard();
this.io.emit("game_over", { scoreboard });
// Persist each player's result to PostgreSQL
for (const player of this.players.values()) {
await this.prisma.gameResult.create({
data: { playerName: player.name, score: player.score, totalTime: 0 }
});
}
}
reset(socket: Socket) {
this.phase = "lobby";
this.currentIndex = 0;
this.clearTimer();
for (const p of this.players.values()) { p.score = 0; p.ready = false; }
this.broadcastLobby();
}
- Finish the class by adding the utility methods and the closing brace:
private broadcastLobby() {
const players = [...this.players.values()].map((p) => ({ name: p.name, ready: p.ready }));
this.io.emit("lobby", { players, phase: this.phase });
}
private getScoreboard() {
return [...this.players.values()]
.map((p) => ({ name: p.name, score: p.score }))
.sort((a, b) => b.score - a.score);
}
private clearTimer() {
if (this.roundTimer) { clearTimeout(this.roundTimer); this.roundTimer = null; }
}
}
What do these helper methods do?
The endGame method writes a GameResult row for each player using Prisma. This means game history survives server restarts.
The broadcastLobby method pushes the current player list and game phase to all connected clients. The getScoreboard method sorts players by score (highest first) for real-time leaderboard updates.
- Save src/game.ts.
The tsx watch process automatically restarts the server. Check the terminal where your dev server is running. You should see Server running at http://localhost:3000 printed again with no errors.
Seeing a runtime error in the terminal?
- Check that you pasted all seven code sections in order. A missing closing brace } at the end of the file is the most common cause.
- Confirm no duplicate method names. If you accidentally pasted a section twice, the server will crash.
- Make sure the file starts with the import statements and ends with a single } closing the class.
Help me debug my game.ts file
Before you test the game, predict: when two players both click Ready, what do you expect to happen first on their screens?
- Open http://localhost:3000 in two separate browser tabs.
- In tab 1, type a name and click Join Game.
- In tab 2, type a different name and click Join Game.
- In both tabs, click Ready.
- Answer the questions in both tabs. Try letting the timer expire on one question to see the auto-advance.
Both tabs switch to the game screen simultaneously. A 10-second countdown ticks down for each question. If you answer correctly, you see "Correct!" and the scoreboard updates live. If you wait too long, the server emits "Time's up!" and advances to the next question automatically. After all 10 questions, both tabs show the final winner and scores.
What about the server terminal?
Check your server terminal. You should see the two "a user connected" messages from when the tabs loaded. The server silently handles all game logic in memory, only writing to the database when the game ends.
Game not starting when both players click Ready?
- Both players must click Ready after joining. If one tab shows "Need at least 2 players..." check that both tabs completed the join step.
- Make sure your database has questions. Run npm run seed if you see an error about empty questions in the terminal.
- Confirm PostgreSQL is running. Run sudo systemctl status postgresql to verify it is active.
Help me troubleshoot my trivia game not starting
✔️ Awesome, I've got everything!
Great. Double check you have saved src/game.ts and the game played through all 10 questions.
ⓧ I'd like to double check the full code
Your src/game.ts file exceeds 30 lines, so confirm each section is present in this order:
- Imports from socket.io and ../generated/prisma/client.js.
- The Player interface, Question interface, and GamePhase type.
- The GameManager class with 11 private properties and the constructor.
- The addPlayer, removePlayer, and playerReady methods.
- The startGame method that loads questions from the database.
- The sendQuestion, timeUp, and advanceRound loop.
- The handleAnswer method with the roundClosed guard and time bonus calculation.
- The endGame and reset methods.
- The broadcastLobby, getScoreboard, and clearTimer helpers, followed by the class closing brace.
The game loop is fully wired. Both players see questions simultaneously, the server enforces the clock, and final results are saved. Next up, you will polish the experience with a ready-up flow, answer reveals, and a play-again button.
Polish the Game Flow and End Screen
Your timed trivia game works end-to-end. Two players can join, answer questions under time pressure, and see final scores.
Now it is time to consciously test the polish that turns a tech demo into something you would actually show someone. A proper ready-up flow, feedback between questions, and a satisfying end screen all exist in your code already. This step is about verifying that each polish feature works as intended.
In this step, get ready to:
- Verify the pre-game ready-up flow prevents the game from starting until all players are prepared.
- Observe the 3-second reveal pause that shows the correct answer between questions.
- Confirm the end-of-game screen announces a winner and the Play Again button resets the game.
Test the pre-game ready-up flow
Without a ready-up gate, the game would start the instant someone joins. Players need a moment to see who is in the lobby before committing.
- Open two browser tabs to http://localhost:3000.
- In Tab 1, type a name (e.g. Alice) and click Join Game.
- Look at the lobby status text at the bottom of the screen.
You should see "Need at least 2 players..." because only one player has joined so far.
- In Tab 2, type a different name (e.g. Bob) and click Join Game.
- Check the lobby status in both tabs.
Both tabs now show "Click Ready to start!" and you can see both player names listed.
- Click Ready in Tab 1 only.
- Observe that the game does NOT start yet. Tab 1's button changes to "Waiting..." and a checkmark appears next to Alice's name in both tabs.
- Click Ready in Tab 2.
The game starts immediately in both tabs. Both players see the first question and a 10-second countdown.
Why require all players to ready up?
The playerReady method on the server checks two conditions: every player's ready flag is true, AND at least 2 players have joined. This prevents a solo player from accidentally starting a game with no opponents.
The server broadcasts the lobby state after each player joins or readies up. Every client sees the same player list in real time, so nobody is surprised when the game kicks off.
Observe the reveal pause between questions
Jumping instantly from one question to the next would feel rushed. Players need a beat to see what the correct answer was and check the standings.
- Answer the current question in one of your tabs by clicking an option.
- Wait for the 10-second timer to expire (or answer in both tabs).
- Watch what happens when time runs out.
You should see the correct answer highlighted in green for about 3 seconds. The mini scoreboard at the bottom updates with current scores. Then the next question appears automatically.
How does the 3-second pause work?
In src/game.ts, the timeUp method emits the time_up event (which reveals the correct answer to all clients), then waits REVEAL_DURATION (3000 ms) before calling advanceRound. The server owns this pause, not the client.
This means all players transition to the next question at exactly the same moment, regardless of network latency.
- Continue playing through the remaining questions. Try answering quickly in one tab and letting time expire in the other.
Verify the end screen and Play Again
After 10 questions, the game needs a satisfying finish. Players should see who won, how everyone scored, and have a clear path to play again.
- After answering all 10 questions, look at both browser tabs.
Before you see the result, predict: which player do you think scored higher? The player who answered faster gets a time bonus on top of the base 10 points per correct answer.
Both tabs now show the result screen with a winner banner displaying the top scorer's name, a sorted scoreboard with point totals, and a Play Again button.
What happens when the game ends?
The endGame method does three things: sets the phase to finished, emits the final scoreboard to all players, and persists each player's result to the GameResult table in PostgreSQL via Prisma.
The client reads scoreboard[0].name to populate the winner text. Since the server sorts by score descending, the first entry is always the winner.
- Click Play Again in either tab.
- Observe that BOTH tabs return to the lobby screen with the Ready button re-enabled.
The lobby shows both players again with their ready status cleared. You can click Ready in both tabs and start a fresh game.
How does Play Again work across all clients?
When any player emits play_again, the server's reset method resets the phase to lobby, clears all scores, sets every player's ready flag to false, and broadcasts the updated lobby to everyone.
This is server-authoritative design in action. One player triggers the reset, but the server decides what that means for ALL connected clients.
Game not resetting properly?
If clicking Play Again does not return both tabs to the lobby, check that your dev server restarted after the latest code changes. Press Ctrl+C in the terminal and run npm run dev again.
If only one tab resets, try refreshing the stuck tab. The Socket.io connection may have dropped during the game.
help me debug why Play Again is not resetting both browser tabs to the lobby
Your trivia game now has the full polished experience. Players join a lobby, ready up together, race through timed questions with reveal pauses, see a winner announced, and can restart instantly.
Secret mission
Add Multiplayer Rooms with Join Codes
Right now, every player who connects shares the same game. What if two groups of friends want to play at the same time? In this secret mission, you will add a room system using Socket.io rooms so multiple independent games can run simultaneously with shareable 4-character join codes.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with PostgreSQL, so there are no ongoing cloud costs.
Resources you used:
- PostgreSQL database trivia_game running locally.
- PostgreSQL user trivia_user with password.
- Project folder trivia-game on your Desktop with all source files and node modules.
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the game further.
- PostgreSQL continues running in the background via systemd. It uses negligible CPU and memory when idle.
- Your project folder stays on the Desktop, ready to pick up where you left off.
- If the dev server is still running in your terminal, you can stop it with Ctrl+C when you are done playing.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and database stay intact for when you return.
- Stop the dev server by pressing Ctrl+C in the terminal where it is running.
- Stop the PostgreSQL service by running:
sudo systemctl stop postgresql
- When you are ready to come back, restart PostgreSQL by running:
sudo systemctl start postgresql
- Then start your dev server again from the trivia-game folder with npm run dev.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your database, database user, and project files permanently.
- Stop the dev server by pressing Ctrl+C if it is still running.
- Drop the database and user by running:
sudo -u postgres dropdb trivia_game
sudo -u postgres dropuser trivia_user
- Remove PostgreSQL entirely by running:
sudo apt remove postgresql
- Delete the project folder by running:
rm -rf ~/Desktop/trivia-game
Nice Work!
Nice Work!
You just built a real-time multiplayer trivia game from scratch using Express, Socket.io, Prisma, and PostgreSQL. Players race through timed questions, scores update live, and the server rejects any cheaters who try to answer after the clock runs out.
You've learned how to:
- Build a server-authoritative game loop with Express and Socket.io that enforces timed rounds, validates answers, and pushes real-time state to all connected players.
- Implement server-side countdown timers that auto-advance rounds and reject late submissions, preventing any single player from stalling the game.
- Persist trivia questions and game results to PostgreSQL via Prisma ORM, using driver adapters and migrations to keep the database in sync with your schema.
- Secret Mission: Added isolated multiplayer rooms with shareable join codes using Socket.io rooms, so multiple independent games can run simultaneously.
Ready to quiz yourself?