src/http/app.ts — the web endpoints

In one sentence: the ordinary web addresses — health checks, metrics, lobby lists, leaderboards — plus serving the game page itself.

Size: 113 lines. Depends on: Express, and the pieces it reports on.


Why this file exists

Gameplay runs over WebSocket, so these endpoints are not in the hot path. They exist for three separate audiences:

AudienceEndpointWhy
Load balancers, Docker/healthis this process alive?
Me, when measuring/metricsis the server keeping up?
The browser/api/*, static fileslobby list, leaderboards, the game page

/metrics is the most valuable of these. It is what the stress test reads to decide whether the server is coping, and it is where the sim-versus-broadcast split lives — the measurement that found the real bottleneck.


The code, line by line

1. Dependencies (lines 9–18)

export interface AppDependencies {
  manager: GameManager;
  loop: GameLoop;
  sockets: SocketServer;
  repository: GameRepository;
  writeQueue: WriteQueue;
  config: GameConfig;
  persistenceEnabled: boolean;
}

Everything is handed in rather than imported and constructed here. This is dependency injection: the file describes what it needs, and server.ts decides what to supply.

That is what lets a test build a whole app with an in-memory repository.

2. Setup (lines 26–29)

export function createApp(deps: AppDependencies): Express {
  const app = express();
  app.use(express.json({ limit: '16kb' }));
  app.disable('x-powered-by');
  • express.json({ limit: '16kb' }) — parse JSON request bodies, but reject anything over 16 KB. Without a limit, someone could send a 500 MB body and exhaust memory. None of our endpoints need a body at all, so 16 KB is generous.
  • app.disable('x-powered-by') — Express normally announces itself in a response header. Telling attackers what you run makes their job easier, for no benefit.

3. /health (lines 31–33)

app.get('/health', (_req, res) => {
  res.json({ status: 'ok', uptimeSec: Math.round(process.uptime()) });
});

The simplest possible "am I alive?" check. Deliberately does not touch the database — if Postgres is down the game still works, so health should not report failure.

_req — the underscore is a convention meaning "this parameter exists but is unused". It stops linters complaining.

4. /metrics — the important one (lines 39–57)

app.get('/metrics', (_req, res) => {
  const mem = process.memoryUsage();
  res.json({
    loop: deps.loop.getMetrics(),
    game: deps.manager.stats(),
    sockets: deps.sockets.metrics,
    persistence: {
      enabled: deps.persistenceEnabled,
      queueDepth: deps.writeQueue.depth,
      inFlight: deps.writeQueue.active,
      ...deps.writeQueue.stats,
    },
    process: {
      rssMb: +(mem.rss / 1024 / 1024).toFixed(1),
      heapUsedMb: +(mem.heapUsed / 1024 / 1024).toFixed(1),
      uptimeSec: Math.round(process.uptime()),
    },
  });
});

Four groups of numbers, in order of usefulness:

loop — the health of the game. avgTickMs against budgetMs is the number that matters; everything else explains why it moved.

game — how many rooms and players exist.

sockets — connections, joins, and the two abuse counters (inputsRateLimited, inputsInvalid).

persistence — is the database connected, how deep is the write queue, how many writes failed or were dropped.

process — memory and uptime.

+(mem.rss / 1024 / 1024).toFixed(1) reads oddly, so unpack it:

  1. mem.rss is bytes, e.g. 189792256.
  2. / 1024 / 1024 converts to megabytes: 181.0.…
  3. .toFixed(1) rounds to one decimal — but returns a string, "181.0".
  4. The leading + converts it back to a number, 181.

Without the +, the JSON would contain "181.0" in quotes, and anything charting it would have to parse it.

...deps.writeQueue.stats spreads the queue's counters in alongside the two live values.

5. /api/rooms (lines 64–71)

app.get('/api/rooms', (req, res) => {
  const includeFinished = req.query.all === '1';
  const rooms = deps.manager
    .listRooms()
    .filter((room) => includeFinished || room.status !== 'finished')
    .map((room) => room.getSummary());
  res.json({ rooms, count: rooms.length });
});

Reads straight from memory. No database query — the list of live lobbies is not durable data.

?all=1 includes finished-but-not-yet-deleted rooms. That flag was added for a real reason: the stress test audits every lobby's scores, and without it the audit ran after all the games had finished, found zero rooms, and silently passed having checked nothing. A check that passes vacuously is worse than no check.

6. /api/rooms/:roomId (lines 73–80)

app.get('/api/rooms/:roomId', (req, res) => {
  const room = deps.manager.getRoom(String(req.params.roomId).toUpperCase());
  if (!room) {
    res.status(404).json({ error: 'room_not_found' });
    return;
  }
  res.json({ ...room.getSummary(), leaderboard: room.getLeaderboard() });
});

:roomId is a URL parameter — /api/rooms/WDZYAP puts WDZYAP in req.params.roomId.

.toUpperCase() so a lowercase URL still works.

The browser uses this to clean up stale sessions. On page load it asks about each remembered lobby; a 404 or status: 'finished' means that saved session is dead and gets removed. That fixed a real bug where a player who left mid-game was offered a "rejoin" into a lobby that had since ended.

7. The database-backed endpoints (lines 83–103)

app.get('/api/leaderboard', async (req, res) => {
  const limit = clampLimit(req.query.limit, 20, 100);
  try {
    res.json({ players: await deps.repository.getTopPlayers(limit) });
  } catch (err) {
    console.error('[api] leaderboard failed', err);
    res.status(503).json({ error: 'persistence_unavailable' });
  }
});

The only two endpoints that touch storage, and both are wrapped in try/catch.

503 means "service unavailable" — the right code, because the failure is temporary and the client may reasonably retry. A 500 would suggest a bug.

Note this is the only place await appears in the HTTP layer, and it is nowhere near the game loop.

8. Serving the game page (line 105)

app.use(express.static(path.join(__dirname, '..', '..', 'public')));

Serves everything in public/, so visiting / returns index.html.

The path walks up two folders. That works in both modes because of how the build is configured: in development __dirname is src/http, and after building it is dist/http — two levels up from either lands at the project root.

Registered last on purpose. Express tries routes in order, so all the /api routes get first refusal and only unmatched paths fall through to static files.

9. clampLimit (lines 109–113)

function clampLimit(raw: unknown, fallback: number, max: number): number {
  const parsed = Number(raw);
  if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
  return Math.min(Math.floor(parsed), max);
}

Query parameters arrive as text and are entirely under the caller's control.

RequestResultWhy
?limit=5050fine
?limit=abcfallbackNumber("abc") is NaN
?limit=-5fallbacknegative rejected
?limit=999999maxcapped
no limitfallbackNumber(undefined) is NaN

The cap is the point. Without it, ?limit=10000000 would ask PostgreSQL for ten million rows, serialise them all to JSON, and take the server down. One small function removes that entire class of problem.

Math.floor because ?limit=5.7 should be 5, not a fractional LIMIT.


Endpoint summary

EndpointReads fromNotes
GET /healthnothingliveness only
GET /metricsmemorytick budget, drift, sockets, queue
GET /api/roomsmemory?all=1 includes finished
GET /api/rooms/:idmemory404 when gone
GET /api/leaderboardPostgreSQL?limit, capped at 100
GET /api/sessionsPostgreSQL?limit, capped at 50
everything elsepublic/the game page

If an interviewer asks

"How would you know if the server were struggling?"

"/metrics. Specifically loop.avgTickMs against loop.budgetMs, and overrunTicks. At 2,000 players it used 13 ms of a 50 ms budget with zero overruns. The sim-versus-broadcast split in there is what told me the bottleneck was sending, not simulating."

"Why doesn't /health check the database?"

"Because the game works without it. If health failed when Postgres was down, an orchestrator would restart a perfectly healthy server. Database status is reported separately under persistence.enabled."

"What stops someone requesting a million rows?"

"clampLimit caps it — 100 for the leaderboard, 50 for sessions — and falls back to a default for anything non-numeric or negative."

Built with LogoFlowershow