Overview
A card game as the playable centerpiece, not the point
OMI was built to put real-time networking, secure backend design, WebSocket communication, deployment, and cloud infrastructure into practice on a project a real person actually plays, not a toy exercise. Underneath a traditional Sri Lankan trick-taking card game sits a Node.js and Express server holding the authoritative game state, a Socket.IO transport layer, a modular service layer, a swappable persistence layer, and a continuous-deployment pipeline running behind Cloudflare on Northflank.
The game itself (2, 3, and 4 player modes, bots, a real persistent shuffled deck, a live leaderboard) is genuine and fully playable at omi.nodenull.org. But the engineering underneath, the same code path that has to survive a reverse proxy, a hostile Origin header, a flooding client, and a redeploy without dropping a single in-progress match, is the actual subject of this case study.
Key Features
What the deployed app does
Real-time multiplayer
Every game action is a Socket.IO event handled by an authoritative server; the client only ever renders state it is sent.
QR-code lobby joining
The host's lobby screen renders a QR code on the spot; scanning it opens the join link directly, no address to type.
2, 3, and 4 player modes
Duel (8 cards + draw pile), Free for All (30 cards, first to 25), and full Team Mode with a real persistent 32-card deck.
Bot players
Any empty seat is filled by a bot, so a match can start solo or with fewer than the full player count.
Reconnection support
A dropped player keeps their seat for a 90-second grace window and reclaims it on reload with a session token, instead of ending the match for everyone.
Server-authoritative state
The server is the only source of truth; every action is validated server-side and a player is only ever sent their own hand.
Persistent leaderboard
Winning 4-player teams are recorded to SQLite, deduped to each team's highest score, sorted, and served read-only over the API.
Responsive, installable client
Plain HTML/CSS/JS with a service worker and web app manifest; installs to a phone or desktop home screen and loads offline.
LAN and public deployment
The same code runs unmodified on a laptop, a LAN host advertised by IP and QR code, or the public Cloudflare/Northflank deployment.
Networking
From a WebSocket upgrade to a LAN address nobody can reach
Socket.IO connection flow. A browser opens GET /socket.io/ with an
Upgrade: websocket header, which Cloudflare and Northflank both proxy
through unmodified. Before the handshake completes, the server's own
allowRequest() checks the request's Host and Origin
headers; only after both pass does the connection receive 101 Switching
Protocols and become a live socket.
GET /socket.io/ (Upgrade: websocket)
The browser requests a WebSocket upgrade.
Proxied through Cloudflare and Northflank
Both hops forward the upgrade request unmodified.
allowRequest() checks Host and Origin
The handshake is rejected outright if either header does not check out, before any game code runs.
101 Switching Protocols
The socket is now live.
emit("join", { name })
The client sends its join event.
Game Manager handleJoin()
Seats the player and broadcasts lobby-update back to the room, each client rendering only its own view of the shared state.
Server broadcasts vs. client-specific state. Public state (whose turn it is, the cards on the table, the score) is broadcast to the whole room; each player's hand is emitted only to that player's own socket. The client never receives, and therefore cannot leak or tamper with, another player's cards.
Reverse proxy awareness. Every request Express sees technically originates from
Northflank's internal network, not the player's browser, so the app has to be told
which hop to trust for the real client address and protocol. app.set('trust
proxy', TRUST_PROXY) (default 1) tells Express how many proxy hops
to trust when reading X-Forwarded-For and X-Forwarded-Proto.
Getting this wrong in either direction breaks something real: too few hops trusted and
every player appears to share Northflank's own IP (rate limiting punishes everyone
together); too many and a spoofed forwarded header would be trusted blindly.
LAN address detection. For LAN hosting, the server has to find its own reachable address without being told one. A lot of dev machines carry several network interfaces at once (Wi-Fi, Ethernet, plus virtual adapters from VirtualBox, VMware, Hyper-V, WSL, Docker, and VPN clients), and the naive "first non-internal IPv4" approach often picks a virtual adapter that no other device on the network can actually reach.
Open a UDP socket
No connection is actually made yet.
connect() toward 8.8.8.8:53
A UDP "connect" sends no packets, so this works even fully offline.
The OS just resolves the route
It runs the routing table to decide which local interface would be used.
Read the socket's local address
That address belongs to the interface holding the default route.
That is the interface other devices can reach
A fallback path additionally scores every interface, rewarding real Wi-Fi/Ethernet adapters and common home ranges (192.168.x, 10.x) while pushing virtual adapters, link-local addresses, and the VirtualBox host-only range to the bottom.
Advertise it as the LAN join address
Printed in the terminal and encoded into the join QR code.
QR-code joining. When the host opens the lobby, a QR code encoding the join URL
is generated on the spot (the terminal also prints a scannable code and the link when
the server starts). In production, the QR encodes the configured
PUBLIC_URL instead of a detected LAN address, and a friendlier
hostname.local (mDNS) address is offered as a fallback where the network
supports it.
Cybersecurity
Threat-modelled for a LAN and for the public internet
Every control below is mapped to a MITRE CWE weakness class in the project's own README, since the server has to run safely both on a private Wi-Fi network and behind Cloudflare in production.
Host header validation stops DNS rebinding: a malicious page could resolve its own domain to a LAN IP and script requests straight at the server. Every request is checked before anything else runs.
Incoming request
Any HTTP request or WebSocket handshake.
Host header allowed?
Checked against ALLOWED_HOSTS in production, or a private-range allowlist on a LAN.
403 Forbidden, or the request proceeds
Anything that fails the check is rejected before it reaches game logic.
WebSocket origin validation stops cross-site WebSocket hijacking: raw WebSockets ignore CORS entirely, so without this check any page on the internet could open a socket straight into a live game.
WebSocket handshake
The connecting page presents an Origin header (if it has one).
Origin host matches the Host header?
Checked in allowRequest(), same-origin only.
Connection rejected, or the socket is allowed
A mismatched Origin never reaches a single game event.
Rate limiting
Per-IP HTTP rate limiting via express-rate-limit (300 requests/minute general, 60/minute on the API), trust-proxy aware so it keys off the real client IP.
Payload and connection limits
A 16 KB cap on HTTP request bodies, a 100 KB cap on Socket.IO payloads, and a configurable maximum of simultaneous socket connections (16 by default).
Event flood protection
Every socket gets its own token-bucket rate limiter (about 20 events/second sustained, small bursts allowed) the moment it connects; a persistent flooder is dropped.
Input sanitisation
Player and team names are stripped of C0/C1 control characters, zero-width spaces, and bidirectional-override characters (the Trojan Source class, CVE-2021-42574) before use anywhere.
Clickjacking protection
X-Frame-Options: DENY and frame-ancestors 'none' stop the page from being framed by a hostile site.
Content-Security-Policy
A strict CSP with no unsafe-inline scripts, set via Helmet; a script tag from anywhere but this origin simply will not execute.
X-Content-Type-Options
nosniff stops the browser from reinterpreting a response as a different content type than the one declared.
Permissions-Policy
Locked down to disable camera, microphone, geolocation, and other browser features the app never uses.
Server-side action validation
Every game action is re-validated server-side against the current authoritative state; a modified client cannot act out of turn or play an illegal card.
Per-player hand secrecy
A player is only ever sent their own hand over their own socket; nobody's cards are broadcast to the room.
Slowloris protections
headersTimeout (10s) and requestTimeout (30s) trim the slow half-open-request windows a Slowloris-style attack depends on.
No framework fingerprinting
x-powered-by is disabled and responses are gzip-compressed, so nothing extra about the stack is advertised.
Deployment Architecture
Browser to database, every hop
Browser
Connects over HTTPS / WSS.
Cloudflare
DNS, TLS termination, CDN, and reverse proxy for the omi subdomain.
Northflank load balancer and container
Receives the proxied HTTPS connection and forwards it to the running Node.js container.
Express + Socket.IO
Helmet, rate limiting, and static assets on the HTTP side; per-socket rate limiting and origin checks on the WebSocket side.
Game Manager
services/gameManager.js: the only code that ever touches the pure rules engine.
SQLite leaderboard
Reached separately from HTTP through routes/api.js, never through the game socket path.
Continuous deployment. The app
deploys straight from the GitHub repository with no Dockerfile: Northflank's buildpack
detects Node.js from package.json (the engines.node field pins
the version), installs dependencies, and runs npm start. Every push to
main triggers an automatic rebuild and redeploy.
git push main
A push to the default branch.
GitHub
Fires a webhook to Northflank.
Northflank buildpack build
npm install, no Dockerfile.
Deploy to a running container
The new build replaces the old one.
GET /api/healthz liveness probe
Northflank polls this endpoint to confirm the process is actually up before routing traffic to it.
Traffic served
A hung or crash-looping process gets restarted instead of silently serving nobody.
Trusting the reverse proxy.
Cloudflare adds X-Forwarded-For and X-Forwarded-Proto before
handing the request to Northflank's own proxy; Express reads those headers only because
app.set('trust proxy', TRUST_PROXY) tells it exactly how many hops to trust,
which is what lets req.ip resolve to the real client address and
req.protocol correctly report https even though the last hop
inside Northflank's private network is plain HTTP.
Cloudflare and Domain Setup
One root domain, two separate deployments
nodenull.org
The static portfolio you are reading now, served through Cloudflare in front of its own static hosting.
omi.nodenull.org
A separate subdomain, routed independently and pointed at an entirely different backend: Northflank's Node.js container.
DNS
Cloudflare manages a CNAME record for the omi subdomain, resolving to Northflank's edge.
SSL/TLS mode: Full (strict)
Cloudflare validates Northflank's own certificate on the second hop, rather than trusting whatever certificate the origin happens to present.
The portfolio and the game coexist under the same root domain purely through subdomain routing: Cloudflare's DNS and TLS layer is shared, but the two are otherwise independent deployments with independent infrastructure, so a redeploy of one never touches the other.
Software Architecture
Transport, rules, and persistence kept apart
The repository's actual layout, one concern per module.
server.js
Entry point: Express, security headers, rate limiting, routes, and Socket.IO wiring.
game.js
The pure rules engine and the shuffle model. No I/O, no networking, no persistence, so it is trivially unit-testable.
config/index.js
Environment-driven configuration; the same build runs on a laptop, a LAN host, or production unmodified.
services/gameManager.js
Lobby, round flow, reconnection, bot scheduling, and event handlers. The only module that touches game.js.
routes/api.js
The HTTP API: /api/leaderboard, /api/stats, /api/health and /api/healthz.
database/
A store factory (index.js) plus a SQLite backend and a portable JSON-file fallback, both behind the same three-method interface.
utils/network.js
LAN address detection, the Host/Origin checks, and join URL/QR generation.
utils/sanitize.js
The shared name-sanitisation routine used for both player names and leaderboard team names.
public/
The vanilla HTML/CSS/JS client, a service worker, a web app manifest, and themed 404/500 pages.
The separation is deliberate: game logic (game.js) has zero knowledge of
Socket.IO, transport (gameManager.js) has zero knowledge of SQL, and
persistence (database/) is swappable behind a three-method interface
(submit, top, close) that both the SQLite and
JSON-file backends implement identically, which is exactly what makes the planned move
to Turso a storage-layer change only (see Limitation below).
Testing
Three unit suites, plus a live smoke test
Game logic (test.js)
Trick resolution, every scoring case including the Kapothi variant and draw carry-over, deck persistence across rounds, the statistical properties of the riffle and overhand shuffle models, and full bot games across all three modes.
Scoring and Kapothi tests
Covered inside the game-logic suite: trump-caller wins, defender wins, announced sweeps, and the penalty for an announced Kapothi that then loses a trick.
Deck persistence tests
Confirms the same 32-card deck genuinely carries over round to round rather than being silently reshuffled by the computer.
Leaderboard tests (test-leaderboard.js)
Team-name generation, dedupe to each team's highest score, sorting, input validation, and persistence across a store reload.
Socket.IO tests (test-sockets.js)
Duplicate-join prevention, reconnect with a session token reclaiming the right seat, and lobby cleanup.
Malformed packet handling
The socket suite asserts the server survives malformed and unexpected payloads without crashing or corrupting game state.
Reconnect tests
A dropped and reconnecting player is verified to reclaim their original seat rather than being treated as a new player.
Flood protection tests
A socket.io-client-driven flood test confirms a flooding socket gets dropped while a well-behaved client keeps playing normally.
Security header tests
npm run test:dist asserts the header, Host, and Origin filtering, plus 404 behaviour, against a real running server.
Live smoke tests
test-dist.js also runs a live-server smoke test: an actual join, the leaderboard API, and malformed-input survival, alongside required-file and package-contract checks for the distributable build.
Challenges & Solutions
Engineering problems worth naming
Challenge
Correctly detecting the LAN IP to advertise on machines with several virtual network adapters.
Solution
A UDP "connect" toward a public address to read the OS's own routing decision, with an interface-scoring fallback that rewards real Wi-Fi/Ethernet and pushes virtual adapters to the bottom.
Challenge
Preventing cheating in a browser-based multiplayer card game, where the client is fundamentally untrusted.
Solution
A server-authoritative state machine: every action is re-validated server-side, and a player is only ever sent their own hand over their own socket.
Challenge
Letting a player reconnect after a dropped Wi-Fi connection without losing their seat or ending the match for everyone else.
Solution
A 90-second grace window keyed to a session token; the seat is held open and silently reclaimed on reload instead of the match ending immediately.
Challenge
Deploying WebSockets correctly behind two chained reverse proxies (Cloudflare, then Northflank).
Solution
Explicit trust proxy configuration so Express reads the real client IP and protocol from forwarded headers instead of the proxy's own connection.
Challenge
Sharing one root domain between a static portfolio and a completely separate live backend.
Solution
Subdomain routing through Cloudflare DNS: nodenull.org and omi.nodenull.org share only the edge layer, with fully independent infrastructure behind it.
Challenge
Handling SQLite persistence limitations on ephemeral cloud container storage.
Solution
Isolated behind a swappable database interface today, with a planned move to Turso (distributed SQLite) so the leaderboard survives container recreation without needing a paid persistent volume; see Limitation below.
Current Limitation
Stated plainly, not hidden
The leaderboard currently uses SQLite on ephemeral Northflank storage, so leaderboard data may reset during redeployment.
Persistent storage was postponed because Northflank persistent volumes require payment.
A future migration to Turso (distributed SQLite over libSQL) is planned, which keeps the exact same store interface and SQL while replacing the local file with a durable, replicated database.
Skills Demonstrated
By category
Networking
Cybersecurity
Cloud and DevOps
Backend
Future Improvements
What would come next
Turso-backed leaderboard persistence, so the live deployment survives container recreation without a paid volume.
Accounts and authentication.
Match history.
Player rankings.
Friends.
Richer statistics, built on the existing /api/stats endpoint.
Player profiles.
Admin tools over the existing API layer.
OMI Multiplayer
Play the live game, or read the source
Built solo end to end: real-time networking, secure backend design, cloud deployment, and a genuine playable card game.