3. Configuration and startup

Three files: config.js (all the numbers), app.js (builds Express), server.js (owns the process).


src/config.js

Every tunable number in the game lives here, read from the environment with a sensible default. Nothing is hard-coded deeper in the codebase.

Why one file. During a stress run you want to change the tick rate or orb count without hunting through game logic. It also means tests import the same constants the server uses, so a test can never drift from reality.

Helpers

FunctionPurpose
num(value, fallback)Number() the env var; fall back if it is not finite. Stops PORT=banana producing NaN.
clamp(value, min, max)Keeps a value inside bounds.

COLLECTIBLE_BOUNDS

export const COLLECTIBLE_BOUNDS = { min: 1, max: 75 };

A playability limit, not a performance one. The world is 1200×700 (840,000 px²) and the pickup radius is 26 px, so each orb owns roughly 2,100 px² of pickup area:

orbsshare of map within pickup range
40 (default)~10% — comfortable
75 (cap)~19% — dense but still a race
400~100% — you are always touching something; not a game

Enforced here and again in createGame(), so neither an env var nor a per-lobby override can build an unplayable world. The floor is 1 rather than something larger because a one-orb lobby is a valid (very short) game and tests rely on asking for one; the floor only stops a zero-orb lobby, which would be "finished" the instant it was created.

The config object

KeyDefaultMeaning
port3000HTTP + websocket port (PORT)
world.width / .height1200 / 700Play area in pixels; the canvas matches exactly
lobby.maxPlayers10Capacity per lobby
lobby.collectibleCount40Orbs per lobby, clamped to 1–75
lobby.disconnectGraceMs30,000How long a dropped player keeps slot, score and name
lobby.emptyLobbyTtlMs120,000Sweep for a lobby created over HTTP that nobody joined
lobby.codeLength6Join code length
player.radius14Drawing + wall clamping
player.speed260Pixels per second, not per tick
collectible.radius9Drawing only
collectible.collectRadius26Pickup distance, checked server-side
collectible.spawnMargin40Keeps orbs off the walls so they stay reachable
tickRate20Broadcasts per second per lobby
rateLimit.inputPerSecond60Per-socket ceiling
rateLimit.collectPerSecond25Per-socket ceiling
username.minLength / .maxLength2 / 16Validation bounds

TICK_INTERVAL_MS is exported as 1000 / tickRate (50 ms) so the broadcaster does not recompute it.

player.speed is per second, not per tick. Combined with real-elapsed dt in tickGame, this means players move the same distance whether the server ticks at a clean 20 Hz or stutters to 12 Hz.


src/app.js

Builds the Express application and nothing else — it never calls listen().

export function createApp({ getRuntimeMetrics = () => ({}) } = {}) { ... }

Why the separation. A test can import createApp(), hand it to supertest or wrap it in an http.Server on a random port, and never bind port 3000. Mixing app construction with process startup is the usual reason HTTP tests become awkward.

What it wires, in order (order matters in Express):

  1. express.json({ limit: '16kb' }) — nothing legitimate is larger.
  2. express.static(publicDir) — serves public/. Same origin as the API and the websocket, which is why there is no CORS configuration anywhere.
  3. GET /health — see below.
  4. /api/lobbieslobbyRoutes, /apileaderboardRoutes.
  5. notFoundHandler — anything unmatched becomes a clean JSON 404.
  6. errorHandlermust be last; Express identifies it by its four arguments.

GET /health

Deliberately more than a 200 OK. Without tick timings and lobby counts, a load test can only tell you the server is up, not whether it is coping.

{
  "status": "ok",
  "uptimeSec": 412,
  "lobbies":  { "lobbies": 12, "activeLobbies": 12, "players": 97, "connected": 95 },
  "runtime":  { "tickRate": 20, "ticks": 8240, "gamesFinished": 31,
                "tickMs": { "p50": 1.4, "p95": 10.6, "max": 18.2 } },
  "memory":   { "heapUsedMb": 46.2, "rssMb": 158.6 },
  "config":   { "tickRate": 20, "maxPlayersPerLobby": 10, "collectibleCount": 40 }
}

getRuntimeMetrics is injected as a callback because the app is built before the broadcaster exists. server.js passes a closure that reads the broadcaster once it does — a small dependency-inversion so app.js never has to know the tick loop exists.


src/server.js

The only file that knows about ports, signals and process lifetime. Everything it starts is built elsewhere and injected.

Startup order

let broadcaster = null;
const app     = createApp({ getRuntimeMetrics: () => broadcaster?.metrics() ?? {} });
const httpServer = createServer(app);
const realtime   = attachRealtime({ httpServer, lobbyManager });
broadcaster = realtime.broadcaster;   // closure above now resolves

The closure resolves the circular need (app wants metrics, metrics come from something built after the app) without either module importing the other.

Bind failure exits

httpServer.on('error', (err) => { /* log */ process.exit(1); });

This is registered before the uncaughtException net below, and it matters. Without it, EADDRINUSE was caught by the catch-all handler and the process lingered as a zombie serving nothing — while an older instance kept answering on the port, so a restart looked like it had worked when it had not. A server that cannot bind its port has nothing to offer; it should die loudly.

Graceful shutdown

On SIGINT / SIGTERM:

  1. Stop the tick loop and close Socket.IO.
  2. Mark every in-flight game ABANDONED.
  3. Persist them in parallel, racing a 5-second timeout so a hung database cannot stop the process from exiting.
  4. Disconnect Prisma, close the HTTP server, exit.
  5. A 3-second unref'd timer force-exits if something still holds the loop open.

In-memory state does not survive a restart, so this at least saves what was in progress rather than dropping it silently.

Process-level safety nets

process.on('unhandledRejection', ...);
process.on('uncaughtException',  ...);

These log loudly rather than exiting. A crash in one lobby must not take down the other 99. They are a safety net, not a substitute for the try/catch around every socket handler (guard() in handlers.js) — that is where errors are actually meant to be caught.


npm scripts

ScriptDoes
npm startnode src/server.js
npm run devnode --watch src/server.js (built-in watcher, no nodemon)
npm testAll 101 tests
npm run test:unitPure logic only — no server, no DB
npm run test:integrationReal sockets + real Postgres, --runInBand
npm run db:up / db:downDocker Postgres
npm run db:migrateprisma migrate dev
npm run db:studioPrisma's DB browser
npm run db:seedDemo leaderboard data
npm run db:cleanPurge bot_* / e2e_* rows left by stress and integration runs
npm run stressThe load generator

The test scripts spell out the Jest binary path:

node --experimental-vm-modules node_modules/jest/bin/jest.js

rather than NODE_OPTIONS=... jest, because inline env-var syntax does not work in npm scripts on Windows (cmd.exe). This form is cross-platform and avoids needing cross-env.

Built with LogoFlowershow