12 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 |
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 |
Dispatch |
mirrors sim DispatchRecord + status (in-flight / resolved), resolvedAt |
ResolutionSummary |
dispatchId, shipId, pilotId, originId, destinationId, tutorial, credits, pilotXp, discoveredLocationId, resolvedAt |
TutorialState |
loanerLocationId (earth / moon), runInFlight |
PlayerState |
company, ships, pilots, bays, locations, tutorial (null once over), activeDispatches, summaries |
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 |
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 |
World seed, tutorial flag, loaner location | pk id |
companies |
Name and credits (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 (jsonb contracts) | unique (player_id, kind, location_id, period_index) |
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) |
Drizzle schema
Copied from apps/server/src/db/schema.ts; 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";
// 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(),
/** 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"),
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(),
});
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(),
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),
],
);
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] })],
);