A real-time multiplayer web application built to demonstrate networking, security, and backend engineering, using the traditional Sri Lankan card game Omi as the playable centerpiece.
Live at omi.nodenull.org. Built by Methindu Damsara (nodenull.org).
- What it is
- Skills demonstrated
- Features
- Technologies
- Networking and infrastructure
- Screenshots
- Quick start
- Environment variables
- Deployment
- Hosting it on your network
- How to play
- Leaderboard
- Database
- Security architecture
- How the shuffle stays honest
- Project layout
- Testing
- Roadmap
- License
- Troubleshooting
OMI is a real-time multiplayer web application built to put networking, security, and backend engineering practice on display, not only to reimplement a card game. It is a Node.js + Express + Socket.IO server holding the authoritative game state, paired with a browser client (plain HTML/CSS/JS, no framework, no build step). The server is split into focused modules, persistence lives behind its own database layer, and the whole thing runs continuously deployed behind Cloudflare on Northflank (see Networking and infrastructure).
Underneath the infrastructure, it is also a genuine implementation of Omi, the Sri Lankan trick-taking card game: 2, 3, and 4 player modes, a real persistent physical deck for the 4 player game, bots filling empty seats, and a shuffle model built on the actual mathematics of card mixing rather than a perfect randomiser (see How the shuffle stays honest).
- Networking: HTTP and WebSocket protocols end to end, reverse proxy chains, DNS and
subdomain routing, TLS termination, proxy header trust (
X-Forwarded-For,X-Forwarded-Proto), and OS-level LAN address discovery (see Networking and infrastructure). - Security: a threat model mapped to CWE and MITRE ATT&CK, a strict Content-Security-Policy, Host header and WebSocket origin validation, rate limiting and connection caps, and input sanitization against control-character and bidirectional-override attacks (see Security architecture).
- Cloud and deployment: continuous deployment from GitHub, buildpack builds with no Dockerfile, a health check endpoint wired to a platform liveness probe, and environment-driven configuration across local, LAN, and production (see Deployment).
- Backend engineering: an authoritative real-time state machine over Socket.IO, a modular service layer, a swappable persistence layer with automatic fallback, and three automated test suites covering rules, sockets, and distribution readiness (see Testing).
- 2, 3, and 4 player modes, with bots filling any empty seats so you can play solo.
- A real physical deck (4 player): you wash, shuffle, and cut the cards by hand, and the same 32 cards carry over from round to round. The shuffle is modelled on real card mixing rather than a perfect randomiser (see below).
- Team selection: the host picks who partners with whom before the game starts.
- Persistent online leaderboard for winning teams, stored in SQLite, sorted highest first with the top three highlighted.
- Reconnect support: refresh the page or drop off Wi-Fi and you reclaim your seat within a grace window instead of ending the match for everyone.
- Installable (PWA): add it to a phone or desktop home screen; it loads offline.
- Join by QR code or invite link, plus a scannable code in the terminal.
- Responsive and accessible: adapts from phones to tablets to desktops, with keyboard play, focus indicators, ARIA labels, and reduced-motion support.
- Deploys anywhere: runs from source, sits behind a reverse proxy over HTTPS, or
packages to a single-file
omi.exefor LAN play. The hosted copy at omi.nodenull.org runs continuously deployed on Northflank behind Cloudflare.
- Runtime: Node.js 18+, Express, Socket.IO.
- Security: helmet, express-rate-limit, compression, a strict Content-Security-Policy.
- Storage: SQLite via better-sqlite3, behind a database layer with a JSON fallback.
- Client: vanilla HTML/CSS/JavaScript, a service worker, and a web app manifest.
- Deployment: Northflank (buildpack builds, continuous delivery from GitHub) behind Cloudflare (DNS, TLS, CDN).
- Tooling: dotenv for configuration, pkg for the optional Windows executable.
A request or game action passes through several hops before it reaches the game engine:
flowchart TD
A[Browser] -->|HTTPS or WSS| B["Cloudflare<br/>DNS - TLS - CDN - reverse proxy"]
B -->|HTTPS| C["Northflank<br/>load balancer and container"]
C --> D["Express<br/>helmet, rate limiting, static assets"]
D --> E["Socket.IO<br/>per-socket rate limiting, origin checks"]
E --> F["Game Manager<br/>services/gameManager.js"]
F --> G["game.js<br/>pure rules engine, no I/O"]
D --> H["Leaderboard API<br/>routes/api.js"]
H --> I[("SQLite or JSON store")]
Cloudflare terminates the public connection and proxies it to Northflank, which runs the
Node process in a container. Express applies security headers and rate limiting before
anything reaches game logic, and Socket.IO carries the real-time traffic to the game
manager, which is the only thing that ever touches game.js, the pure rules engine.
nodenull.org is my portfolio site; omi.nodenull.org is a subdomain pointed at this
project specifically, so the two stay on separate infrastructure behind one domain.
Cloudflare sits in front of Northflank and handles:
- DNS: the
omisubdomain resolves to Northflank's edge. - TLS termination: Cloudflare holds the public-facing certificate, so browsers see valid HTTPS even though the certificate work happens at the edge.
- CDN: static assets (CSS, client JS, icons) can be cached at Cloudflare's edge; the API and Socket.IO paths are never cached (the service worker on the client follows the same rule, see Project layout).
- Reverse proxy: Cloudflare forwards the request to Northflank over a second, separate TLS connection, configured in Full (strict) mode, so Cloudflare validates Northflank's own certificate rather than trusting whatever the origin presents.
The app deploys straight from this 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 a
rebuild and redeploy automatically:
flowchart LR
A["git push main"] --> B[GitHub]
B -->|webhook| C["Northflank buildpack"]
C -->|npm install| D["Build"]
D -->|deploy| E["Running container"]
E -->|"GET /api/healthz"| F["Liveness probe"]
F -->|ok| G["Traffic served"]
GET /api/healthz (see Database) is wired up as Northflank's health check,
so the platform knows to restart the container if the process hangs instead of leaving a
dead instance behind. Environment variables (NODE_ENV, ALLOWED_HOSTS, PUBLIC_URL,
DATA_DIR, and so on, see Environment variables) are set in
Northflank's dashboard rather than committed to the repo, and the leaderboard's SQLite
file lives on a persistent volume so it survives redeploys.
Every request Express sees technically comes from Northflank's internal network, not the player's browser, so the app has to be told which hops to trust for the real client address and protocol:
flowchart LR
A["Client (real IP)"] --> B[Cloudflare]
B -->|"adds X-Forwarded-For, X-Forwarded-Proto"| C["Northflank proxy"]
C --> D["Express: app.set('trust proxy', TRUST_PROXY)"]
D --> E["req.ip is the real client IP<br/>req.protocol is https"]
TRUST_PROXY (default 1) tells Express how many proxy hops to trust when reading
X-Forwarded-For. Get this wrong in either direction and two things break: rate limiting
keys off the wrong IP (either everyone shares Northflank's IP and gets rate-limited
together, or a spoofed header is trusted blindly), and the app cannot tell whether it is
actually being served over HTTPS.
flowchart LR
A[Browser] -->|TLS 1.3| B["Cloudflare edge"]
B -->|"TLS, Full (strict)"| C["Northflank origin"]
C -->|"plain HTTP, private network only"| D["Express and Socket.IO"]
The connection is encrypted from the browser to Cloudflare, and again from Cloudflare to
Northflank; only the last hop, inside Northflank's private network, is plain HTTP. The
server's Content-Security-Policy explicitly allows wss: in connectSrc (see
server.js), so the Socket.IO connection upgrades to a secure WebSocket rather than
falling back to polling.
sequenceDiagram
participant C as Browser
participant P as Cloudflare / Northflank
participant S as Express + Socket.IO
participant G as Game Manager
C->>P: GET /socket.io/ (Upgrade: websocket)
P->>S: proxied upgrade request
S->>S: allowRequest() checks Host and Origin
S-->>C: 101 Switching Protocols
C->>S: emit("join", { name })
S->>G: handleJoin()
G-->>C: emit("lobby-update")
Every socket gets its own token-bucket rate limiter the moment it connects (see
Security architecture), and allowRequest rejects the
handshake outright if the Host or Origin header does not check out, before a single
game event is processed.
For how the server finds its own address on a local network rather than behind Cloudflare, see Hosting it on your network.
Add screenshots or a short GIF here to show the lobby, the physical-deck shuffle, a hand in play, and the leaderboard. Suggested captures:
docs/lobby.png The lobby with the QR code, team panel, and invite link
docs/shuffle.png Washing / riffling the physical deck
docs/play.png A four-player hand mid-trick
docs/leaderboard.png The leaderboard with the top three highlighted
(Images are not committed to keep the repository light; drop them in a docs/ folder
and reference them here.)
Requires Node.js 18 or newer. Install and run:
npm install
npm startThen open the address it prints (see the next section). That is all: the browser client ships with the repo, so there is nothing else to copy or configure.
If port 3000 is already taken, pick another one:
# macOS / Linux
PORT=3001 npm start
# Windows PowerShell
$env:PORT=3001; npm startBuild a standalone Windows executable (no Node needed on the machine that runs it):
npm run build
# or: npx pkg . --targets node18-win-x64 --output omi.exeFor local development with auto-restart on file changes:
npm run devEvery tunable is read from the environment, so the same build runs on a laptop, a LAN
host, or a public deployment with no code changes. Copy .env.example to .env for
local use, or set these on your hosting platform. All are optional and have sensible
defaults.
| Variable | Default | Purpose |
|---|---|---|
PORT |
3000 |
Port to listen on. |
NODE_ENV |
development |
production quiets logs, trusts the proxy for host checks, and skips the LAN QR banner. |
ALLOWED_HOSTS |
(empty) | Comma-separated hostnames to answer to. Empty on a LAN (private addresses are allowed automatically); set it in production to lock the server to your domain. |
PUBLIC_URL |
(empty) | Public base URL to advertise in join links / QR when deployed behind a proxy. |
TRUST_PROXY |
1 |
Proxy hops to trust for the real client IP and protocol. |
MAX_SOCKETS |
16 |
Maximum simultaneous connections. |
DB_DRIVER |
auto |
auto uses SQLite when available, otherwise a JSON file. Force with sqlite or json. |
DATA_DIR |
./data |
Where the leaderboard is stored. Point at a persistent volume in production. |
LEADERBOARD_SIZE |
100 |
Rows to keep and return. |
LOG_LEVEL |
info (prod) / debug |
error, warn, info, or debug. |
The hosted copy at omi.nodenull.org runs on Northflank, deployed straight from this repository with a buildpack build (no Dockerfile), behind Cloudflare for DNS, TLS, and edge proxying. See Networking and infrastructure for the full request path and the reasoning behind each piece.
It calls app.set('trust proxy', ...), so it reads the real client IP and protocol from
the proxy chain, and it makes no localhost-only assumptions, which is what lets the
same code run unmodified on a laptop, a LAN host, or behind Cloudflare and Northflank.
The production configuration behind the live deployment:
NODE_ENV=production
PORT=8080 # or whatever the platform assigns
ALLOWED_HOSTS=omi.nodenull.org
PUBLIC_URL=https://omi.nodenull.org
TRUST_PROXY=1 # one hop: Northflank's own proxy in front of the container
DATA_DIR=/data # a Northflank persistent volume, so scores survive redeploysNotes:
ALLOWED_HOSTSlocks the server to this exact hostname; without it, production trusts the proxy and answers any host, which is fine on a platform where the proxy already filters traffic, but tighter is safer.- The persistent volume matters: on an ephemeral container filesystem the SQLite file is wiped on every redeploy, and the leaderboard would reset (see Database for the planned fix).
- WebSockets are proxied by both Cloudflare and Northflank by default; the client
connects over
wss:automatically once the page itself is served over HTTPS. GET /api/healthzis wired up as Northflank's health check, so a hung process gets restarted instead of serving nobody silently.- The
omi.exebuild is for LAN play only; the cloud deployment runs the Node process directly, not the packaged executable.
To deploy this elsewhere, any platform that runs a long-lived Node process behind HTTPS
works the same way: set ALLOWED_HOSTS and PUBLIC_URL to your domain, point
DATA_DIR at persistent storage, and make sure WebSocket upgrades are allowed through
whatever sits in front of it.
This is the part I cared about most, since the point is that other people join over the LAN without any setup.
Finding the right address. A lot of dev machines have several network interfaces (Wi-Fi, Ethernet, plus virtual adapters from VirtualBox, VMware, Hyper-V, WSL, Docker, and VPN clients). The naive "first non-internal IPv4" trick often picks one of those virtual adapters, and then the address shown to players is one nobody can actually reach. To avoid that, the server figures out its address two ways and prefers the more reliable one:
flowchart TD
A["Open a UDP socket"] --> B["connect() toward 8.8.8.8:53"]
B --> C["No packet is actually sent<br/>the OS just resolves the route"]
C --> D["Read the socket's local address"]
D --> E["That is the interface holding<br/>the default route"]
E --> F["Advertise it as the LAN join address"]
- It opens a UDP socket and "connects" it toward a public address. UDP connect sends no packets, it just runs the OS routing table, so the local address that comes back is the interface holding the default route. That is the one other devices on the Wi-Fi actually talk to. This works even with no internet connection.
- As a fallback it enumerates every interface and scores them, preferring real Wi-Fi and Ethernet adapters and common home ranges (192.168.x, 10.x) while pushing virtual adapters, link-local (169.254.x), and the VirtualBox host-only range to the bottom.
Joining without typing an IP. When the host opens the lobby, the screen shows a QR
code generated on the spot. Anyone points their phone camera at it and they are in. The
join link is printed underneath for people who would rather type or paste it, and the
terminal also prints a scannable QR code and the link when the server starts. On some
networks the friendlier http://<hostname>.local:3000 address (mDNS) works too, so
that is offered as a fallback.
Firewall. The first time you run it on Windows, allow Node (or omi.exe) through
the firewall on private networks when prompted. Without that, other devices cannot
reach port 3000.
The game has a built-in How to Play panel (the round ? button, bottom right of
every screen) written in plain English. The short version:
- Start the server, then everyone opens the join link or scans the QR code and enters a name. The first person in is the host.
- The host picks a mode and starts. In the 4 player mode the lobby also shows a TEAMS panel: the host presses CHANGE PARTNERS to cycle through the three possible pairings until everyone is happy with who plays with whom, and the game then seats each pair across from each other.
- Modes:
- 2 players (Duel): 8 cards each plus a draw pile. The pile's top card sets trump, and both players draw a fresh card after each trick. Best of 5 rounds.
- 3 players (Free for All): 30 cards, 10 tricks a round, every trick is a point, first to 25.
- 4 players (Team Mode): the full Sri Lankan game with a real persistent deck (details below), first team to 10 tokens.
- Empty seats are played by bots.
- Follow the suit that was led if you can. Trump beats everything else, otherwise the highest card of the led suit wins the trick.
Deal and play run counter-clockwise, following the standard rules (pagat.com). The same 32 cards circulate the whole game and are never reshuffled by the computer between rounds. A round goes:
- Wash: the dealer drags the pile around to smoosh the cards.
- Shuffle: chop overhand packets off the deck by clicking it, or riffle the two halves together, as many times as you like.
- Cut: the opponent to the dealer's left slices the squared stack and restacks it.
- Deal 4 and call trump: the player to the dealer's right gets the first 4 cards and picks the trump suit before anyone gets more.
- Deal 4 more: hands fill to 8 and the trump caller leads.
- Each trick is gathered face-down in the order it was played. Those piles become next round's deck, and the deal passes to the right.
Scores are kept the traditional way, with the 20 unused cards acting as tokens. First team to capture 10 tokens wins.
| Result | Tokens |
|---|---|
| Trump caller's team takes 5 to 7 tricks | +1 to the callers |
| Defenders take 5 to 7 tricks | +2 to the defenders |
| Announced Kapothi, swept all 8 | +3 to the sweepers |
| Announced Kapothi, then lost a trick | +4 to the opponents |
| Unannounced sweep of all 8 | scores as a normal win (+1 or +2) |
| 4 to 4 draw | no tokens, a bonus token waits for the next winners |
Kapothi (called Basthe in the south) is the all-or-nothing call. After a team wins the first 6 tricks, the leader decides before the 7th whether to announce it and play for +3, or stay quiet for the safe +1 or +2.
Ending early. Anyone can press END MATCH EARLY under the scoreboard. If every player agrees, the match stops and the highest score wins. Level scores end in a draw. A single decline cancels the vote.
Winning 4 player teams are recorded on a persistent leaderboard, reachable from the 🏆 button on the start screen and the game-over screen.
- The team name is generated automatically as
Player One + Player Two, so nobody types a separate name. - Only a team's highest score is kept. If the same pair plays again and does better, their entry updates; a lower score is ignored, and there are no duplicate rows.
- Entries are sorted highest first, and each stores the score and the date it was set. The top three are highlighted.
- Only all-human winning teams are recorded (a team with a bot in it is skipped).
The board is served read-only at GET /api/leaderboard and survives server restarts.
Persistence lives behind a small database layer in database/, so game logic never
touches storage directly and the backend can be swapped later.
- SQLite (via
better-sqlite3) is the default. One row per team, keyed by team name, updated in place with an upsert that keeps the higher score. - If a native SQLite build is not available (for example inside the packaged
omi.exe), it automatically falls back to a JSON file store with the same interface. Force a backend withDB_DRIVER=sqliteorDB_DRIVER=json. - Moving to PostgreSQL later means writing one more store with the same three methods
(
submit,top,close) and selecting it indatabase/index.js; nothing above the database layer changes.
Why this is not the final word on persistence. SQLite works well for development and
for a platform with a persistent volume attached, which is how the live deployment runs
it today. On a container platform without one, though, a redeploy or a restart wipes an
ephemeral filesystem and the leaderboard resets, since there is nothing durable
underneath the database file itself. The next planned step is a store backed by
Turso (distributed SQLite over libSQL), which keeps the same submit / top /
close interface and the same SQL, but replaces the local file with a durable,
replicated database, so the leaderboard survives container recreation without needing a
volume at all.
GET /api/healthz returns a small JSON health check for platform probes.
The server is meant to run on a private Wi-Fi network as well as behind Cloudflare in production, so I threat-modelled it for both settings and mapped each control to a known weakness class. Weaknesses are referenced by MITRE CWE ID, attacker techniques by MITRE ATT&CK, and dependency issues by CVSS score and GitHub Security Advisory (GHSA) ID.
| Risk | What could go wrong | Control | Reference |
|---|---|---|---|
| DNS rebinding | A malicious web page resolves its own domain to your LAN IP and scripts requests to the server | Host header allowlist: on a LAN only localhost and RFC 1918 private ranges are served; in production it honours ALLOWED_HOSTS. Everything else gets 403 |
CWE-350, CWE-346 |
| Cross-site WebSocket hijacking | Raw WebSockets ignore CORS, so another site could open a socket to the game | Same-origin handshake in allowRequest, foreign Origin rejected at connect |
CWE-1385, CWE-346 |
| Event flooding and resource exhaustion | A client spams messages or HTTP requests to pin the CPU or exhaust memory | Per-socket token bucket (about 20 events/s), 16 connection cap, 100 KB socket payload cap, persistent flooders dropped, plus per-IP HTTP rate limiting (express-rate-limit) and a 16 KB request-body cap |
CWE-400, CWE-770, ATT&CK T1499 |
| Slow-request holding (Slowloris) | Half-open requests held to tie up the server | headersTimeout and requestTimeout trim slow windows |
CWE-400, ATT&CK T1499.001 |
| Malicious input in names | Control, zero-width, or bidirectional-override characters used to spoof or corrupt display (the Trojan Source class, CVE-2021-42574) | Names are stripped to printable characters before use | CWE-20, CWE-1007 |
| Client-side cheating | A modified client tries to peek at hands or act out of turn | Server is authoritative, every action is validated, and a player is only ever sent their own hand | CWE-602, CWE-359 |
| Clickjacking and MIME sniffing | The page framed by a hostile site, or responses reinterpreted as script | Headers set with helmet: X-Frame-Options: DENY, frame-ancestors 'none', X-Content-Type-Options: nosniff, a strict Content-Security-Policy with no inline scripts, and a locked-down Permissions-Policy |
CWE-1021, CWE-16 |
| Leaderboard input | A team name or score crafted to inject or corrupt display | Names are sanitized the same way as player names; scores are validated as positive integers before storage | CWE-20 |
Two of these controls are worth walking through, since they are the ones that decide whether a request reaches the game at all:
Host header validation stops DNS rebinding: a malicious page could resolve its own domain to your LAN IP and then script requests straight at the server. Every request is checked before anything else runs:
flowchart LR
A[Incoming request] --> B{"Host header allowed?<br/>ALLOWED_HOSTS, or a private range on a LAN"}
B -->|No| C["403 Forbidden"]
B -->|Yes| D["Request proceeds"]
WebSocket origin validation stops cross-site WebSocket hijacking: raw WebSockets ignore CORS, so without this check any page on the internet could open a socket straight into the game.
flowchart LR
A["WebSocket handshake"] --> B{"Origin host matches<br/>the Host header?"}
B -->|No| C["Connection rejected"]
B -->|Yes| D["allowRequest() allows the socket"]
What Helmet actually turns on:
X-Frame-Options: DENYandframe-ancestors 'none': stops the page from being framed by another site (clickjacking).- A strict Content-Security-Policy with no
unsafe-inlinescripts: the main defense against injected script execution (XSS), since a script tag from anywhere but this origin simply will not run. X-Content-Type-Options: nosniff: stops the browser from reinterpreting a response as a different content type than the one declared.- A locked-down Permissions-Policy: disables browser features (camera, microphone, geolocation, and so on) the app never uses, so a compromised script has nothing extra to reach for.
Responses are also gzip-compressed (compression) and the framework banner is
suppressed (x-powered-by disabled), so nothing about the stack is advertised.
Dependencies. The runtime set stays small (express, socket.io, helmet,
express-rate-limit, compression, better-sqlite3, dotenv, chalk, and two QR helpers) and
is scanned with npm audit, which draws on the GitHub Advisory Database. The runtime
packages carry no known CVEs.
The one advisory that shows up is GHSA-22r3-9w55-cj54
in pkg, a build-time only tool: a local privilege escalation (CWE-276, CVSS 6.6,
local vector) that never ships to players and only matters on the machine that compiles
the executable, so build on a machine you trust.
How it was checked. The header, host, and origin filtering, the 404 behaviour, a
real socket join, and malformed-input survival are all asserted by the automated
npm run test:dist suite against a live server. The test-sockets.js suite adds
duplicate-join, reconnect, and cleanup coverage, and a socket.io-client flood test
confirms a flooding socket is dropped while a well-behaved client keeps playing.
On a LAN the private-host allowlist keeps this safe without extra configuration. In
production, ALLOWED_HOSTS is set to omi.nodenull.org and the app sits behind
Cloudflare and Northflank's own network protections (see
Networking and infrastructure).
A normal Math.random shuffle is a perfect randomiser, which would quietly erase the
whole point of a persistent physical deck. Instead the mixing is modelled on how cards
actually behave, using the standard results from the mathematics of card shuffling:
- Riffles follow the Gilbert-Shannon-Reeds model (cut near the middle on a binomial split, then interleave with probability proportional to each half's remaining size). By the Bayer-Diaconis result, a deck needs roughly seven good riffles to fully mix, so a couple of lazy riffles leave real structure behind.
- Overhand chops just reverse packet order, which barely mixes, exactly like the real move.
- A short wash only partially stirs the pile.
The upshot is that if players shuffle lazily, runs of cards from last round's tricks survive into the next deal, just like at a real table, and thorough shuffling genuinely randomises. The human wash also feeds real entropy into the process: cursor coordinates and timings seed the generator, so no two shuffles play out the same.
server.js Entry point: Express, security, routes, Socket.IO wiring
game.js Pure game rules and the shuffle model, no I/O
config/index.js Environment-driven configuration
utils/
logger.js Leveled logger
network.js LAN address detection, host/origin checks, join URL/QR
sanitize.js Shared name sanitization
routes/
api.js HTTP API (/api/leaderboard, /api/stats, /api/health)
services/
gameManager.js Lobby, round flow, reconnect, bot scheduling, handlers
leaderboardService.js Leaderboard business logic (validation, team names)
database/
index.js Store factory (SQLite, JSON fallback)
sqliteStore.js SQLite backend
jsonStore.js Portable JSON-file backend
public/
index.html Client markup shell
css/styles.css Client styles
js/app.js Client logic
sw.js Service worker (offline / installable)
manifest.webmanifest Web app manifest
favicon.ico Multi-size browser-tab icon (16/32/48)
icons/ App icons (SVG + raster), favicon, and the social-preview image
404.html, 500.html Themed error pages
socket.io.min.js Socket.IO browser client, vendored with the repo
test.js Game rules and shuffle-model unit tests
test-leaderboard.js Leaderboard unit tests
test-sockets.js Socket integration tests (reconnect, cleanup, ...)
test-dist.js Distribution + live-server checks (npm run test:dist)
.env.example Documented environment variables
npm test # game rules, shuffle model, leaderboard, and socket behaviour
npm run test:dist # required files, package contract, assets, security headers,
# and a live-server smoke test (join, API, malformed input)npm test runs three suites:
- Game and shuffle (
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 models, and full bot games in all three modes. - Leaderboard (
test-leaderboard.js): team-name generation, dedupe to the highest score, sorting, input validation, and persistence across a store reload. - Sockets (
test-sockets.js): duplicate-join prevention, malformed-packet survival, reconnect (reclaiming a seat with a session token), and lobby cleanup.
The structure is deliberately loose so features can be added without rewrites. The database layer, service layer, and API routes are the natural seams for what comes next:
- Turso-backed leaderboard storage, so the live deployment survives container recreation without depending on a persistent volume (see Database).
- Accounts and authentication, friends, and player profiles.
- Match history and richer statistics (the
/api/statsendpoint is the starting point). - Achievements and cosmetics.
- Global and season rankings built on the same leaderboard store, or a PostgreSQL one.
- Spectator mode (watch a game in progress without taking a seat).
- Admin tools over the API layer.
This project is source-available, not open source: the code is here to read, clone, and run locally for evaluation, but redistribution, commercial use, and public redeployment are reserved. See LICENSE for the exact terms. If you would like to use part of this project elsewhere, reach out through nodenull.org.
- To stop the server: press Ctrl+C in its terminal. For the packaged
omi.exe, end it from Task Manager or runtaskkill /F /IM omi.exe. Stopping ends the match for everyone. - Port already in use: another copy is probably still running. Stop it, or start on
another port with
PORT=3001 npm start. The server prints a clear message instead of crashing silently. - Players cannot connect (LAN): confirm everyone is on the same Wi-Fi, that Node (or
omi.exe) is allowed through the firewall on private networks, and that they use the network link, notlocalhost. - Leaderboard resets after redeploy: point
DATA_DIRat a persistent volume; ephemeral filesystems wipe the SQLite file on redeploy.