Architecture
How the backend is put together and why
The stack
| Layer | Choice | Why |
|---|---|---|
| HTTP | Hono on @hono/node-server | Tiny, fast, Web-standard Request/Response, first-class OpenAPI |
| Validation | Zod (schemas in @repo/shared) | One schema → runtime validation + TS types + OpenAPI docs |
| Docs | @hono/zod-openapi + Scalar | Docs generated from the code, can't drift |
| ORM | Drizzle (Postgres dialect) | Typed SQL, real migrations, same code on PGlite and Postgres |
| Auth | better-auth | Sessions, email+password now, OAuth (Steam/Discord) later |
| Build / test | tsup / Vitest | Fast, zero-config |
App factory pattern
Everything is constructed explicitly and passed down — no globals, no import-time side effects:
// index.ts (the only place with side effects)
const env = loadEnv();
const { db, close } = await createDb({ databaseUrl: env.DATABASE_URL });
const auth = createAuth(db, env);
const discord = createDiscordNotifier(env);
const steam = createSteamClient(env);
const app = createApp({ db, auth, env, discord, steam });The AppContext (src/context.ts) is the seam that makes tests trivial: makeTestApp() builds the same app on an in-memory database and calls app.request() directly — no port, no mocks, no network.
Database strategy
createDb picks a driver by environment, everything downstream is identical:
DATABASE_URLset (production):pgPool against real Postgres.- Unset (local dev): PGlite — Postgres compiled to WASM, in-process, persisted to
apps/api/.data/pglite. - Tests: PGlite fully in-memory, fresh per suite.
Migrations are plain SQL generated by drizzle-kit into apps/api/drizzle/ and run automatically on boot on all three. See Database.
Route structure
Each route group is a factory taking AppContext and returning an OpenAPIHono sub-app with createRoute definitions built from @repo/shared schemas:
src/routes/wishlist.ts → /api/wishlist
src/routes/invites.ts → /api/invites
src/routes/achievements.ts → /api/achievementsapp.ts mounts them, plus better-auth's handler on /api/auth/*, the Scalar UI on /docs, and the spec on /openapi.json.
Where multiplayer will live
Not in this API. The Rust sim is deterministic (same seed + inputs → identical world, byte for byte), which makes lockstep networking the natural fit — that wants a dedicated realtime server (likely Rust, reusing the sim crate), not an HTTP framework. This API stays the authority for identity: game servers validate better-auth session tokens against it. Meta-services that fit HTTP fine — leaderboards, cloud saves, matchmaking brokering — land here.