A real-time, two-team quiz application built for early-talent onboarding sessions, and hardened from working prototype to a professional, maintainable tool.
Team Quiz is a live, browser-based quiz for onboarding groups of new hires. Players join from any device, are split into two random teams, and answer technical questions together. After every round the correct answer and a short explanation are shown, so the game works as a learning moment rather than a pure competition. A separate host panel drives the session and chooses from several question sets spanning Java/DevOps, Git, Linux, AWS, AI and security.
The random-teams-plus-discussion format is deliberate: it gets graduates talking, reasoning aloud and meeting each other, which is the real goal of an onboarding activity.
Each room holds one game as a small state machine with five phases:
login β ready β question β reveal β gameover. Many rooms run concurrently,
one per host, each with its own 4-digit join code.
Throughout, the host sees the correct answer (to read aloud); players never receive the answer or explanation until the reveal β this is enforced server-side, not just hidden in the UI.
It is a single Node.js process. Real-time state is pushed to every connected client over a WebSocket (Socket.IO); the only persistence is a small JSON file recording which questions each set has already asked. TLS is terminated by nginx in front of the app.
Players' phones / laptops Host laptop
(index.html) (host.html)
β β
β WebSocket (Socket.IO) over HTTPS
βββββββββββββββ¬βββββββββββββ
βΌ
nginx (TLS, reverse proxy)
βΌ
Node.js ββ Express (static + /config + /healthz)
ββ Socket.IO handlers
ββ GameEngine (in-memory state machine)
ββ store.js (atomic JSON file: used-question history)
βΌ
AWS EC2 (Ubuntu, systemd service)
The application code is split into small modules with single responsibilities. The game
rules live in a transport-agnostic GameEngine, so the logic can be tested
without any network, and the socket layer is a thin adapter that calls engine methods and
broadcasts the resulting state.
Questions live as one JSON file per set in a questions/ directory. The server
auto-discovers them at startup, so adding a new set is just dropping in a file β no code
change. Each set carries an id, display name, description, ordering and its list of
questions (each with four options, the correct index and an explanation).
The host chooses the active set from a dropdown in the lobby. Crucially, the βalready askedβ history is tracked per set, so every set exhausts its own pool independently and questions don't repeat until a set runs out. The shipped content is 18 sets β 664 questions β covering Java/DevOps, Git, Linux, AWS, Azure, Networking, Security, Containers & Kubernetes, CI/CD & IaC, Databases, Observability, Python, AI foundations, general tech trivia and a few harder rounds for fun.
Three plain HTML/CSS/JS pages share one dark, GitHub-inspired theme:
index.html) β login, lobby with the chosen set and rules, then the live question and voting, with sound effects on reveal (synthesized in the browser, mutable from the top bar).host.html) β a password-gated control panel: set selector, draw/redraw teams, start/reveal/next, new match, reset history and team-name editing, plus a one-click "Big screen" link for the room.spectate.html) β a read-only spectator view sized for a projector: scores, live question, vote status and reveals. Spectators join by room code, don't appear in the roster, can't vote, and receive the player view of the state β so the answer stays server-side until reveal, exactly as for players.
There is no front-end framework or build step; the pages talk to the server purely over
Socket.IO and a tiny /config endpoint.
The site is publicly reachable, so it is treated as internet-exposed rather than a trusted internal tool. The hardening applied:
X-Powered-By.GET /healthz returns a small JSON status for monitoring or load-balancer probes.
Tests use Node's built-in runner (node --test) with no external framework, on
two levels:
GameEngine in isolation with an in-memory store: scoring, the win-and-ahead rule (including ties that continue), no-repeat then reset, per-team vote locking, set-switching guards and answer hiding.Together this is a fast, meaningful safety net β the refactor into modules was validated entirely by these tests staying green.
ESLint and Prettier enforce a consistent style, and a GitHub Actions workflow runs lint,
format check and the full test suite on every push and pull request. The project is no
longer a one-person black box: another engineer can clone it, run npm ci, and
get immediate feedback.
Infrastructure is described in Terraform: an EC2 instance with a static IP and security group, provisioned with nginx as a TLS-terminating reverse proxy and a Let's Encrypt certificate. DNS points a subdomain at the instance. The app runs as a systemd service, with configuration (win score, passwords, data-file path) supplied through an environment file.
Day-to-day content and code updates are pushed to the instance directly and the service restarted; the persistent question-history file lives outside the app directory so it survives redeploys. A Dockerfile and a Kubernetes/Helm chart are also included, giving a clear path to run the same image on the EKS platform later if desired.
server.js entry point: express app, helmet, Socket.IO, listen, shutdown store.js per-set "already asked" history (atomic JSON writes) src/ config.js environment + paths logger.js pino logger questions.js loads question sets from a directory game.js GameEngine β the game state machine (no transport concerns) socketHandlers.js wires Socket.IO events to the engine validation.js name / answer input helpers teamNames.js team-name pool util.js shuffle questions/ one JSON file per question set public/ player + host front-ends, styles, these docs test/ unit + integration tests infra/ Terraform (EC2 + nginx + HTTPS) deploy/helm/ Kubernetes chart for the EKS path
spectate.html) for projectors, opened in one click from the host panel. Question loading is now immune to stray dotfiles (e.g. macOS ._*.json AppleDouble artefacts).Dockerfile now creates /data owned by the non-root node user and sets ADMINS_FILE, so a mounted volume persists both state and admin accounts; a hardened docker-compose.yml (read-only rootfs, dropped capabilities, optional Caddy HTTPS proxy) is included. The EC2/Terraform infra was aligned to the current admin model (SUPER_ADMIN_USER/SUPER_ADMIN_PASSWORD + ADMINS_FILE, dropping the unused HOST_PASSWORD), the redeploy trigger now hashes the whole app, and CI builds and smoke-tests the image. Includes an independent defensive security review (no critical findings). Library now 16 sets, 534 questions./admin.html, env-based) creates persistent admin accounts (scrypt-hashed in ADMINS_FILE); each admin signs in at /host.html and hosts one room at a time, controlling only their own. Removing an admin closes their game. The old shared host password is gone.Team Quiz Β· technical overview