Extending

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:#fff

The 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 version

Steps

  1. Derive a seed from the owning address — never draw from a caller's stream.
  2. Write the generator as a pure function of (seed, parameters).
  3. Add it to the system generator where the body is built.
  4. Bump SYSTEM_ALGORITHM's version, because existing saves now describe a different universe.
  5. 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 ?? nodeDraw

Calibrate 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. irregularity is 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

  1. Define it beside the existing tasks and register it in the registry both sides import.
  2. Payload and result must be structured-cloneable — plain objects, arrays, typed arrays. Seeds travel as hex strings, addresses as their text form, and a SurfaceParameters as a WireSurface through encodeSurface — the seed converted, everything else as it is.
  3. Declare transfers for large buffers.
  4. Poll context.canceled() at a granularity where the check costs less than the work.
  5. 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 surfaceFrameId and parseSurfaceFrameId pair up in packages/universe/src/frames.ts, then dispatch to it from World.ensureFrame. Keeping the two together is what lets the round trip be a property test — and what stops a repeat of the -0 trap, 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? isDebris and isPlanetKind in packages/universe are the typed partition, and the display sites, the orbit traces and observedPlanets all read them. These were three separate Set<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_RADIUS needs a figure — see ADR-0013. figure: null means 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 note

The 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.tsx uploads them when selection changes; render/starProjection.ts projects 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:#fff

The 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:

  • world is 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. Host is the simulation half plus render: 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) and isLandable(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

  1. packages/<name>/package.json with "type": "module", "private": true, "exports": { ".": "./src/index.ts" } and inertialref.layer set to a number strictly above every dependency. Copy packages/rendering/package.json.
  2. Nothing to do for TypeScript. Packages have no tsconfig.json of their own — the root project already includes packages/*/src, and the four host applications have their own config.
  3. Add it to the dependents' dependencies with workspace:*.
  4. pnpm install then pnpm 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 check

And 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?