9.1 KiB
Tech Stack — Deadhead: Space Freight Tycoon
Companion to game-design-document.md.
TL;DR
TypeScript end to end, in one monorepo.
| Layer | Choice |
|---|---|
| Language | TypeScript (client, server, shared sim) |
| Monorepo | pnpm workspaces |
| Client UI | React + Vite |
| Client state | Zustand (game state) + TanStack Query (server sync) |
| System map rendering | PixiJS (via @pixi/react) |
| Styling | Tailwind CSS |
| PWA | vite-plugin-pwa (Workbox) |
| Native wrapper | Capacitor (iOS/Android) |
| API server | Node + Fastify |
| Database | PostgreSQL |
| ORM / migrations | Drizzle ORM |
| Validation | Zod (shared schemas, client + server) |
| Auth | Better Auth (self-hosted) — or Supabase Auth if going managed |
| Job queue / scheduler | pg-boss (Postgres-backed, no Redis needed) |
| Push notifications | web-push (VAPID) for PWA; FCM/APNs via Capacitor for native |
| Planetary positions | astronomy-engine |
| Seeded RNG | Small pure-TS PRNG (e.g., sfc32 / mulberry32) |
| Testing | Vitest (unit/sim), Playwright (E2E) |
| Deployment | Docker containers (API + Postgres), static client on a CDN |
1. Why TypeScript Everywhere
The single most important architectural decision is the deterministic dispatch model (GDD §14): a trip's arrival time, position, and events are all derived from its dispatch record and RNG seed.
That logic has to run in two places:
- Client — to show ships moving along their routes, display ETAs, and preview a contract before dispatch.
- Server — as the authority that resolves payouts, XP, and events.
If both sides run the same code, they can never disagree. A shared TypeScript package gives you that for free. With a C# or Python backend you'd be maintaining two implementations of the sim and chasing drift bugs forever.
2. Repo Layout
/apps
/web React + Vite client (also the Capacitor app)
/server Fastify API, workers
/packages
/sim Pure, deterministic game logic (no I/O)
/schema Zod schemas + shared types (API contracts)
/content Static game data: ships, destinations, goods, skills
packages/sim rules
- Pure functions only. No
Date.now(), noMath.random(), no network, no DB. - Time is always passed in as an argument.
- Randomness comes only from a seeded PRNG derived from the dispatch seed.
- Fully unit-testable: given a dispatch record + timestamp, the output is always identical.
Core functions (illustrative):
travelTime(route, ship, cargo, throttle): Duration
positionAt(dispatch, now): Vec2
rollEvents(dispatch): GameEvent[] // pre-rolled at dispatch time
resolveDispatch(dispatch, now): Resolution // payout, XP, damage, discovery
generateContracts(bay, worldState, seed): Contract[]
packages/content
Ships, destinations, goods, and skills live as typed data files (TS or JSON validated by Zod). This makes balancing a data edit, not a code change, and makes the content easy to hot-reload during tuning.
3. Client
React + Vite
The game is overwhelmingly UI: contract boards, fleet lists, pilot sheets, bay management. That's what React is good at. A game engine would be fighting you on every menu.
PixiJS for the system map
The one truly "game-like" view is the solar system map with ships moving along routes. PixiJS gives GPU-accelerated 2D rendering that stays smooth on phones, embedded inside the React app as a single component.
- Planets positioned from
astronomy-engine(scaled/compressed for readability — soft sci-fi). - Ships interpolated along routes with
sim.positionAt()every frame. - Pan/zoom with touch support.
State
- TanStack Query owns server data (fleet, contracts, company) — caching, refetch on focus, optimistic updates on dispatch.
- Zustand for local UI and derived real-time state (current clock, selected ship, map camera).
Server time on the client
On connect, the client fetches server time and stores the offset. All "now" values used for rendering come from Date.now() + serverOffset, so a wrong device clock only affects visuals and is never trusted for outcomes.
PWA + Capacitor
vite-plugin-pwamakes the web build installable with offline asset caching.- Capacitor wraps the same build for the App Store and Play Store when you want that. It gives more reliable push notifications and native polish without a second codebase.
- Web Push works on installed PWAs on iOS 16.4+, so Capacitor can wait until after launch.
4. Server
Fastify
Fast, TypeScript-friendly, schema-based validation (pairs well with Zod via fastify-type-provider-zod). Small surface area.
API shape
Plain REST/JSON is enough. This isn't a twitch-multiplayer game; nothing needs WebSockets at launch. The client polls/refetches on focus, which is exactly the "pick up the phone, see what happened" pattern.
Server-authoritative resolution
- Dispatch: server validates (ship available, pilot qualified, landing class OK, fuel range OK), stamps departure time, generates the seed, pre-rolls events via
sim, and stores the record. - Resolution: happens lazily when the player loads state and via a scheduled job at arrival time (so notifications fire and the economy stays consistent even if the player never opens the app).
- Resolution must be idempotent: a dispatch resolves exactly once, guarded by a status column + transaction.
PostgreSQL + Drizzle
- Relational data fits this game well: companies, ships, pilots, bays, contracts, dispatches.
- Drizzle is TypeScript-first, light, and its schema can live in
packages/schemaalongside Zod types. - JSONB columns for flexible bits (pre-rolled event lists, ship loadouts).
pg-boss for scheduled work
Postgres-backed job queue, so you don't need to run Redis.
- Schedule an "arrival" job per dispatch → resolve + send push notification.
- Periodic jobs: regenerate contract boards, recompute fuel prices, run automation rules (standing routes).
Auth
- Better Auth: self-hosted, TypeScript-native, supports email/password, OAuth (Google/Apple/Discord), and anonymous accounts.
- Strongly recommend guest accounts upgradable to full accounts — players can start instantly on mobile and link an account later to sync across devices.
- If you'd rather not run auth yourself, Supabase (Auth + Postgres) is the managed alternative and plays fine with Drizzle.
5. Notifications
| Platform | Mechanism |
|---|---|
| Web / installed PWA | Web Push with VAPID keys (web-push library) |
| Capacitor iOS | APNs via @capacitor/push-notifications |
| Capacitor Android | FCM via @capacitor/push-notifications |
Fired from the pg-boss arrival job. Batch notifications when many ships arrive at once ("4 ships arrived — +12,400 cr").
6. Planetary Positions
astronomy-engine computes real planetary positions for any date, in pure JS, no API calls.
- Run it in
packages/simso client and server agree on distances. - Use a fixed game epoch mapped to real ephemeris dates, advanced by the compressed game clock (1 real min = 1 game hour by default).
- Moons and stations inherit their parent body's position plus a small fixed offset — soft sci-fi doesn't need lunar orbits.
- For interstellar (late game), real star positions from a static dataset in
packages/content.
7. Deployment
- Client: static build on any CDN (Cloudflare Pages, Netlify, Vercel).
- Server + Postgres + workers: Docker containers. Easy to run on a home Docker host during development, then move to a VPS or a managed platform (Fly.io, Railway, Render) for production.
- Backups: automated Postgres dumps from day one — player saves are the database.
8. Testing
- Vitest on
packages/simis where most of the value is. Determinism tests: same dispatch + time → same result, every time. - Balance simulations: scripts that run thousands of simulated dispatches through
simto check payout curves and progression pacing without playing the game. - Playwright for a handful of critical E2E flows (sign up → first job → first ship).
9. Alternatives Considered
| Option | Why not (for this game) |
|---|---|
| Unity | Heavy web builds, slow mobile-web load times, painful for UI-heavy screens. |
| Godot | Better than Unity for web, but still a game engine for a game that's mostly menus; larger downloads than a React app. |
| Phaser | Great for action games; the map is the only part that needs it, and PixiJS covers that more lightly. |
| .NET backend | Solid, but loses shared sim code with the client (unless you go all-in on Blazor, which is a weaker mobile web story). |
| Firebase | Fast to start, but deterministic, transactional server-side resolution is awkward in Firestore + Cloud Functions. |
| Redis + BullMQ | Excellent, but an extra service to run when Postgres + pg-boss covers the need at this scale. |
10. Build Order
packages/sim+packages/content: travel time, positions, seeded events, resolution. Fully tested, no UI.- Server: auth (guest accounts), dispatch + resolve endpoints, Postgres schema.
- Client: contract board, fleet list, dispatch flow. Plain UI, no map yet.
- pg-boss arrival jobs + Web Push.
- PixiJS system map.
- PWA install, then Capacitor builds.