deadhead-space-freight-tycoon/memory-bank/tech-stack.md

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(), no Math.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 sim every frame.
  • Pan/zoom with touch support.

State

  • TanStack Query caches engine results (player state, boards) behind the same typed ApiClient the 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 saveVersion and 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-pwa makes 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/sim so 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 to index.html for 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/sim is 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 sim to 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.