1,867 words · 4 diagrams
How to add things without breaking an invariant. Each section names the seam, the steps, and the trap.
Read AGENTS.md first — it is the rules. This is the how-to. The agent handbook is docs/agents/.
Where does my change go?
flowchart TB
Q{"what are you adding?"}
Q -->|"new generated content"| GEN["<b>universe</b><br/>a generator + an address segment"]
Q -->|"expensive computation"| WRK["<b>workers</b><br/>a task definition"]
Q -->|"new physical behavior"| PHY["<b>physics</b> + <b>simulation</b>"]
Q -->|"something to draw"| REN["<b>rendering</b> (data)<br/>+ <b>apps/game</b> (Three.js)"]
Q -->|"something to store"| PER["<b>protocol</b> (schema)<br/>+ <b>persistence</b> (migration)"]
Q -->|"something to inspect"| DEV["<b>devtools</b>"]
style GEN fill:#0369a1,stroke:#0c4a6e,color:#fffThe layer rule decides most of it: a package may depend only on strictly lower
layers, and pnpm graph will tell you if you got it wrong.
It will not tell you about a cycle inside a package. pnpm graph discards
every intra-package edge before it starts, and an import cycle that is fine for
types is a crash for values: a module-scope constant read across the cycle is
dereferenced before its own module's body has run, which is a
ReferenceError: Cannot access 'X' before initialization under native Node ESM
and — because vitest resolves modules through its own runner — invisible to a
vitest test. apps/headless/src/moduleGraph.test.ts spawns one Node process
per entry point and is the thing that catches it. If you add a value import
between two files that already reach each other, add the entry point to that
list.
Adding generated content
Say you are adding rings to gas giants.
sequenceDiagram
participant A as address
participant S as seed
participant G as generator
participant T as test
A->>A: does it need its own address segment?
Note right of A: rings are a property of a body →<br/>no new segment. Ring *particles*<br/>would need one.
S->>S: derive from the body's seed:<br/>deriveSeed(bodySeed, 'rings')
G->>G: pure function of that seed alone
T->>T: shuffled-order test
T->>T: bump the algorithm versionSteps
- Derive a seed from the owning address — never draw from a caller's stream.
- Write the generator as a pure function of
(seed, parameters). - Add it to the system generator where the body is built.
- Bump
SYSTEM_ALGORITHM's version, because existing saves now describe a different universe. - Add a test that generates it in shuffled order and compares.
The trap. Anything that reads sibling state — "make this moon bigger if the previous one was small" — reintroduces order dependence. If you need correlation between siblings, derive both from the parent in a single pure function that produces the whole set at once.
The trap that looks like the opposite of order dependence. Keeping the draw
order is what protects a body's neighbors; it says nothing about the body. A
draw that consumes exactly the values it always did and returns a different
number is still a different universe at that address, and the version is what
stops two builds agreeing they share an address space whose contents disagree.
Stretching the axial-tilt tail is the case that made this rule: one gaussian in,
one angle out, nothing downstream of it moved in the stream — and 142 bodies in
6,496 now point their poles somewhere else, the worst by 41°. world.stateHash()
cannot catch it either, because a landed entity's numbers are body-frame-relative
and identical on both sides of a pole that moved. If the value changed, spend
the version.
The other trap, from the same family. A ?? that skips a draw is order
dependence in miniature:
// Wrong: reading the seed only when the published value is absent makes every
// *later* draw for this body depend on whether JPL happens to publish a node.
longitudeOfAscendingNode: body.ascendingNode ?? rng.range(0, 2 * Math.PI)
// Right: draw, then override.
const nodeDraw = rng.range(0, 2 * Math.PI)
longitudeOfAscendingNode: body.ascendingNode ?? nodeDrawCalibrate a generator against something measured
If the content has a real-world counterpart, the distribution is not a design
choice and should not be written as one. irregularFigure draws a body's
elongation and roughness from the spread across twenty-five published shape
models; makeSmallBody sizes a belt by Dohnanyi's collisional cascade and floors
its rotation at the barrier a rubble pile flies apart at.
Two things follow, and both have already been learned the hard way here:
- Assert the distribution, not a draw. A claim about a population cannot be evaluated by generating one body. See testing.
- Check that the parameter means what it says.
irregularityis defined as a fraction of the mean radius, and the first version delivered 0.03 when asked for 0.18 — an fBm's standard deviation is a sixth of its range and nothing divided it out. A parameter with a physical name has to be measured against its own definition at least once.
Adding a worker task
export const myTask = defineTask<Request, Response>({
name: 'universe.myThing',
version: 1,
run(payload, context) {
if (context.canceled()) return partial
return result
},
transfers: (r) => [r.buffer.buffer], // if it returns a typed array
})Steps
- Define it beside the existing tasks and register it in the registry both sides import.
- Payload and result must be structured-cloneable — plain objects, arrays,
typed arrays. Seeds travel as hex strings, addresses as their text form,
and a
SurfaceParametersas aWireSurfacethroughencodeSurface— the seed converted, everything else as it is. - Declare
transfersfor large buffers. - Poll
context.canceled()at a granularity where the check costs less than the work. - Test it inline and through a pool, and assert the results match.
The trap. Bumping the payload shape without bumping version. A page left
open across a deploy will then mix two algorithm versions in one universe
instead of failing loudly.
Adding a frame kind
Frames are root (exactly one, the universe itself), fixed (an absolute
position) or dynamic (a pure function of time). Adding a kind usually means
adding a dynamic evaluator in packages/universe/src/frames.ts.
Checklist
- The evaluator must be a pure function of
t— no accumulated state. - Return all four components: position, orientation, velocity, angular velocity. Dropping angular velocity silently breaks the transport theorem for children.
- If the frame can be persisted, its id must determine it completely — see the identity trap.
- Give it a parser beside its formatter, the way
surfaceFrameIdandparseSurfaceFrameIdpair up inpackages/universe/src/frames.ts, then dispatch to it fromWorld.ensureFrame. Keeping the two together is what lets the round trip be a property test — and what stops a repeat of the-0trap, where the parser lived a package below the formatter and had no counterpart to its sign-collapsing rule.
Adding a body kind
BodyKind is a closed union and the tables keyed on it are
Record<BodyKind, …>, so adding one is a compile error until every table has an
entry — which is the point. DENSITY, KIND_COLOR, KIND_ALBEDO,
KIND_ROUGHNESS and KIND_HAZE all live in packages/universe/src/system.ts.
Two things the type system does not catch and that a new kind has to answer:
- Is it a world or is it rubble?
isDebrisandisPlanetKindinpackages/universeare the typed partition, and the display sites, the orbit traces andobservedPlanetsall read them. These were three separateSet<string>literals in three files once, and a ninth kind would have compiled against all of them and landed in the wrong half of each while both tests kept passing. - Is it round? A kind whose members fall below
ROUNDING_RADIUSneeds afigure— see ADR-0013.figure: nullmeans round, not unknown.
Adding something to draw
flowchart LR
SIM["simulation<br/><i>canonical state</i>"] --> SNAP["snapshot<br/><i>add a field</i>"]
SNAP --> SCENE["rendering/scene<br/><i>compute placement + tier</i>"]
SCENE --> R3F["apps/game<br/><i>mutate Three.js</i>"]
SCENE -.- NOTE["decide <b>here</b>, in plain data.<br/>this is the part that gets tested."]
classDef note fill:none,stroke:none,color:#64748b,font-style:italic
class NOTE noteThe trap. Doing the deciding in the React component. If the component knows where something is, that logic is untestable and the boundary has moved. The component's job is to copy numbers onto objects.
Two practical rules from the existing renderer:
- Mutate imperatively for per-frame work. A React reconcile per body per frame at 144 Hz is a lot of work to arrive at the same matrix.
- Give buffers the invalidation rule their coordinates need. Render-space
positions depend on the floating origin, but star sources carry packed
absolute sectors, subcells and remainders.
scene/Starfield.tsxuploads them when selection changes;render/starProjection.tsprojects them against observer uniforms on the GPU. Ordinary camera translation must not become a full source upload. ADR-0038 records source identity and projection ownership.
Adding something to persist
Only if it cannot be regenerated. If it can, it belongs in the generator.
flowchart TB
Q{"can it be derived from<br/>(seed, address, version)?"}
Q -->|yes| GEN["put it in the generator.<br/>storing it makes a stale cache."]
Q -->|no| MUT["it is a mutation, or entity state"]
MUT --> SCHEMA["add to the schema in <b>protocol</b>"]
SCHEMA --> DEC["decode with a default, so older<br/>saves still load"]
DEC --> CAP["capture it in <b>persistence</b>"]
CAP --> TEST["round-trip test:<br/>hash equal <b>and still equal</b><br/>after 300 more ticks"]
style GEN fill:#7f1d1d,stroke:#450a0a,color:#fff
style TEST fill:#065f46,stroke:#064e3b,color:#fffThe trap. Capturing a field but not restoring it. The hashes match at rest and diverge later — which is exactly how the missing control-input bug was caught, and why the round-trip test steps both worlds afterward.
Standing a world up: openSession
Anything that needs a running world — the browser client, the headless runner, the capability checks, a test — goes through one function:
import { openSession } from '@inertialref/devtools'
const session = openSession({
seed: 'inertialref',
workers: () => createInlineWorker(registry), // or null for no pool
store: new MemorySaveStore(),
})
session.harness.orbit('g:milky-way/s:SOL/b:0', 400)It performs seven steps in the one order that works — derive the world from a
seed, load a system, choose a landable body, put a ship above it, stand up a
worker pool, pick a save store, wire the harness — and returns the host the
harness is built over, with the session's extras on the same object: world
(a getter), player(), pool(), replaceWorld(), authority(), now,
render, plus harness, store, system, target and dispose().
| Option | For |
|---|---|
seed, system |
what to generate |
catalog |
the star catalog; defaults to Sol alone |
workers |
a WorkerFactory, or null for no pool at all |
poolSize, now |
pool sizing and an injected clock |
store |
a SaveStore; defaults to in-memory |
authority |
an AuthorityPort; defaults to a LocalAuthority (ADR-0008) |
render |
as much of the render side as the host has: scene(), frameStats(), terrain(), lensView(), framingLens(), setFlightLens(), pixelRatio(), setChrome(), setLayers(), timing() |
onWorldReplaced |
called after a load replaces the world, so the host can drop what it derived from the old one |
render is a nested object rather than members beside world, so nothing in
it can shadow the getter openSession exists to protect; and it is Partial
because a test names the two members it is about and renderHost() answers
the rest the way a display-less runtime honestly can.
Three things about it are load-bearing:
worldis a getter, never a captured reference. Loading a save replaces the world wholesale, and a host that copied the reference kept reporting on the discarded one while the frame loop ran the new one.- The host port is one interface, and its render side is whole.
Hostis the simulation half plusrender: RenderHost; a host that draws supplies the whole render side, and one that does not gets the headless adapter, so no reader of the port asks whether a member is there. - Spawn policy lives here.
landingTarget(system)andisLandable(body)are the one place a target is chosen. When five call sites each did this themselves, two of them drifted to different spawn distances and nothing could have noticed.
If a set-up sequence is shared between the app and the runner, it belongs here rather than in the harness.
Adding a package
packages/<name>/package.jsonwith"type": "module","private": true,"exports": { ".": "./src/index.ts" }andinertialref.layerset to a number strictly above every dependency. Copypackages/rendering/package.json.- Nothing to do for TypeScript. Packages have no
tsconfig.jsonof their own — the root project already includespackages/*/src, and the four host applications have their own config. - Add it to the dependents'
dependencieswithworkspace:*. pnpm installthenpnpm graph.
Note pnpm graph also rejects any third-party runtime dependency in
packages/*. The core has to run unchanged in a browser, a worker and Node, and
depending on nothing but itself is the cheapest way to guarantee that.
The trap. Needing a host capability (DOM, IndexedDB, Worker). Do not raise
the layer to reach it — declare a port and let the host implement it, the
way workers and persistence do. That is what keeps every package runnable in
Node.
Before you open the diff
pnpm checkAnd then the honest questions:
- Does a test fail if the invariant I relied on breaks?
- Is anything new inspectable in the debug overlay or the harness?
- If any generated value moved — not just the draw order — did I bump the algorithm version?
- Did I add a rule that belongs in AGENTS.md, or a decision that belongs in an ADR?
- Did I record anything durable in CONTEXT.md?
Related
- AGENTS.md — the rules
- Architecture — where the seams are
- Roadmap — what is already planned, with its seam identified