src/index.ts — starting the process
src/index.ts — starting the process
In one sentence: the first file that runs. It picks a database, starts the server, and shuts down cleanly when asked.
Size: 110 lines. Depends on: server.ts, the repositories, dotenv.
Why this file exists
server.ts knows how to build a server. This decides which server to build
and handles the messy realities of being a process: environment variables,
missing databases, and shutdown signals.
Keeping those concerns here means server.ts stays clean enough for tests to use
directly.
The code, line by line
1. Loading the environment (line 1)
import 'dotenv/config';
An import with no name — it runs the module for its side effect. This reads the
.env file and copies its contents into process.env.
It must be first, before anything that reads a setting. An import lower down would run too late and every variable would be undefined.
2. main (lines 9–26)
async function main(): Promise<void> {
const port = Number(process.env.PORT ?? 3000);
const repository = await createRepository();
const server = buildServer({ repository });
const boundPort = await server.start(port);
console.log(`[server] listening on http://localhost:${boundPort}`);
console.log(`[server] tick rate ${server.config.tickRateHz}Hz`);
console.log(
`[server] ${server.config.maxPlayersPerRoom} players/lobby, ` +
`${server.config.collectibleCount} collectibles`,
);
console.log(
`[server] results persistence: ${
repository instanceof InMemoryGameRepository ? 'OFF (in-memory)' : 'ON (PostgreSQL)'
}`,
);
process.env.PORT ?? 3000 — use the environment variable, or 3000 if it is not
set.
The startup log is deliberately informative. The last line especially:
[server] results persistence: ON (PostgreSQL)
That line was added because of a real problem. Persistence used to fail silently — you would play a full game, look in the database, find nothing, and have no idea why. Now the server states plainly whether results are being saved.
3. Graceful shutdown (lines 28–40)
const shutdown = async (signal: string): Promise<void> => {
console.log(`[server] ${signal} received, shutting down`);
try {
await server.stop();
process.exit(0);
} catch (err) {
console.error('[server] shutdown failed', err);
process.exit(1);
}
};
process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('SIGTERM', () => void shutdown('SIGTERM'));
Two signals, both meaning "please stop":
- SIGINT — you pressed Ctrl+C.
- SIGTERM — Docker, Kubernetes, or a process manager asking politely.
Without these handlers the process would die instantly, and any game that had just
finished would never be saved. server.stop() flushes the write queue first.
process.exit(0) means success; 1 means failure. Orchestrators read that code.
() => void shutdown(...) — the void operator says "I am deliberately not
waiting for this promise". Without it, linters warn about an unhandled promise.
4. createRepository — choosing storage (lines 48–95)
async function createRepository(): Promise<GameRepository> {
if (process.env.PERSISTENCE === 'off') {
console.log('[persistence] disabled (PERSISTENCE=off), using in-memory repository');
return new InMemoryGameRepository();
}
The explicit opt-out, used by the stress test. There is no point writing 100 game results while measuring tick performance.
try {
const { PrismaClient } = await import('@prisma/client');
const { PrismaGameRepository } = await import('./persistence/PrismaGameRepository');
const prisma = new PrismaClient();
await prisma.$queryRaw`SELECT 1`;
console.log(`[persistence] connected to PostgreSQL (${describeDatabaseUrl()})`);
return new PrismaGameRepository(prisma);
await import(...) is a dynamic import — loading a module at runtime rather
than at startup. Used here so the server can start even if the Prisma client has
not been generated yet. A normal top-level import would crash before any of our
code ran.
await prisma.$queryRawSELECT 1“ — the simplest possible query, purely to prove
the connection works. Prisma connects lazily, so without this the first failure
would happen much later, in the middle of a game.
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (process.env.PERSISTENCE === 'required') {
console.error([
'',
'[persistence] FATAL: PERSISTENCE=required but PostgreSQL is unreachable.',
` url: ${describeDatabaseUrl()}`,
` error: ${message}`,
'',
].join('\n'));
process.exit(1);
}
Strict mode. With PERSISTENCE=required, an unreachable database stops the
server rather than letting it run without saving. That is what you would set in
production, where losing game history is not acceptable.
console.warn([
'',
' ***************************************************************',
' * WARNING: PostgreSQL is UNREACHABLE. *',
' * The game will run, but NO RESULTS WILL BE SAVED. *',
' ***************************************************************',
` url: ${describeDatabaseUrl()}`,
` error: ${message}`,
' fix: docker compose up -d (then restart this server)',
' strict: PERSISTENCE=required (fail fast instead of degrading)',
'',
].join('\n'));
return new InMemoryGameRepository();
}
}
The default: degrade, but loudly.
The design decision is that gameplay is never blocked on storage being healthy. The cost of that is a failure mode where everything looks fine but nothing is saved — so the warning is deliberately impossible to miss, and it tells you the fix and the strict alternative.
The array-joined-with-newlines style avoids a long string of \n escapes, which
are easy to get wrong.
5. describeDatabaseUrl (lines 98–107)
function describeDatabaseUrl(): string {
const raw = process.env.DATABASE_URL;
if (!raw) return 'DATABASE_URL is not set';
try {
const u = new URL(raw);
return `${u.hostname}:${u.port || '5432'}${u.pathname}`;
} catch {
return 'unparseable DATABASE_URL';
}
}
Shows which database was attempted, without leaking the password.
DATABASE_URL looks like:
postgresql://game:game@localhost:5433/gamedb?schema=public
That contains a username and password. Printing it to a log would put credentials somewhere they should never be. This prints only:
localhost:5433/gamedb
Enough to spot the common mistake — pointing at port 5432 when this project runs on 5433 — with nothing sensitive.
The try/catch handles a malformed URL, since new URL() throws on invalid
input.
6. Starting (line 109)
void main();
main is async, so it returns a promise. void states plainly that nothing is
waiting for it.
The three persistence modes
| Setting | Database reachable | Result |
|---|---|---|
unset or on | yes | PostgreSQL. Normal. |
unset or on | no | in-memory, loud warning. Game works, nothing saved. |
off | — | in-memory, quiet. Used by the stress test. |
required | no | refuses to start, exit code 1. |
All four verified:
=== DB unreachable, default mode ===
***************************************************************
* WARNING: PostgreSQL is UNREACHABLE. *
* The game will run, but NO RESULTS WILL BE SAVED. *
***************************************************************
[server] listening on http://localhost:3101
[server] results persistence: OFF (in-memory)
=== DB unreachable, PERSISTENCE=required ===
[persistence] FATAL: PERSISTENCE=required but PostgreSQL is unreachable.
exit code: 1
If an interviewer asks
"What happens if the database is down when the server starts?"
"By default it degrades to in-memory with a loud banner warning, because gameplay shouldn't be blocked on storage. With
PERSISTENCE=requiredit refuses to start instead — that's what you'd set in production."
"Why the dynamic import for Prisma?"
"So the server can start even if the Prisma client hasn't been generated. A top-level import would crash before any of our code ran, with a confusing error."
"Why print the database host at startup?"
"Because a silent fallback is a genuinely confusing failure — you play a game, find an empty database, and have no idea why. It prints host, port and database name with the credentials stripped, which also catches the common mistake of pointing at the wrong port."