src/server.ts — wiring everything together

In one sentence: the one place where every piece is connected to every other piece.

Size: 96 lines. Depends on: all the other layers.


Why this file exists

Every other file declares what it needs and receives it. Somebody has to actually create the objects and hand them out. That is this file, and the pattern has a name: the composition root.

The rule: dependencies are wired here and nowhere else.

What that buys:

  • An integration test builds a complete server on a random free port with an in-memory repository — one function call.
  • Production swaps in PostgreSQL by passing one argument.
  • You can see the entire shape of the system by reading 40 lines.

The code, line by line

1. What comes back (lines 11–24)

export interface BuiltServer {
  httpServer: http.Server;
  manager: GameManager;
  loop: GameLoop;
  sockets: SocketServer;
  repository: GameRepository;
  writeQueue: WriteQueue;
  config: GameConfig;
  start(port: number): Promise<number>;
  stop(): Promise<void>;
}

Every piece is exposed, which matters for testing. An integration test reaches into server.manager to place a player at exact coordinates, then checks what the game did — something impossible if these were hidden.

start returns a Promise<number> — the port actually bound. That is the key to tests: pass port 0 and the operating system picks any free one, so tests never collide with a running dev server.

2. Options (lines 26–30)

export interface BuildOptions {
  config?: Partial<GameConfig>;
  repository?: GameRepository;
  seed?: number;
}

All optional. buildServer() with no arguments gives a working server.

Partial<GameConfig> means "some settings, not all". A test overrides three values and inherits the rest:

buildServer({
  config: { countdownMs: 0, collectibleCount: 5, maxPlayersPerRoom: 4 },
  repository: new InMemoryGameRepository(),
  seed: 1234,
});

seed makes room codes predictable in tests.

3. Config and repository (lines 40–43)

const config: GameConfig = { ...loadGameConfig(), ...options.config };
const repository = options.repository ?? new InMemoryGameRepository();
const persistenceEnabled = !(repository instanceof InMemoryGameRepository);

{ ...loadGameConfig(), ...options.config } — spread the environment-based config first, then let explicit options override. Later spreads win.

?? new InMemoryGameRepository() — no repository supplied means in-memory. So a test that forgets to pass one still works.

instanceof checks which implementation arrived, purely so /metrics can report whether results are actually durable.

4. The write queue (lines 45–50)

const writeQueue = new WriteQueue({
  concurrency: 10,
  maxQueueDepth: 5000,
  onError: (err) => console.error('[persistence] write failed', err),
  onDrop: (total) => console.warn(`[persistence] shed backlog, ${total} dropped total`),
});

The logging callbacks live here, not inside WriteQueue. The queue reports what happened; this file decides that reporting means writing to the console.

That separation is why WriteQueue has no logging dependency and is trivial to test.

5. The game (lines 52–53)

const manager = new GameManager(config, options.seed);
new PersistenceSubscriber(repository, writeQueue).attach(manager);

Two lines that create the entire game and connect it to storage.

Note the second line creates an object and immediately calls .attach() without keeping a reference. That is intentional — once attached, the subscriber lives inside the manager's handler list and nothing else needs to talk to it.

This is the moment persistence becomes an observer rather than something the game depends on. If you deleted this line, the game would still work perfectly; it just would not save anything.

6. HTTP, sockets and the loop (lines 55–57)

const httpServer = http.createServer();
const sockets = new SocketServer(httpServer, manager, config);
const loop = new GameLoop(manager, config, () => sockets.broadcastAll());

The order is forced by the dependencies:

  1. httpServer first, created with no request handler yet.
  2. sockets attaches to it. WebSocket connections begin as ordinary HTTP requests that ask to be upgraded, so Socket.IO needs the HTTP server.
  3. loop last, because it needs both the manager and the sockets.

() => sockets.broadcastAll() is the callback the loop calls each tick. The loop has no idea what it does — it is just "the thing to run after simulating". That is why GameLoop can be tested with an empty function.

7. Express last (lines 59–69)

const app = createApp({ manager, loop, sockets, repository, writeQueue, config, persistenceEnabled });
httpServer.on('request', app);

Express is attached after Socket.IO, and the order matters.

Socket.IO needs to intercept requests to /socket.io/ before Express sees them. Attaching Express as a plain request listener means Socket.IO gets first look at every request and only passes on the ones it does not want.

8. start (lines 80–90)

start(port: number): Promise<number> {
  return new Promise((resolve, reject) => {
    httpServer.once('error', reject);
    httpServer.listen(port, () => {
      const address = httpServer.address();
      const boundPort = typeof address === 'object' && address ? address.port : port;
      loop.start();
      resolve(boundPort);
    });
  });
}

listen uses a callback, so it is wrapped in a promise to make it awaitable.

httpServer.once('error', reject) catches the common failure: the port is already in use. Without it, that error would be unhandled and crash the process with a confusing message.

httpServer.address() returns the port actually bound. When you pass 0, the OS chooses a free one — this is how tests get an available port without guessing.

loop.start() is inside the listen callback, so the game only starts ticking once the server is genuinely accepting connections.

9. stop — shutting down in order (lines 92–99)

async stop(): Promise<void> {
  loop.stop();
  await sockets.close();
  await new Promise<void>((resolve) => httpServer.close(() => resolve()));
  await writeQueue.flush(5000);
  await repository.disconnect();
}

The order is the reverse of startup, and each step has a reason:

  1. loop.stop() — stop ticking first. No point simulating a game nobody can see.
  2. sockets.close() — disconnect players. They get a clean close rather than a dropped connection.
  3. httpServer.close() — stop accepting new requests.
  4. writeQueue.flush(5000)give pending saves up to 5 seconds to finish. This is the step people forget. Without it, a game that ended moments before shutdown would be lost.
  5. repository.disconnect() — close the database connection, but only after the writes have landed.

The 5-second cap matters: if the database is completely down, shutdown must not hang forever.


The whole system in one picture

buildServer()
    ├── config          ← environment + overrides
    ├── repository      ← Postgres, or in-memory
    ├── writeQueue      ← bounded, never blocks the game
    ├── manager ────────── the game (rooms live in here)
    │      └── PersistenceSubscriber listens for "game over"
    ├── httpServer
    │      ├── sockets  ← WebSocket, attached first
    │      └── app      ← Express, attached second
    └── loop ──────────── ticks 20×/s, calls sockets.broadcastAll()

How tests use it

server = buildServer({
  repository,
  seed: 1234,
  config: { countdownMs: 0, collectibleCount: 5, maxPlayersPerRoom: 4, tickRateHz: 30 },
});
const port = await server.start(0);
url = `http://localhost:${port}`;

A complete server — game loop, WebSockets, HTTP — on a random free port, with no database, in one call. Seventeen integration tests share that one setup.


If an interviewer asks

"Why is all the wiring in one file?"

"It's the composition root. Every other file declares what it needs and receives it, so one place decides what's actually supplied. That's what lets a test build a complete server with an in-memory repository on a random port, and production swap in PostgreSQL by passing one argument."

"Why does the game loop take a callback instead of the socket server?"

"So it doesn't depend on Socket.IO. The loop's job is 'tick, then run this function'. A test passes an empty function and tests the timing in isolation."

"What happens to a game that finishes during shutdown?"

"stop() flushes the write queue for up to five seconds before closing the database connection, so pending results still land. The cap is there so a dead database can't make shutdown hang."

Built with LogoFlowershow