22 KiB
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(). |
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/gameToRealin 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/contentand 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 |
Pilot |
id, name, isAvatar, xp, level, skills ({ skillId: level }), skillPointsAvailable, assignedShipId, status, locationId (null in flight) |
Bay |
id, locationId |
LocationState |
id, discovered |
Contract |
mirrors sim Contract (optional scriptedId on a scripted tutorial contract) |
Dispatch |
mirrors sim DispatchRecord + status (in-flight / resolved), resolvedAt |
ResolutionSummary |
dispatchId, shipId, pilotId, originId, destinationId, tutorial, credits (net), loanRepayment, pilotXp, discoveredLocationId, scriptedId, resolvedAt |
TutorialState |
loanerLocationId (earth / moon), runInFlight, runsCompleted, dealAvailable |
GoalState |
id, status (incomplete / ready / claimed) |
PlayerState |
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, 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/bays/: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 |
buyShip |
POST /api/ships |
modelId |
{ 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 |
spendSkillPoint |
POST /api/pilots/:pilotId/skills |
pilotId, skillId |
{ pilot } |
yes |
acknowledgeSummaries |
POST /api/summaries/acknowledge |
dispatchIds |
{ acknowledged } |
yes |
acceptDeal |
POST /api/deal/accept |
— | PlayerState |
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). 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) | — |
pilots |
XP, skills (jsonb), points spent, assignment, location (unassigned only), status | 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 bay boards and tutorial boards per period (tutorial boards also per run) (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", ["bay", "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"),
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):
- Merge path parameters, query and body, and validate them against the route's
requestschema →400 validation-erroron failure (also for malformed JSON). - If
route.auth, resolve the session to a player id →401 unauthorizedon failure. - Run the handler with the typed input and
{ playerId, request, reply }. - Outside production, validate the result against the route's
responseschema; a mismatch is logged and returned as500 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.
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, 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/screens/* |
HQ, Contracts (TutorialBoard / BayBoards + 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"); the client stores nothing itself.