8.4 KiB
Tech Stack — Deadhead: Space Freight Tycoon
Companion to game-design-document.md. Updated for CR-014: the game runs entirely on the device; there is no game server.
TL;DR
TypeScript end to end, in one monorepo, running on the player's device.
| Layer | Choice |
|---|---|
| Language | TypeScript (client, engine, shared sim) |
| Monorepo | pnpm workspaces |
| Client UI | React + Vite |
| Client state | TanStack Query (engine results) + Zustand (clock, preferences, map camera) |
| System map rendering | PixiJS |
| Styling | Tailwind CSS |
| Game engine | packages/engine: the game's commands over one save document, in the app |
| Save storage | IndexedDB (web); a JSON file via @capacitor/filesystem (iOS/Android) |
| Validation | Zod (route catalog and save document) |
| Time | Device clock that never runs backward, plus a developer-only offset |
| Notifications | Local notifications via @capacitor/local-notifications (iOS/Android); none on the web |
| PWA | vite-plugin-pwa (Workbox) |
| Native wrapper | Capacitor (iOS/Android) |
| Planetary positions | astronomy-engine |
| Seeded RNG | Small pure-TS PRNG; world seeds and ids from the platform's crypto random source |
| Testing | Vitest (sim, engine, components), Playwright (end to end) |
| Deployment | Static files (Caddy on a home server, or any static host) |
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 runs in the engine (to decide outcomes) and in the screens (to show ships moving, ETAs, and contract previews). Both use the same packages/sim, so they can never disagree. The game started with a server as the authority; because sim already had no I/O, moving everything onto the device (CR-014) was a port rather than a rewrite.
2. Repo Layout
/apps
/web React + Vite client (also the Capacitor app); runs the engine
/packages
/sim Pure, deterministic game rules (no I/O)
/engine The game's commands over a save document; storage, clock and notifications injected
/schema Zod schemas: the command catalog (requests, responses, errors) and the save document
/content Static game data: ships, locations, goods, skills, config
packages/sim rules
- Pure functions only. No
Date.now(), noMath.random(), no network, no storage. - 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.
packages/engine rules
- Uses storage, the clock and the notification scheduler only through injected interfaces, so tests run on in-memory storage and a test clock.
- One mutation queue: commands run one at a time; each loads the save, changes a copy, validates and saves it.
- Every command has the same request and response schemas as the route catalog in
packages/schema.
packages/content
Ships, locations, goods, and skills live as typed data files validated by Zod. Balancing is a data edit, not a code change.
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
simevery frame. - Pan/zoom with touch support.
State
- TanStack Query caches engine results (player state, boards) behind the same typed
ApiClientthe screens always used; mutations invalidate them. - Zustand for the ticking clock, device preferences and the map camera.
Time
now = max(device time + developer offset, last time seen), with last time seen kept in the save. Time never runs backward, so setting the clock back can't undo progress; setting it forward to skip waits is accepted (decision 0.5.88).
Resolution loop
On launch, on resume and every few seconds while open, the app resolves arrivals that have come due and refreshes the screens. Closing the app loses nothing: arrival times are in the save, and the next launch resolves them.
Storage and saves
- Web: IndexedDB, one record for the save plus three rotating backups.
- iOS/Android: a JSON file in the app's data directory (written to a temp file, then renamed), included in iCloud and Android device backups.
- If the main save won't load, the newest valid backup is used.
- Export save gives the whole save as one text code (gzip + base64url) to copy or share as a file; Import save migrates and validates it. That is how a company moves between devices.
- Each save format change bumps
saveVersionand ships with a migration.
One tab at a time
In the browser, only one tab runs the game (Web Locks API); others show "Deadhead is open in another tab" with "Use here".
PWA + Capacitor
vite-plugin-pwamakes the web build installable and caches it for offline use. The game works the same with or without a network.- Capacitor wraps the same build for the App Store and Play Store, adding native file storage and local notifications.
4. Notifications
| Platform | Mechanism |
|---|---|
| Capacitor iOS / Android | Local notifications (@capacitor/local-notifications) |
| Web / installed PWA | None |
The schedule is worked out from the save after every change: one notification per arrival, rest ending and upgrade finishing, with events in the same minute combined ("Deadhead: 2 updates"). It replaces whatever was scheduled before, so changed or cancelled events never leave a stale notification. Permission is asked right after the deal, and Settings has an on/off toggle. No push service is needed because every arrival time is known at dispatch.
5. Planetary Positions
astronomy-engine computes real planetary positions for any date, in pure JS, no API calls.
- Run it in
packages/simso the engine and the screens 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.
6. Deployment
- Web: the static build, served by Caddy (
deploy/, HTTPS and caching rules) or any static host that falls back toindex.htmlfor client routes. - Native: Capacitor builds of the same web build (store release work is a later change request).
- No server, database or backups to run: players' saves live on their devices.
7. Testing
- Vitest on
packages/simis where most of the value is. Determinism tests: same dispatch + time → same result, every time. - Vitest on
packages/engine: every command against in-memory storage and a test clock (the server's integration tests, ported). - Balance simulations: scripts that run simulated play through
simto check payout curves and progression pacing. - Playwright against the web build alone, fast-forwarding with the developer clock offset; one test checks that the production build loads nothing but its own files.
8. 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. |
| A game server (Fastify + Postgres + pg-boss, as first built) | Single-player with outcomes derived from timestamps, so it isn't needed; it added hosting cost, outage risk and accounts (CR-014). |
| Cloud saves / cross-device sync | Out of scope for now (CR-014 §8); export/import covers moving a company. |
| SQLite on the device | One validated JSON document is enough at this size and is easy to export, back up and migrate. |