Architecture
Status: draft v0.1, 2026-11-09. Client evidence in
docs/research/client-stack.md. Principle in force: build the simple version of something that can scale, not the scalable version of something simple. We refactor when reality asks, not before.
1. The three ideas everything rests on
1.1 The galaxy is a pure function
galaxy(seed, coordinates) → star, planets, resources
Deterministic, no I/O, no state. Nothing about an untouched system is stored anywhere. The database only ever holds deltas: what players discovered, claimed, built, mined out or destroyed.
- Storage for the universe: zero, regardless of size.
- The client can generate and render any region locally from the seed; the server ships only deltas and the things it is willing to reveal.
- No world-generation step, no size cap, no migration when the galaxy grows.
The same function must produce bit-identical results on client and server. That constraint alone justifies sharing one language across both.
The one exception, and it is bounded. Rare deposits deplete permanently and re-emerge in unsafe space (ECONOMY.md §2.2), so where the rare things are is not a pure function of the seed — it is seed plus accumulated state. This is fine and must stay fine:
- The seed still generates the potential: which bodies could ever host a rare deposit. That set never changes and costs nothing.
- The database stores only which of those have been worked out, and which have surfaced — a delta per touched body, exactly like everything else.
- Migration is a job (§3), not a simulation: when a deposit depletes, one row is written and one new site is chosen from the seed-defined candidate set, weighted toward unsafe systems.
So the client can still render a region from the seed alone; it simply cannot know where the good rocks are without asking, which is correct — that information is a game mechanic, not geometry.
1.2 Nothing ticks
State that changes with time is computed on read from a timestamp, never advanced by a background job.
- Tax ledger = one row per colony per cycle, written by the daily tax sweep and read as a sum; nothing accrues between cycles.
- Build queues =
started_at + duration; completion is derived, then lazily written when read. - Fleet position = interpolated from
departed_at,arrives_at, origin and destination. - Security and tax = arithmetic over a handful of stored integers (
SECURITY-AND-SOVEREIGNTY.md§8).
A million idle players cost nothing. There is no cron that touches every row, ever. This is the single biggest reason this design can reach the scale Jonathan asked for without becoming complicated.
1.3 Geography is the shard key
Travel across the galaxy takes days to weeks by design (GAME-DESIGN.md §4.3). Players live in a region; interaction is overwhelmingly local.
So when one database stops being enough, the galaxy partitions by region — and cross-partition traffic is rare, slow, and tolerant of asynchrony, because in the fiction it already is. The sharding strategy is free: it is the map.
We do not build this now. We build so that it stays possible: region id present in every spatial row from day one, no query that assumes a global view, no cross-region transaction in a hot path.
2. Stack
| Layer | Choice | Why |
|---|---|---|
| Language | TypeScript, client and server | Galaxy generation and combat resolution must be one implementation shared by both. Two implementations of a deterministic function is a bug factory. |
| Runtime | Bun | TypeScript with no build step, built-in test runner, bundler, Postgres and SQLite drivers. Fewer moving parts is the brief. [DECISION PENDING] — confirm production maturity vs Node 26 before locking; Node is the fallback and the port is trivial. |
| HTTP | Hono | Tiny, fast, excellent TypeScript, runtime-agnostic so the Bun bet is reversible. |
| Database | PostgreSQL 17 | One store, boring, already the house pattern on the prod box. JSONB where the shape is genuinely variable; real columns everywhere else. |
| Schema | SQL migrations, plain files, applied in order | No ORM. Hand-written SQL with a thin typed query layer. |
| Push | Poll first, SSE when it matters | Check-in play barely needs push. SSE survives proxies, reconnects by itself, and costs one HTTP connection. WebSockets only if the live-orders layer ships. |
| Auth | Passkeys (WebAuthn), optional recovery email | No social logins — the no-tracking pillar forbids them. No passwords to leak. |
| Client | React 19.3 + Three.js 0.187 (WebGL2) + react-three-fiber 9.8 + Tailwind 4 + Vite 8, installable PWA, no native wrapper | One full-viewport canvas with dense DOM UI over it. Three is 189 KB gzip against Babylon's 694 KB, measured. WebGPU sits behind a createRenderer() seam for later. Evidence and alternatives: docs/research/client-stack.md. |
| Assets | GLB + meshopt + KTX2, generated by headless Blender 5.2.3 LTS | Scripted in Python, versioned as code, no human in the loop. See ART.md. |
Deliberately absent
No Redis, no Kafka, no message broker, no microservices, no GraphQL, no Kubernetes, no ORM, no state container framework. Every one of these is a thing to add the day it solves a problem we actually have, and not before. Postgres is the queue, the cache and the lock manager until it demonstrably isn't.
3. Data model sketch
Only the shapes that matter. All spatial rows carry region_id.
| Table | Holds | Notes |
|---|---|---|
accounts | credentials, recovery | minimal PII by policy |
characters | identity, reputation, skills | the player's face in the world |
systems | delta only: discovery, controlling colony, paying-the-Empire flag, taxed_at | row exists only once touched |
bodies | delta only: depletion, structures present | row exists only once touched |
structures | gates, refineries, fabricators, defences | the gate rows are also the lane graph |
fleets | composition, location or transit, doctrine | |
transits | from, to, departed_at, arrives_at, mode | position is derived, never stored |
queues | production jobs: started_at, duration, output | completion derived, lazily materialised |
stacks | inventory at a location | |
orders | market orders, per station | |
contracts | hauling, mercenary, collateral | |
corporations, memberships, standings | politics | |
reports | player-facing event log, combat logs | the check-in loop reads this first |
jobs | due_at scheduled work | the only tick in the system |
The only scheduler
One table, jobs, with a due_at index. A worker claims rows with SELECT ... FOR UPDATE SKIP LOCKED and runs them. It handles exactly three things:
- Arrivals — a transit completes: resolve combat, apply loot, write reports.
- Tax sweep — daily, over claimed systems only. That set is bounded by what the playerbase can afford to hold, which is the only set that matters.
- Lapse and decay — the lapse ramp, structure ruin, contract expiry.
This is one small worker process, horizontally scalable by adding more of them, with no coordination beyond SKIP LOCKED. It is the entire background infrastructure of the game.
Read a system and the API derives its current state from a timestamp and a handful of integers. Nothing in this picture polls, ticks, or sweeps — the worker only ever wakes for work that is genuinely due.
4. Shared core
A package used verbatim by client, server and the balance simulator:
galaxy/— the seeded generator. Deterministic, exhaustively tested for determinism, versioned: changing it changes the universe, so it gets an explicit version number and a migration story.combat/—(fleets, doctrines, environment, seed) → log. Pure. Tested properly (COMBAT.md§9).economy/— tax, security, refining, travel time, price band. Pure arithmetic, tested properly.rules/— shared constants and types.
Everything else — the HTTP layer, the UI, persistence — is plumbing around these four. Plumbing gets smoke-tested, not unit-tested (AGENTS.md §3).
5. Security posture
- Server-authoritative without exception. The client may predict anything (it has the galaxy function, it can render futures) but it never reports an outcome.
- Every mutation validated against server-held state. The client is assumed hostile.
- Rate limits on every client-callable endpoint, by account and by IP.
- Visibility is enforced server-side: the API must never return what a character has not discovered or cannot see. Intel is a game mechanic (
SECURITY-AND-SOVEREIGNTY.md§7), so a leak in the API is a leak in the design. - No third-party scripts on the client. None. This is a pillar, not a preference.
6. Scaling path
Written down so nobody builds step four on day one.
- Now — one app process, one Postgres, one worker. Comfortably serves tens of thousands of check-in players on the existing hardware.
- Pressure on reads — read replicas; the app is stateless, so add processes.
- Pressure on writes — partition by
region_id. Cross-region interaction is already rare and slow in the fiction. - Hot spots — the market and chat are the only plausible candidates for extraction into their own service. Not before they hurt.
Observability comes from the Prometheus/Loki/Grafana stack already running on the prod box (INFRA.md). Server-side, aggregate, anonymous — never per-player tracking.
7. Open questions
- [ ] Bun or Node 26 for the server runtime. Decide with evidence before first code.
- [ ] How is galaxy-function versioning handled if we ever need to change generation? Freeze v1 for existing regions and generate new space with v2? Probably.
- [ ] Does the client ship the galaxy function (fast, offline-capable, but reveals generation rules) or ask the server (slower, hides nothing worth hiding)? Leaning: ship it — the seed-to-star mapping is not a secret, what is out there and who owns it is.
- [ ] Postgres as the market matching engine — fine at this scale, but worth a load test early since it is the most write-contended thing in the game.
- [ ] Replay delivery: seed + order list and let the client re-simulate, or ship the resolved log? Re-simulation is a few hundred bytes and proves determinism in production. Leaning re-simulation.
Changelog
- 2026-11-09 — v0.1. Pure-function galaxy, compute-on-read state, geography as shard key, single-language TypeScript core shared client/server, Postgres-only persistence, one
jobstable as the entire scheduler. - 2026-11-09 — v0.2. §1.2 no longer describes resource accrual, which
GATHERING.md§6 forbids: compute-on-read now covers queue completion, transit position and the tax ledger. Deposit migration targets unsafe systems rather than low-security ones. Thestructuresrow loses beacons, and the lapse job no longer claims security decay.