6. The HTTP layer

src/routes/src/controllers/src/services/, with src/middleware/ for validation and error formatting.

HTTP handles what is genuinely request/response. It deliberately does not seat players — that happens over the socket, because picking a lobby and taking a seat in it must be one uninterrupted step.


Endpoints

MethodPathHandlerPurpose
POST/api/lobbiescreateLobbyHandlerOpen a private lobby, get a join code
GET/api/lobbieslistLobbiesHandlerWhat is running right now
GET/api/lobbies/:codegetLobbyHandlerCheck a code before using it
GET/api/leaderboardgetLeaderboardHandlerAll-time table (Postgres)
GET/api/gamesgetRecentGamesHandlerRecent match history
GET/healthinline in app.jsLobby counts, tick timings, memory

src/routes/

Route tables and nothing else — no logic, so the URL structure is readable at a glance.

// lobby.routes.js
router.post('/',      createLobbyHandler);
router.get('/',       listLobbiesHandler);
router.get('/:code',  getLobbyHandler);

Mounted in app.js as /api/lobbies and /api.

Ordering note: GET / is registered before GET /:code, so the collection route is never shadowed by the parameterised one.


src/controllers/

Thin glue: parse the request, call a service or the lobby manager, send the result. Every query lives in a service so it can be tested without an HTTP request.

lobby.controller.js

summarise(game) — the one place a lobby is converted for public consumption:

{ gameId, code, status, players, connected, maxPlayers,
  remaining, collectibleCount, joinable }

It never leaks player positions, usernames or reconnect tokens. A single shared shaping function means a field can't accidentally appear in one endpoint but not another.

  • createLobbyHandlercreateLobby(), responds 201.
  • listLobbiesHandler — every ACTIVE lobby, plus getStats() and the configured limits.
  • getLobbyHandlerfindByCode(), or throw appError(404, 'LOBBY_NOT_FOUND').

A synchronous throw is safe here: Express 4 catches synchronous throws in route handlers and routes them to the error middleware.

A lobby created here starts empty, which the broadcaster must not mistake for "everyone left" — hence the playerSeq > 0 check described in 05-realtime.md. It is swept after emptyLobbyTtlMs if nobody ever joins.

leaderboard.controller.js

Both handlers are async and use try/catchnext(err):

export async function getLeaderboardHandler(req, res, next) {
  try {
    res.json({ leaderboard: await getAllTimeLeaderboard({ limit: req.query.limit }) });
  } catch (err) {
    next(err);
  }
}

Express 4 does not catch rejected promises, so an async handler that throws without this would hang the request until it timed out. Forwarding with next(err) means all HTTP failures are formatted in exactly one place.


src/middleware/validate.js

Everything crossing the network boundary goes through here, whether it arrived over HTTP or over a socket.

Hand-rolled rather than a schema library. With this few fields, zod would be a dependency earning its keep only in comments — but it is what you would reach for once payload shapes multiply.

validateUsername(raw)

const USERNAME_PATTERN = /^[\p{L}\p{N} _-]+$/u;
const value = raw.trim().replace(/\s+/g, ' ');
  • Whitespace is collapsed so " a b " and "a b" cannot masquerade as different players — which matters because usernames are unique per lobby.
  • The character set is deliberately narrow: Unicode letters and digits, space, -, _. A username is rendered in other people's browsers, so the smaller the allowed set the better. \p{L} and \p{N} with the u flag keep non-Latin names working.
  • Length 2–16 from config.

Returns { ok, value } or { ok, reason, message } — a machine-readable code for tests and clients, plus human text for display.

validateDirection(payload)

Two finite numbers, nothing else. Rejects NaN, Infinity and non-objects, which is what stops a player position becoming NaN permanently. Magnitude is not checked heresetInput normalises it, so this only rejects nonsense that hints at a tampered client.

validateId(raw, { maxLength })

Non-empty trimmed string within a length cap.


src/middleware/errorHandler.js

appError(message, { status, code })

A factory, not a subclass of Error:

export function appError(message, { status = 400, code = 'BAD_REQUEST' } = {}) {
  const err = new Error(message);
  err.status = status;
  err.code = code;
  return err;
}

The only thing a class would buy is instanceof, and the handler branches on status instead. throw appError(...) still throws a real Error, so stack traces are unaffected — and it keeps the codebase free of classes.

notFoundHandler

Unmatched routes become clean JSON rather than Express's HTML default:

{ "error": { "code": "NOT_FOUND", "message": "No route for /api/nope" } }

errorHandler(err, req, res, next)

The single place HTTP errors become responses.

message: status >= 500 ? 'Something went wrong.' : err.message

A 500 never leaks its internal message or stack to the client — it is logged server-side instead. A 4xx message is safe because we authored it.

next must stay in the signature even though it is unused: Express identifies error middleware by its arity of four. Dropping it silently turns this into ordinary middleware that never runs.


Response shape

Success returns the resource directly. Failure is always:

{ "error": { "code": "MACHINE_READABLE", "message": "Human readable." } }

Socket errors mirror this with { ok: false, reason: 'CODE' }, so both transports speak the same vocabulary — LOBBY_NOT_FOUND, LOBBY_FULL, USERNAME_TAKEN mean the same thing whether they arrive over HTTP or a websocket.

Built with LogoFlowershow