deadhead-space-freight-tycoon/memory-bank/architecture.md

28 KiB
Raw Blame History

Architecture

Living reference for how the code is organised. Update it after any database schema or API change (implementation plan rule 0.1.9). Decisions and their reasons live in DECISIONS.md.

Packages

Package Role Depends on
packages/content Static game data (locations, ship models, cargo, skills, GameConfig) as Zod-validated TypeScript, plus the content bundle with id lookups. zod
packages/sim Pure, deterministic game rules: clock, seeded RNG, positions, distances, travel, fuel, eligibility, contract boards, dispatch validation/records, flight progress, XP, resolution. No I/O, no Date.now(), no Math.random(). balance/ holds the balance simulation (Step 12.1), outside src/ and the coverage measure. content, astronomy-engine
packages/schema Zod schemas for every API request, response and error. Shared by server and client. sim (error codes), zod
apps/server Fastify API, Postgres via Drizzle, pg-boss workers. The authority on outcomes. content, sim, schema
apps/web React client. Predicts with sim, never decides outcomes. content, sim, schema

Conventions

  • Times are Unix milliseconds (numbers). A real time is a wall-clock instant; a game time is the instant of the in-game date (realToGame / gameToReal in sim).
  • Ids are non-empty strings. Content ids are kebab-case (earth, bulk-hauler).
  • Credits are whole numbers.
  • Static vs. player data: the API never sends static data. The client imports @deadhead/content and joins by id.

API data model (packages/schema)

Shape Fields
ServerTime realTime, gameTime
Company id, name, credits, loanBalance
Ship id, modelId, name, status (idle / in-flight), locationId (null in flight), dispatchId (null when idle), pilotId, lastArrivalAt (latest resolved arrival, else delivery time; picks the default Contracts location), upgrades ({ upgradeId: level }), stats (effective: speed, capacity, range, overhead, handling per tonne), inShopUntil (null unless in the shop now), shop ({ upgradeId, level, until } of the latest job until acknowledged; CR-009)
Pilot id, name, isAvatar, isVeteran (the deal's veteran, CR-010), xp, level, skills ({ skillId: level }), skillPointsAvailable, assignedShipId, status, locationId (null in flight), dutyHours, dutyLimit, resting, restEndsAt (null unless resting; CR-004)
Bay id, locationId
LocationState id, discovered
Contract mirrors sim Contract: route, cargoTypeId (class), goodId, shipper, priority, deliveryWindowGameHours (Priority only, else null), tier, massTonnes (to 0.1 t under 10 t), payout, requirements, offeredAt/expiresAt, firstContact, optional scriptedId on a scripted tutorial contract (CR-007)
Dispatch mirrors sim DispatchRecord + status (in-flight / resolved), resolvedAt
ResolutionSummary dispatchId, shipId, pilotId, originId, destinationId, tutorial, deadhead, fuelCost, goodId / massTonnes / shipper (null for a deadhead, CR-007), credits (net), loanRepayment, pilotPay (hired pilot's profit share, CR-010), pilotXp, discoveredLocationId, scriptedId, restEndsAt (set when the trip ended in mandatory rest), resolvedAt
TutorialState loanerLocationId (earth / moon), runInFlight, runsCompleted, dealAvailable
GoalState id, status (incomplete / ready / claimed)
PlayerState playerId (CR-011), company, ships, pilots, bays, locations, tutorial (null once over), activeDispatches, summaries, goals (empty during the tutorial)

Errors

Every non-2xx response has the body { error: { code, message } }. code is one of ERROR_CODES: every sim dispatch and reposition validation reason (DISPATCH_FAILURES, REPOSITION_FAILURES), unchanged (CR-007 adds too-slow: the ship can't make a Priority contract's delivery window; CR-012 adds would-strand: from the destination, at arrival plus noStrandMarginGameHours, the ship couldn't reach any other discovered location), plus the server codes in SERVER_ERROR_CODES (validation-error, unauthorized, not-found, insufficient-funds, ...).

API routes (packages/schema/src/routes.ts)

The routes catalog is the single source for both server and client. Each entry has method, path, request (path parameters merged with the body), response (2xx body) and auth. Errors always use the error body above.

Name Method & path Request Response Auth
getServerTime GET /api/time — ServerTime no
createGuest POST /api/guest — { playerId, state: PlayerState } + session cookie no
getPlayerState GET /api/state — PlayerState yes
getContractBoard GET /api/locations/:locationId/board locationId { contracts, periodEndsAt } yes
getTutorialBoard GET /api/tutorial/board — { contracts, periodEndsAt } yes
createDispatch POST /api/dispatches { kind: "contract", contractId, shipId, pilotId } / { kind: "tutorial", contractId } / { kind: "reposition", shipId, pilotId, destinationId } { dispatch } yes
startUpgrade POST /api/ships/:shipId/upgrades shipId, upgradeId (next level; ship idle at an HQ) { ship } yes
buyShip POST /api/ships modelId, bayId (one of the player's bays; the ship is delivered there) { ship } yes
leaseBay POST /api/bays locationId { bay } yes
hirePilot POST /api/pilots locationId (a player bay) { pilot } yes
assignPilot POST /api/pilots/:pilotId/assign pilotId, shipId { pilot } yes
unassignPilot POST /api/pilots/:pilotId/unassign pilotId { pilot } yes
restPilot POST /api/pilots/:pilotId/rest pilotId (idle, not resting) { pilot } yes
spendSkillPoint POST /api/pilots/:pilotId/skills pilotId, skillId { pilot } yes
acknowledgeSummaries POST /api/summaries/acknowledge dispatchIds, optional shipIds (finished upgrades seen; at least one id overall) { acknowledged } yes
acceptDeal POST /api/deal/accept — PlayerState yes
getBackupCode GET /api/backup-code — { hasCode, createdAt } yes
createBackupCode POST /api/backup-code — { code, createdAt } (shown once; replaces any old code) yes
restoreCompany POST /api/restore code (case, spaces and dashes ignored); header x-device-id for rate limiting { playerId, state } + session cookie no
devAdvanceClock POST /api/test/advance-clock advanceMs { offsetMs, realTime } yes; non-production only
devAddCredits POST /api/test/add-credits amount { company } yes; non-production only
newCompany POST /api/company/new — PlayerState (the fresh start) yes
renameCompany POST /api/company/name name (trimmed, 3–32 letters, digits, spaces, . , ' & ! ? -) { company } yes
repayLoan POST /api/loan/repay amount { company } yes
claimGoal POST /api/goals/:goalId/claim goalId { company } yes

Database (apps/server/src/db)

Postgres via Drizzle (pg driver). Migrations are SQL files in apps/server/drizzle/, generated with pnpm --filter @deadhead/server db:generate and applied with db:migrate (uses DATABASE_URL; rerunning is a no-op). Contracts are JSONB snapshots (contract_boards.contracts, dispatches.record); 0005_cr007_goods backfills pre-CR-007 contracts with a default good of their class, the origin's first shipper and no Priority. Every game table hangs off players.id; times are bigint Unix ms.

Table Purpose Key constraints
players Link to the Better Auth user, world seed, tutorial flag, loaner location, tutorial runs completed, deal accepted pk id, unique user_id → user.id
companies Name, credits and loan balance (the row locked for money changes) unique player_id
ships Owned ships: model, name, status, location (null in flight), in-shop-until and the latest shop job (upgrade id, level) until acknowledged (CR-009), from_deal (handed over in the deal, not bought; migration 0009_cr013_ship_from_deal backfills each player's first ship; CR-013) —
player_logins Extra sign-ins per player: a guest user created by restoring with a backup code (CR-011) PK user_id
ship_upgrades Installed upgrade level per ship and upgrade (CR-009) primary key (ship, upgrade)
pilots XP, skills (jsonb), points spent, assignment, location (unassigned only), status, duty hours, rest-ends-at (CR-004), is_veteran (CR-010) unique assigned_ship_id (one pilot per ship)
bays Leased bays unique (player_id, location_id)
discovered_locations Discovered location ids pk (player_id, location_id)
contract_boards Stored location boards (one per discovered location, CR-002) and tutorial boards per period (tutorial boards also per run); kind is location or tutorial (jsonb contracts) unique (player_id, kind, location_id, period_index, tutorial_run)
dispatches Full sim record (jsonb, with contract snapshot), status, arrival, resolution (jsonb), resolved-at, acknowledged-at index (status, arrival_at), (player_id, status)
accepted_contracts Contracts taken from a board, linked to their dispatch pk (player_id, contract_id)
claimed_goals Onboarding goals whose reward was claimed pk (player_id, goal_id)
user, session, account, verification Better Auth tables (src/db/auth-schema.ts, generated by its CLI; user.is_anonymous from the anonymous plugin). Timestamps here are Postgres timestamp, Better Auth's convention. —

Drizzle schema

Copied from apps/server/src/db/schema.ts (game tables) and apps/server/src/db/auth-schema.ts (Better Auth); update both together.

import type { Contract, DispatchRecord, Resolution } from "@deadhead/sim";
import {
  bigint,
  boolean,
  index,
  integer,
  jsonb,
  pgEnum,
  pgTable,
  primaryKey,
  text,
  uniqueIndex,
} from "drizzle-orm/pg-core";
import { user } from "./auth-schema";

// All times are Unix milliseconds (bigint), matching sim and the API.
const time = (name: string) => bigint(name, { mode: "number" });

export const activityStatus = pgEnum("activity_status", ["idle", "in_flight"]);
export const dispatchStatus = pgEnum("dispatch_status", ["in_flight", "resolved"]);
export const boardKind = pgEnum("board_kind", ["location", "tutorial"]);

export const players = pgTable("players", {
  id: text("id").primaryKey(),
  /** The Better Auth user (a guest account in the base game). */
  userId: text("user_id")
    .notNull()
    .unique()
    .references(() => user.id, { onDelete: "cascade" }),
  /** 32-bit unsigned seed for boards, dispatches and names (decision 0.5.10). */
  worldSeed: bigint("world_seed", { mode: "number" }).notNull(),
  tutorialActive: boolean("tutorial_active").notNull().default(true),
  /** Where the tutorial loaner is (decision 0.5.26); null once the tutorial ends. */
  loanerLocationId: text("loaner_location_id"),
  /** Tutorial runs resolved so far (CR-001). */
  tutorialRunsCompleted: integer("tutorial_runs_completed").notNull().default(0),
  /** The one-time deal has been accepted (decision 0.5.30). */
  dealAccepted: boolean("deal_accepted").notNull().default(false),
  createdAt: time("created_at").notNull(),
});

export const companies = pgTable("companies", {
  id: text("id").primaryKey(),
  playerId: text("player_id")
    .notNull()
    .unique()
    .references(() => players.id, { onDelete: "cascade" }),
  name: text("name").notNull(),
  /** Row-locked by every endpoint that changes money or fleet state (Step 6.14). */
  credits: bigint("credits", { mode: "number" }).notNull(),
  /** Owed to the lender (decision 0.5.32). */
  loanBalance: bigint("loan_balance", { mode: "number" }).notNull().default(0),
});

export const ships = pgTable(
  "ships",
  {
    id: text("id").primaryKey(),
    playerId: text("player_id")
      .notNull()
      .references(() => players.id, { onDelete: "cascade" }),
    modelId: text("model_id").notNull(),
    name: text("name").notNull(),
    status: activityStatus("status").notNull().default("idle"),
    /** Null while in flight. */
    locationId: text("location_id"),
    createdAt: time("created_at").notNull(),
  },
  (t) => [index("ships_player_idx").on(t.playerId)],
);

export const pilots = pgTable(
  "pilots",
  {
    id: text("id").primaryKey(),
    playerId: text("player_id")
      .notNull()
      .references(() => players.id, { onDelete: "cascade" }),
    name: text("name").notNull(),
    isAvatar: boolean("is_avatar").notNull().default(false),
    xp: integer("xp").notNull().default(0),
    skills: jsonb("skills").$type<Record<string, number>>().notNull().default({}),
    skillPointsSpent: integer("skill_points_spent").notNull().default(0),
    /** One pilot per ship (decision 0.5.2). */
    assignedShipId: text("assigned_ship_id")
      .unique()
      .references(() => ships.id, { onDelete: "set null" }),
    /** Only unassigned pilots store a location (decision 0.5.27). */
    locationId: text("location_id"),
    status: activityStatus("status").notNull().default("idle"),
    /** Game hours flown since the last rest (decision 0.5.42). */
    dutyHours: doublePrecision("duty_hours").notNull().default(0),
    /** Resting until this time; null (or past) when not resting (decision 0.5.45). */
    restEndsAt: time("rest_ends_at"),
    createdAt: time("created_at").notNull(),
  },
  (t) => [index("pilots_player_idx").on(t.playerId)],
);

export const bays = pgTable(
  "bays",
  {
    id: text("id").primaryKey(),
    playerId: text("player_id")
      .notNull()
      .references(() => players.id, { onDelete: "cascade" }),
    locationId: text("location_id").notNull(),
    createdAt: time("created_at").notNull(),
  },
  (t) => [uniqueIndex("bays_player_location_idx").on(t.playerId, t.locationId)],
);

export const discoveredLocations = pgTable(
  "discovered_locations",
  {
    playerId: text("player_id")
      .notNull()
      .references(() => players.id, { onDelete: "cascade" }),
    locationId: text("location_id").notNull(),
    discoveredAt: time("discovered_at").notNull(),
  },
  (t) => [primaryKey({ columns: [t.playerId, t.locationId] })],
);

/**
 * One stored board per bay per period, and one tutorial board per period per
 * loaner location (decisions 0.5.5 and 0.5.21). `locationId` is the bay's or
 * the loaner's location.
 */
export const contractBoards = pgTable(
  "contract_boards",
  {
    id: text("id").primaryKey(),
    playerId: text("player_id")
      .notNull()
      .references(() => players.id, { onDelete: "cascade" }),
    kind: boardKind("kind").notNull(),
    locationId: text("location_id").notNull(),
    periodIndex: integer("period_index").notNull(),
    /** Tutorial boards only: runs completed when generated (0 for bay boards). */
    tutorialRun: integer("tutorial_run").notNull().default(0),
    contracts: jsonb("contracts").$type<Contract[]>().notNull(),
    createdAt: time("created_at").notNull(),
  },
  (t) => [
    uniqueIndex("contract_boards_unique_idx").on(
      t.playerId,
      t.kind,
      t.locationId,
      t.periodIndex,
      t.tutorialRun,
    ),
  ],
);

export const dispatches = pgTable(
  "dispatches",
  {
    id: text("id").primaryKey(),
    playerId: text("player_id")
      .notNull()
      .references(() => players.id, { onDelete: "cascade" }),
    /** Null for a tutorial run (the loaner is not a ship row). */
    shipId: text("ship_id").references(() => ships.id, { onDelete: "set null" }),
    pilotId: text("pilot_id")
      .notNull()
      .references(() => pilots.id, { onDelete: "cascade" }),
    tutorial: boolean("tutorial").notNull(),
    /** The full sim record, including a snapshot of the contract. */
    record: jsonb("record").$type<DispatchRecord>().notNull(),
    arrivalAt: time("arrival_at").notNull(),
    status: dispatchStatus("status").notNull().default("in_flight"),
    /** Applied effects, kept for the "while you were away" summary. */
    resolution: jsonb("resolution").$type<Resolution>(),
    resolvedAt: time("resolved_at"),
    acknowledgedAt: time("acknowledged_at"),
  },
  (t) => [
    index("dispatches_due_idx").on(t.status, t.arrivalAt),
    index("dispatches_player_status_idx").on(t.playerId, t.status),
  ],
);

export const acceptedContracts = pgTable(
  "accepted_contracts",
  {
    playerId: text("player_id")
      .notNull()
      .references(() => players.id, { onDelete: "cascade" }),
    contractId: text("contract_id").notNull(),
    periodIndex: integer("period_index").notNull(),
    dispatchId: text("dispatch_id")
      .notNull()
      .references(() => dispatches.id, { onDelete: "cascade" }),
    acceptedAt: time("accepted_at").notNull(),
  },
  (t) => [primaryKey({ columns: [t.playerId, t.contractId] })],
);

/** Onboarding goals whose reward has been claimed (decision 0.5.34). */
export const claimedGoals = pgTable(
  "claimed_goals",
  {
    playerId: text("player_id")
      .notNull()
      .references(() => players.id, { onDelete: "cascade" }),
    goalId: text("goal_id").notNull(),
    claimedAt: time("claimed_at").notNull(),
  },
  (t) => [primaryKey({ columns: [t.playerId, t.goalId] })],
);
// Better Auth tables, generated by `@better-auth/cli generate` (anonymous plugin).
// Regenerate rather than hand-edit when Better Auth or its plugins change.
import { relations } from "drizzle-orm";
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core";

export const user = pgTable("user", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  emailVerified: boolean("email_verified").default(false).notNull(),
  image: text("image"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at")
    .defaultNow()
    .$onUpdate(() => /* @__PURE__ */ new Date())
    .notNull(),
  isAnonymous: boolean("is_anonymous").default(false),
});

export const session = pgTable(
  "session",
  {
    id: text("id").primaryKey(),
    expiresAt: timestamp("expires_at").notNull(),
    token: text("token").notNull().unique(),
    createdAt: timestamp("created_at").defaultNow().notNull(),
    updatedAt: timestamp("updated_at")
      .$onUpdate(() => /* @__PURE__ */ new Date())
      .notNull(),
    ipAddress: text("ip_address"),
    userAgent: text("user_agent"),
    userId: text("user_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
  },
  (table) => [index("session_userId_idx").on(table.userId)],
);

export const account = pgTable(
  "account",
  {
    id: text("id").primaryKey(),
    accountId: text("account_id").notNull(),
    providerId: text("provider_id").notNull(),
    userId: text("user_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    accessToken: text("access_token"),
    refreshToken: text("refresh_token"),
    idToken: text("id_token"),
    accessTokenExpiresAt: timestamp("access_token_expires_at"),
    refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
    scope: text("scope"),
    password: text("password"),
    createdAt: timestamp("created_at").defaultNow().notNull(),
    updatedAt: timestamp("updated_at")
      .$onUpdate(() => /* @__PURE__ */ new Date())
      .notNull(),
  },
  (table) => [index("account_userId_idx").on(table.userId)],
);

export const verification = pgTable(
  "verification",
  {
    id: text("id").primaryKey(),
    identifier: text("identifier").notNull(),
    value: text("value").notNull(),
    expiresAt: timestamp("expires_at").notNull(),
    createdAt: timestamp("created_at").defaultNow().notNull(),
    updatedAt: timestamp("updated_at")
      .defaultNow()
      .$onUpdate(() => /* @__PURE__ */ new Date())
      .notNull(),
  },
  (table) => [index("verification_identifier_idx").on(table.identifier)],
);

export const userRelations = relations(user, ({ many }) => ({
  sessions: many(session),
  accounts: many(account),
}));

export const sessionRelations = relations(session, ({ one }) => ({
  user: one(user, {
    fields: [session.userId],
    references: [user.id],
  }),
}));

export const accountRelations = relations(account, ({ one }) => ({
  user: one(user, {
    fields: [account.userId],
    references: [user.id],
  }),
}));

Auth and sessions

Better Auth (apps/server/src/auth.ts) with the anonymous plugin, mounted only through its server API (no /api/auth/* HTTP handler yet). POST /api/guest calls signInAnonymous, forwards its session cookie, and creates the player (createPlayer) linked to the new user. Authenticated routes call requirePlayerId, which resolves the cookie to a session, then to players.user_id; failures are 401 unauthorized.

Request pipeline (apps/server/src/http)

Every catalog endpoint is registered with registerRoute(app, routes.<name>, options, handler):

  1. Merge path parameters, query and body, and validate them against the route's request schema → 400 validation-error on failure (also for malformed JSON).
  2. If route.auth, resolve the session to a player id → 401 unauthorized on failure.
  3. Run the handler with the typed input and { playerId, request, reply }.
  4. Outside production, validate the result against the route's response schema; a mismatch is logged and returned as 500 internal-error.

Handlers throw ApiError(status, code, message) for expected failures. Unknown paths return 404 not-found.

Game logic (apps/server/src/game)

Module Responsibility
lock.ts withPlayerLock: transaction + FOR UPDATE on the player's company row
resolve.ts resolveArrivedDispatches: exactly-once resolution of due dispatches (lazy on every request; Phase 7 job)
tutorial.ts maybeEndTutorial (decision 0.5.4)
boards.ts Stored tutorial and bay boards per period; accepted contracts hidden
dispatch.ts Tutorial, contract and reposition dispatch; storeDispatch (record, accepted contract, fuel)
fleet.ts Buy ship, lease bay, spend credits
pilots.ts Hire, assign, unassign, spend skill point
summaries.ts Acknowledge "while you were away" entries
player-state.ts, mappers.ts Database rows to API shapes
names.ts Seeded ship and pilot names

Routes (apps/server/src/routes) wrap every authenticated action in withPlayer: lock, resolve due arrivals, run the action, then maybeEndTutorial. Game-rule failures are 409 with their error code; unknown things are 404.

Background jobs (apps/server/src/jobs)

pg-boss (queue.ts) in the app's Postgres database (schema pgboss), behind a JobQueue interface; tests use a recording fake and call handlers directly. Queues are always created; workers and cron schedules run only when WORKERS_ENABLED=true.

Job Trigger Does
resolve-arrivals Sent after each dispatch commits, startAfter = arrival (on the real clock) resolveArrivedDispatches under the player lock; no-op if a request already resolved it
cleanup-expired-contracts Cron 0 * * * * Deletes boards and accepted contracts older than 24 periods

The app clock is base clock + offset; the offset is set only by the test-only POST /api/test/clock-offset, which is not registered in production.

Deployment (deploy/)

File Role
server.Dockerfile API image: production dependencies via pnpm deploy, TypeScript run with tsx, migrations then listen; non-root; health check on /api/health
compose.yml Production stack (project deadhead): Postgres 18 (named volume, not published), the server, and web (Caddy on HTTP_PORT/HTTPS_PORT, certificate volumes)
web.Dockerfile, Caddyfile Caddy image with the built app: /api/* to server:3000, client routes fall back to index.html, hashed assets cached for a year, shell and service worker never; HTTPS for SITE_ADDRESS
README.md Home-server setup: DNS, port forwarding, .env, start/update, LAN-only mode
.env.example PUBLIC_URL (also Better Auth's URL), SITE_ADDRESS, host ports, Postgres credentials, BETTER_AUTH_SECRET, log level; copy to deploy/.env
check.sh Builds and starts the stack under a throwaway project, checks it, removes it

Client (apps/web)

Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In development, Vite proxies /api to the server at 127.0.0.1:3000 (same origin, decision 0.5.20).

Module Role
src/api/client.ts createApiClient().call(route, input): typed by the route catalog; validates responses; throws ApiError (server code) or ApiValidationError
src/api/context.tsx ApiProvider / useApi (tests inject a fake)
src/clock/clock.ts Zustand useClock: server offset (last one kept in deadhead:clock-offset for offline starts), serverNow(), 1 s ticker
src/state/player.ts usePlayerState (TanStack Query, key player-state): null means no session; refetch on focus and on arrival
src/App.tsx Clock sync; loading / start screen / routed layout
src/layout/Layout.tsx Header (credits, game clock), screen outlet, bottom tab bar
src/routes.ts Screen list shared by router and navigation
src/map/art/manifest.json Map art manifest (CR-003): asset id → { kind, file } in public/assets/map/ (never edited); null file = missing, placeholder used
scripts/map-art.mjs Generates public/assets/map-gen/ (<id>@1x.png, <id>@2x.png, bg.webp) from the manifest with sharp; output committed
src/map/style.ts Map presentation config (CR-003): sprite sizes, zoom scale limits, child-body offsets, badges, tap size, parallax, colors; validated by mapStyleSchema
src/map/art/load.ts, art/visuals.ts Load the generated art as one PixiJS bundle (2x at DPR ≥ 2); texture-or-placeholder per scene item (decision 0.5.41)
src/map/layout.ts Pure screen layout: backgroundRect, spriteScale, childOffset, layoutScene (centres, sizes, tap targets; used by renderer and hit test), decorate (label and badge placement)
src/map/overlays.ts Per-location state overlays (lock, desaturate/dim, HQ marker, idle badge), applied by the renderer
src/map/ticker.ts runWhileVisible: pause the map's ticker while the page is hidden; stop it on unmount
src/map/renderer.ts MapRenderer: all PixiJS drawing for the map, layer by layer; MapCanvas owns the app, input and loading state
src/state/connection.ts Offline (Step 11.2): Zustand useConnection (online, lastUpdate), browser online/offline events, and the last player state cache (deadhead:last-state, schema-checked on load)
vite.config.ts (PWA) vite-plugin-pwa (Step 11.1): manifest, generateSW precache of the shell, woff2 fonts, map-gen art and icons (public/icons, from scripts/icons.mjs); /api never cached
e2e/*.e2e.ts Playwright tests against vite preview of the build (install, offline); the API is faked with route interception
src/state/preferences.ts Device preferences (CR-011): clock and countdown format, reduced-motion override, dismissed tips, saved map camera; local storage, every access wrapped
src/screens/* HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen
src/components/* AwaySummary dialog, Countdown
src/state/actions.ts useLoadedPlayer, useGameAction(route) (mutation + invalidate state and boards)
src/map/* System map: projection (square-root radial scale), camera (Zustand pan/zoom), scene (locations, flights, idle badges), hitTest, MapCanvas (PixiJS v8, lazy-loaded with the Map screen)
src/game.ts, src/format.ts Sim-based predictions (distance, trip time) and display formatting

Session: Better Auth's cookie (credentials: "include"). On the device the client keeps only caches and preferences: the last player state and clock offset (offline start), device preferences, the device id and seen board ids.

Offline (Step 11.2): a request that gets no reply marks the app offline and getPlayerState falls back to the cached state; the banner reads "Offline — showing last update from HH:MM". Buttons with needsServer (every server action and the sheets that lead to one) are disabled while offline; the away summary and the deal wait until back online. The browser's online event refetches.