3. Configuration and startup
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
| Function | Purpose |
|---|---|
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:
| orbs | share 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
| Key | Default | Meaning |
|---|---|---|
port | 3000 | HTTP + websocket port (PORT) |
world.width / .height | 1200 / 700 | Play area in pixels; the canvas matches exactly |
lobby.maxPlayers | 10 | Capacity per lobby |
lobby.collectibleCount | 40 | Orbs per lobby, clamped to 1–75 |
lobby.disconnectGraceMs | 30,000 | How long a dropped player keeps slot, score and name |
lobby.emptyLobbyTtlMs | 120,000 | Sweep for a lobby created over HTTP that nobody joined |
lobby.codeLength | 6 | Join code length |
player.radius | 14 | Drawing + wall clamping |
player.speed | 260 | Pixels per second, not per tick |
collectible.radius | 9 | Drawing only |
collectible.collectRadius | 26 | Pickup distance, checked server-side |
collectible.spawnMargin | 40 | Keeps orbs off the walls so they stay reachable |
tickRate | 20 | Broadcasts per second per lobby |
rateLimit.inputPerSecond | 60 | Per-socket ceiling |
rateLimit.collectPerSecond | 25 | Per-socket ceiling |
username.minLength / .maxLength | 2 / 16 | Validation 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):
express.json({ limit: '16kb' })— nothing legitimate is larger.express.static(publicDir)— servespublic/. Same origin as the API and the websocket, which is why there is no CORS configuration anywhere.GET /health— see below./api/lobbies→lobbyRoutes,/api→leaderboardRoutes.notFoundHandler— anything unmatched becomes a clean JSON 404.errorHandler— must 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:
- Stop the tick loop and close Socket.IO.
- Mark every in-flight game
ABANDONED. - Persist them in parallel, racing a 5-second timeout so a hung database cannot stop the process from exiting.
- Disconnect Prisma, close the HTTP server, exit.
- 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
| Script | Does |
|---|---|
npm start | node src/server.js |
npm run dev | node --watch src/server.js (built-in watcher, no nodemon) |
npm test | All 101 tests |
npm run test:unit | Pure logic only — no server, no DB |
npm run test:integration | Real sockets + real Postgres, --runInBand |
npm run db:up / db:down | Docker Postgres |
npm run db:migrate | prisma migrate dev |
npm run db:studio | Prisma's DB browser |
npm run db:seed | Demo leaderboard data |
npm run db:clean | Purge bot_* / e2e_* rows left by stress and integration runs |
npm run stress | The 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.