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

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(), no Math.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-pwa makes 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/schema alongside 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/sim so 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/sim is 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 sim to 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

  1. packages/sim + packages/content: travel time, positions, seeded events, resolution. Fully tested, no UI.
  2. Server: auth (guest accounts), dispatch + resolve endpoints, Postgres schema.
  3. Client: contract board, fleet list, dispatch flow. Plain UI, no map yet.
  4. pg-boss arrival jobs + Web Push.
  5. PixiJS system map.
  6. PWA install, then Capacitor builds.