3,237 words
Commands, toolchain, and the conventions that surprise people. For the rules
those conventions exist to protect, see AGENTS.md. For
how an agent should start and finish work, see the
agent handbook.
Commands
Package manager is pnpm. The lockfile is pnpm's; do not use npm, yarn, or bun to change dependencies.
pnpm install --frozen-lockfile
pnpm dev # Astro on 5173 and cf dev on 8787
pnpm preview # production build, served by the real Worker on 8787
pnpm test # Vitest, Node environment only
pnpm test:gpu # the shader suite on the real GPU via Dawn; not in check
pnpm test:slow # terrain descent and galaxy convergence/population checks; in check and CI
pnpm typecheck # the six type projects, in parallel — a group of pnpm check
pnpm lint # oxlint, not eslint
pnpm graph # dependency layering and cycle check
pnpm brand # regenerate brand artifacts from design/brand/brandmark.svg
pnpm presets:plates # recapture the built-in preset thumbnails through the renderer
pnpm presets:check # every picture has a plate, every composition it names resolves
pnpm presets:compare # photograph the pictures and difference them against the plates, or --base <ref>
pnpm presets:ledger # rewrite the headless picture ledger after a deliberate move
pnpm docs:build # render docs/ and packages/* into the documentation site
pnpm build # optional media pull, docs, typecheck, then Astro build
pnpm check # every stage as one graph: graph, spelling, brand, presets, format, lint,
# typecheck, test, test:slow, sim, build. --list prints them, --force ignores the stamps
pnpm check --only gate # what the Stop hook runs: graph, lint, typecheck, test
# Reports describe the tree; spelling:check also gates it.
pnpm fta # complexity per file; fta:check exits 1 above a score of 91
pnpm knip # what nothing imports; knip:check narrows to the four hard classes
pnpm spelling # British declarations and their references
pnpm spelling:check # reject British declarations; part of pnpm check
pnpm test:coverage # the suite again, writing coverage/coverage-final.json
pnpm sim --self-test # headless run plus the twelve capability checks
pnpm vitest run <substring> # a single test file
pnpm drive --help # drive Chrome over CDP: --js, --shot, --sample, --down
pnpm drive --trace 3000 # record a Chrome trace. Needs ?timing=trace on --url,
# or the recording carries none of our tracks
pnpm timing --help # read one back: per-track p50/p95 and which span was slow
pnpm sim --profile # the same report headlessly, over the worker pool
# Vendored data. Everything under data/ is committed; these rebuild it.
pnpm catalog:fetch # download the star catalog sources into .data/raw
pnpm catalog:report # build the catalog and print, without writing
pnpm catalog:build # build the catalog and write data/catalog
pnpm catalog:build --refresh # ...re-downloading rather than using the cache
pnpm textures:build # surface maps into data/textures (1.5 GB in, 25 MB out)
pnpm shapes:build # measured shape models into data/shapes
pnpm solar:fetch # data/reference/solar-system.json, from JPL
pnpm dev:client # Astro only
pnpm dev:server # cf dev on localhost:8787
pnpm run deploy:worker # build, then cf deploy
pnpm media:pull # reference audio from R2; not in git
pnpm media:pushpnpm dev needs a dist/ to exist, which a fresh worktree does not have.
apps/server/wrangler.config.ts points the assets at ../game/dist, and cf dev refuses to start when that directory is absent — so in a worktree created
by /parallel, or in any clone that
has never built, the Worker half exits immediately and scripts/dev.mjs stops
the Astro half with it. The failure names the directory and nothing else, and it
is easy to read as a broken checkout.
Two ways out, and which one you want depends on why you are serving:
pnpm docs:build && pnpm dev:client # Astro only, with its documentation manifest
pnpm build # once, then `pnpm dev` works for the life of the worktreepnpm check runs pnpm build, so a worktree that has been through the gate
once is already fixed. pnpm drive walks into this: --serve is on by
default and starts scripts/dev.mjs, so on a fresh worktree it reports that
pnpm dev exited without serving, a few seconds in, and the reason is in
.data/drive/dev.log. Generate the docs, serve with pnpm dev:client, and pass
--no-serve, or build once.
Astro daemonizes itself when it detects a coding agent. Astro 7 sniffs the
environment for Claude Code, Codex, Cursor and the rest, and a detected astro dev or astro preview spawns a detached copy of itself, writes a lock file
under apps/game/.astro/, and returns: from a terminal, a server that outlives
the session; under scripts/dev.mjs, a client child that exits cleanly a
second in and takes the Worker with it. The game package's dev and preview
scripts set ASTRO_DEV_BACKGROUND and ASTRO_PREVIEW_BACKGROUND, the
variables Astro gives its own detached child so that it does not daemonize
twice, and so every route into Astro stays in the foreground. They are the only
switch: --ignore-lock throws once an agent is detected. The cost is that the
lock file records the server as background, so astro dev logs points at a
log nobody writes.
pnpm dev refuses a taken port before either child starts. A live Astro on
5173 is named by pid from its lock file while that pid is alive. Astro removes
the file only on a graceful stop, so after a Ctrl-C it names a pid that is
gone, and pnpm dev then says so and points at lsof instead;
pnpm --filter @inertialref/game exec astro dev stop ends a live one. A held
8787 is refused the same way, except under --ensure, which starts the client
alone and lets Vite proxy to the Worker already there.
pnpm run deploy:worker, not pnpm deploy:worker — deploy is a pnpm
built-in. After any change to apps/server/cloudflare.config.ts, regenerate
apps/server/.cloudflare/types/index.d.ts with
pnpm --filter @inertialref/server run types and commit it.
pnpm check is the gate, and scripts/check.mjs is what it runs: every stage
as one graph under the machine's core budget, with a line for what each cost.
Sequentially the stages cost 244 s on a ten-core M5 from a cold terrain archive;
as a graph the same tree runs in 80.5 s warm, bounded by the slowest stage and
whatever cannot fit beside it. A stage that passes is stamped with a key made
of the working tree's content, so a run on an unchanged tree reuses the stamp
and says so — the Stop hook's stages are what a later pnpm check usually
reuses — and --force runs everything. CI runs the same command, so there is
no list of CI stages to drift; a pull request is not done until it passes
there.
Complexity, unused-code and coverage reports stay outside the gate. fta
scores complexity, coverage counts what the suite executed, and knip asks
whether a file needs to exist at all. fta and knip each pair a report that
exits 0 with a :check that exits 1 when its threshold is exceeded. The
threshold belongs on the command line so the report remains usable while
there is existing debt. The findings are in
the complexity report.
pnpm spelling reads identifier declarations through the TypeScript checker
and reports their references and boundary sites. pnpm spelling -- --json
prints the inventory as JSON. pnpm spelling:check rejects any British
declaration and runs in pnpm check. Strings, comments and data need separate
review: an identifier can also be a stored key that the compiler cannot move.
Generated external declarations and quoted third-party names retain their
spelling under the house style.
None of the four data commands are needed to build or run the game — their
outputs are committed. Run one when the upstream publishes; the diff is the news.
textures:build and shapes:build download 1.5 GB between them into the
gitignored .data/, and only the processed outputs are committed. The
catalog guide has the provenance rules each of them follows.
The site deploys to the inertialrefd Worker. Canonical URL:
https://inertialref.app. It also serves https://inertialref.jonjaques.com
without redirecting, so existing installations retain access to their
origin-scoped saves and service worker. To
check a build before trusting DNS, pnpm --filter @inertialref/server run previews:deploy deploys a Worker Preview named for the branch and prints its
URL — every branch push does the same in Workers Builds; analytics and
<link rel="canonical"> name the custom domain, so a preview never counts as a
visit.
Five TypeScript projects, no project references
A referenced project may not disable emit. Emitting declarations for twelve
source-only packages to satisfy tsc -b buys nothing. Five independent
tsconfig projects type-check the portable core and four host environments:
| Project | Covers | Environment |
|---|---|---|
tsconfig.json |
packages/*/src |
No DOM lib, no Node lib — must run in the browser, a worker, and Node |
apps/game/tsconfig.json |
the client | DOM, WebWorker, JSX |
apps/headless/tsconfig.json |
the Node runner | Node types |
apps/server/tsconfig.json |
the Worker | workerd globals and Env, from .cloudflare/types/index.d.ts |
apps/ingest/tsconfig.json |
the catalog build | Node types; runs offline, never at play time |
packages/* are source-only workspace links. There is no build step between
an edit and a test. Each package declares inertialref.layer in
package.json and may depend only on strictly lower layers. pnpm graph
enforces layering, acyclicity, and the ban on third-party runtime dependencies
in packages/*.
If a package needs a host capability, it declares a port and the host
implements it. See packages/workers/src/transport.ts and
packages/persistence/src/store.ts. That is why the worker pool can be driven
by an in-process fake in Node tests.
Conventions
- SI internally — meters, seconds, kilograms, radians. Presentation units are branded types and exist only for display.
- Axes are right-handed, +Y up. A system's reference plane is XZ, forward
is −Z. Textbook orbital mechanics is +Z up, so
physics/frameConvention.tsconverts once at that boundary and nowhere else. - Terrain is sampled in body-fixed axes. Sampling in inertial axes leaves the mountains behind as the planet rotates.
- Imports carry their
.tsextension.allowImportingTsExtensionsis on and Node runs the sources directly. The exception is@/inapps/game, which resolves toapps/game/srcbecause the shadcn registry writes@/lib/utils. Its definitions inapps/game/vite.config.ts,apps/game/tsconfig.json, and the rootvitest.config.tsmust agree. TypeScript 6 rejectsbaseUrl, so the tsconfig usespathswithout it. Hand-written code still imports relatively. - No
enum, no parameter properties, no runtime namespaces —erasableSyntaxOnlyis on. Useconstobjects plus union types. import typefor type-only imports —verbatimModuleSyntaxis on.- One React component per file.
react/no-multi-compis an error. A constant or type the component needs goes in a sibling.ts. The exemption isapps/game/src/components/ui/*.tsx, which the shadcn registry rewrites. - Comments explain why, and specifically why the obvious thing does not work. Do not restate the code.
Toolchain
Astro 7 with @astrojs/react over Vite 8 and
@rolldown/plugin-babel running reactCompilerPreset(). The standalone Vite
config remains available for focused build tests. React Compiler is
on: do not hand-write useMemo / useCallback memoization. useMemo for a
stable Three.js object is a different thing and is fine.
oxlint runs the react, typescript, and oxc plugins. Type-aware rules
are off. react/no-multi-comp is an error. Both that rule and
react/only-export-components are off for apps/game/src/components/ui/*.tsx.
Prettier formats files written through the edit hooks. Do not run
pnpm format or pnpm lint merely to duplicate those hooks. Commands that
write files outside the hooks still need pnpm format — the shadcn CLI, and
any edit an agent makes through the shell rather than through its edit
tool, which is the case the hooks cannot see and the one that reaches CI as a
format:check failure.
ImageMagick and ffmpeg are what the frame tools shell out to. magick
differences a run of rendered frames and is required by scripts/frameDiff.mjs,
--cast and scripts/traceFrames.mjs; ffmpeg turns a cast into the clip worth
attaching to a pull request and is optional, skipped rather than fatal when absent.
brew install imagemagick ffmpeg.
Three typefaces, self-hosted from @fontsource: Archivo Variable
(condensed display), IBM Plex Sans Variable (prose and controls, upright and
italic), IBM Plex Mono (every reading, static 400/500/600). There is no
serif. Symbol coverage is self-hosted beside them: two subsets cut from the
desktop Plex TTFs carry the mathematical operators under the same family
names (src/assets/fonts/), and two Noto subsets carry the planetary sigils no
text family draws — ☉ and ⊕ are not among them, and
design/plans/type-coverage.md says why. All are imported in src/index.css,
which also defines the ten type-* utilities. Do not write a size, a weight,
and a tracking at a call site — use a named step. A Google Fonts <link>
would break offline, which is the base case.
React DnD 16 drives dockable panels, and only the gesture. What a drop
means is arithmetic in apps/game/src/dock/layout.ts and dock/floating.ts.
The backend is chosen once at mount from (pointer: coarse) because
DndProvider cannot be handed a different one. ADR-0012.
Two headless libraries carry the long lists.
@tanstack/react-virtual windows the navigator's tree and the catalog
dialog's results — at fifty light years the survey is fourteen hundred systems
and reconciling them twice a second beside the render loop is the stutter, not
the arithmetic. It is headless, so the tree keeps its own markup, its single tab
stop and its keyboard. @leeoniya/ufuzzy ranks the navigator's search: the
catalog's own index is exact-then-prefix-then-substring, which is right for an
address and cannot find proxmia. It returns match ranges rather than markup,
which is what lets a row light the matched characters without
dangerouslySetInnerHTML over catalog data.
shadcn/ui is the overlay control set. Do not hand-roll a control the
registry has. Go through hud/Action.tsx, hud/SwitchRow.tsx, or
hud/TransportButton.tsx. They call releaseFocus after a pointer click so
flight controls regain the keyboard, and they enforce the accent-as-material
rule: Button's solid default variant is wrong for the primary tone; use
outline plus the sky-500/15 wash. ScrollArea is installed and unused:
its display: table viewport breaks truncate. Add a component with
pnpm dlx shadcn@latest add <name> from apps/game, then run
pnpm format — the registry writes double quotes and semicolons. Do not run
shadcn init; it would overwrite src/index.css.
Brand is generated from design/brand/brandmark.svg via pnpm brand.
Never hand-edit favicon.svg, the .ico, the apple-touch and PWA icons, the
share card, the web manifest, robots.txt, or
src/icons/brandmark.ts. pnpm brand:check is in pnpm check. The share
card has a second source, design/brand/og-plate.png — a captured frame of the
renderer that its type is composited over. scripts/brand/og.mjs carries the
framing it was shot at, so it can be shot again. Astro's sitemap integration
emits the route sitemap during the application build.
Preset plates are the thumbnails under the planetarium's Presets panel, in
apps/game/public/presets/, and the frames a reviewer last accepted for the
thirteen pictures. pnpm presets:plates recaptures the set, or one by id — it
serves the tree on a port of its own and drives Chrome at it, so the machine
needs a GPU and nothing else. They are vendored for the reason the share card is: a build that
needed a GPU would not run in CI, on a fork, or on a machine with no display,
and the one thing a thumbnail may not do is be absent. pnpm presets:check is
in pnpm check and proves that each picture has a plate and names a
composition that still resolves. Whether a plate still looks like the picture
is pnpm presets:compare: it opens each picture's public URL from a server of
its own, waits for the sky and the ground to stop arriving, and differences
the frame against its plate, or against a baseline served from --base <ref>,
reporting the pixels that moved with a reference | tree | difference pair
for each. It compares the lit pictures by default — a frame that is mostly sky
or shadow compares its noise — and names the dark ones it skipped. A move is accepted by
recapturing the plate, and the rewritten file in the pull request is the claim
under review. The headless half is the picture ledger in
apps/headless/src/pictureLedger.json — where each camera stands, its lens,
the body it resolved and the terrain a stance asks for — which pnpm test
holds as a snapshot and pnpm presets:ledger rewrites.
Testing has both halves.
The documentation site at /docs is generated. pnpm docs:build renders
every markdown file under docs/, plus the two adopted from the root —
AGENTS.md at /docs/working-card and STYLE.md at /docs/style, because
the pages under docs/ cite both and a site that answered those links with a
hop to GitHub would send the reader out of the building — and every export of
packages/* through TypeDoc, into apps/game/public/doc-content/, which is
gitignored, staged by pnpm build before the client build, and fetched at
runtime. Editing a page means editing the markdown; the site has no copy of
its own. Two things it will refuse to do: a markdown file under docs/ that
no wing in scripts/docs/wings.mjs lists fails the build rather than
publishing nowhere, and a {@link} pointing at a renamed symbol fails it
rather than rendering as words that link to nothing. scripts/docs/build.mjs
carries the rest.
API articles always ship as JSON. Builds from main also pre-render their
HTML; other branches emit one API loading shell and fetch articles when opened.
Prose remains pre-rendered in both cases. Workers Builds uses
WORKERS_CI_BRANCH; GitHub uses the source branch; a local build uses the
checked-out branch. To verify the complete production output on a feature
branch, run IR_PRERENDER_API=1 pnpm build. Set it to 0 to force asynchronous
API pages. Use pnpm preview for built API deep links because the Worker asset
server applies their generated proxy rules; astro preview does not. During
pnpm dev, Astro renders API loading shells on demand.
design/ is the other half of that division, and it is not published.
Plans, working reviews and the complexity report live there; docs/ is the
finished account of what the system does and design/ is the working one. A
reader who reaches a documentation site expects the system to already behave
the way the page says, and a plan is the one document that promises the
opposite — so routeFor returns null for a path under design/, and a link
from a published page into a plan resolves to GitHub rather than to a route
that does not exist. Brand sources and the reference imagery are there for the
same reason: they are inputs, not pages.
Site metadata comes from src/site.ts. documentHead.ts renders it into
every Astro document, and pages/DocumentMeta.tsx updates it on client
navigation. The sitemap comes from the emitted routes. Production public pages
are pre-rendered; the Worker serves their HTML as static assets.
ADR-0039 records the boundary.
Analytics loads from src/analytics.ts, only in a production build, only
on the canonical host, and only without Global Privacy Control. The
measurement id is VITE_GA_MEASUREMENT_ID and is not in the repository.
Workers Builds supplies it as a build variable. A deploy from this machine
reads the same name from gitignored apps/game/.env.production; a real
environment variable wins over the file. apps/game/.env.example documents
the setup. Nothing secret may go in either place because every VITE_* value
ships in the bundle.
Accounts are Clerk's
(ADR-0048)
and exist only where both halves have a key. The client needs
PUBLIC_CLERK_PUBLISHABLE_KEY — PUBLIC_, because Astro exposes nothing else
to the browser — in gitignored apps/game/.env.local for pnpm dev and as a
build variable for a deploy — the production instance's in the Production build
settings and the development instance's in Previews Base. The Worker needs
CLERK_SECRET_KEY in the root .env.local, which pnpm dev:server loads into
the Worker alone; deployed, each environment holds its own instance's key under
that name. CLERK_ENABLED and TOUR_GUIDE_ENABLED are declared in
cloudflare.config.ts and on in every evaluation. In production, accounts and
the guide answer on inertialref.app alone — the one domain Clerk's production
instance serves — and the second host behaves as a build without them; a
preview and localhost sign in against the development instance from any
host. Without the publishable key there is no account badge; without the
secret /api/account answers that accounts are not configured and the guide is
unavailable. Both states are supported builds. The guide is
granted by hand: tour: true or admin: true in the user's private metadata
in Clerk's dashboard.
Reference audio is not in git. It lives in R2 and reaches the browser
from one table, apps/server/src/media.ts: pnpm media:pull copies it into
gitignored apps/game/public/media/, and the Worker's MEDIA binding serves
it when a credential-less build did not. /media/* is an allow-list, never a
key prefix. See hosting H-8 for the two workerd traps around
R2Range.
Three.js: in apps/game, import three/webgpu and three/tsl, never
three. packages/* may not import Three.js at all; pnpm graph enforces
that half.
Node 26 is required. Cursor Cloud gets it from
.cursor/Dockerfile, which also installs git,
git-lfs, tmux, and a UTF-8 locale — Cursor clones and runs terminals inside
that image, not beside it. Claude cloud environments that still ship Node 20–22
use scripts/cloud-setup.sh; until the correct
runtime is installed, type stripping fails at the first import.
Debugging
Four configurations in .vscode/launch.json,
shared by VS Code and Cursor. The play button on Launch Browser starts
the game.
| Configuration | Debuggee | Port |
|---|---|---|
| Launch Browser | the client; the editor starts Astro + cf dev | 5173 |
| Attach Browser | Chrome already running with remote debugging | 9222 |
| Launch Node | apps/headless (--self-test) |
— |
| Attach Node | pnpm sim (node --inspect=127.0.0.1:9229) |
9229 |
Launch Browser runs node scripts/dev.mjs --ensure as a background task. If
5173 is already up, it reuses that process and does not kill it when debugging
stops; if it is not, the task is pnpm dev and stopping debugging stops both
children.
The Worker's workerd inspector under cf dev is on 9230 so it does not
steal Node's default; dev.inspectorPort in apps/server/wrangler.config.ts
sets it. Source maps ship in
pnpm dev and in the production JS (including the universe worker);
build.sourcemap: true is the switch, and a build that omitted the
sourceMappingURL comment fails the gate. Production CSS has no map — Vite 8
minifies it with lightningcss and does not expose that option. Dev CSS maps
are css.devSourcemap.
The configurations, the inspect flag, and the inspector port are checked by
scripts/debug.test.mjs. How to use them: .vscode/README.md.
Related
- Getting started — clone to flying
- Testing — what to test, and how
- Extending — adding a body type, a task, a save field
- Agent handbook
- Editor debug configurations