1,924 words · 6 diagrams
How InertialRef is put together, in one sitting. Individual mechanisms have their own concept pages; this page is the map that shows where they meet.
The one idea
Everything else follows from a single separation:
flowchart TB
CANON["<b>Canonical state</b><br/>UniverseVector · frames · ticks<br/><i>what is true</i>"]
PRES["<b>Presentation</b><br/>render space · LOD · meshes<br/><i>what is drawn</i>"]
CANON -->|"snapshot<br/>(plain data, one way)"| PRES
PRES -.->|"never writes back"| CANON
style CANON fill:#0369a1,stroke:#0c4a6e,color:#fff
style PRES fill:#334155,stroke:#1e293b,color:#fffThe renderer is a reader. It can be replaced, moved to another thread, or deleted, and the universe is unchanged. A Three.js coordinate is never the truth about where anything is — ADR-0001.
That is why the simulation runs identically in a browser, a Web Worker and Node,
and why apps/headless exists: it steps the same core with no DOM, no React and
no WebGL, so the claim breaks loudly instead of quietly.
The stack, top to bottom
flowchart TB
subgraph L6["layer 6"]
DEV["<b>devtools</b><br/>inspection · capability checks · harness · session"]
end
subgraph L5["layer 5"]
WRK["<b>workers</b><br/>tasks · pool · ports"]
PER["<b>persistence</b><br/>save · migrate · store port"]
REN["<b>rendering</b><br/>origin · LOD · meshing"]
NET["<b>net</b><br/>authority port · local authority"]
end
subgraph L4["layer 4"]
SIM["<b>simulation</b><br/>clock · entities · flight · streaming"]
PRO["<b>protocol</b><br/>codecs · wire · save schema"]
end
subgraph L3["layer 3"]
UNI["<b>universe</b><br/>addressing · catalog · generation · terrain"]
end
subgraph L2["layer 2"]
PHY["<b>physics</b><br/>Kepler · rigid body · atmosphere"]
end
subgraph L1["layer 1"]
SPA["<b>spatial</b><br/>UniverseVector · frames · origin"]
PCD["<b>procedural</b><br/>PRNG · seeds · noise"]
end
subgraph L0["layer 0"]
SHR["<b>shared</b><br/>units · invariants · logging"]
end
DEV --> WRK & PER & REN & NET
WRK --> PRO & UNI
PER --> PRO & SIM
NET --> PRO & SIM & UNI
REN --> SIM & UNI
SIM --> UNI & PHY
PRO --> SPA
UNI --> PHY & PCD
PHY --> SPA
SPA --> SHR
PCD --> SHR
style SHR fill:#0369a1,stroke:#0c4a6e,color:#fff
style SPA fill:#0e7490,stroke:#155e75,color:#fff
style PCD fill:#0e7490,stroke:#155e75,color:#fffA package may depend only on strictly lower layers. pnpm graph enforces
that and rejects cycles; the layer number lives in each package.json under
inertialref.layer.
The rule earns its keep in a specific way: spatial cannot learn what an orbit
is, universe cannot learn what a ship is, and nothing below rendering can
learn what a pixel is. When a lower package needs a capability from a higher one
it declares a port and the host implements it — see
workers.
What each package is for
| Package | Owns | Never knows about |
|---|---|---|
shared |
units, brands, invariants, structured logging | anything domain-specific |
spatial |
absolute position, frame graph, floating origin | orbits, bodies, rendering |
procedural |
PRNG, seed derivation, noise, algorithm versions | what is being generated |
physics |
Kepler solutions, 6-DoF integration, drag, thrusters | which body, which ship |
universe |
addressing, catalog, generation, terrain, frames-for-bodies | entities, ticks |
simulation |
clock, entities, flight, frame transitions, streaming | React, Three.js, the DOM |
protocol |
validated wire/save shapes | where bytes come from |
workers |
typed tasks, job pool, transport ports | Worker (the class) |
persistence |
save capture/restore, migrations | IndexedDB (a port) |
net |
who owns the simulation this client does not | sockets, Cloudflare, transports |
rendering |
canonical→render bridge, LOD, terrain meshing | Three.js |
devtools |
inspection, capability checks, harness, session wiring | — (it may depend on everything) |
rendering deliberately does not import Three.js. It emits positions,
scales, orientations and vertex buffers as plain data; apps/game applies them.
That is what makes the render pipeline testable in Node, and it is why the
"where should this be drawn" logic has tests at all.
One frame, end to end
The tightest loop in the system. Everything above the dashed line is canonical; everything below is presentation.
sequenceDiagram
autonumber
participant TICK as EngineTick
participant ENG as GameEngine
participant CLK as SimulationClock
participant W as World
participant SNAP as snapshot()
participant SCN as buildScene()
participant R3F as React Three Fiber
TICK->>ENG: frame(delta)
Note over TICK: a useFrame at priority −1<br/>so the tick precedes every reader
Note over ENG,CLK: the only place wall clock enters
ENG->>W: advance(delta) → n
W->>CLK: plan(delta) → wanted, budget
loop n fixed ticks (n may be 0, or millions)
W->>W: step(), or one jump over the ticks every entity coasts
Note right of W: gravity · thrust · drag<br/>integrate · frame transitions,<br/>or propagate from the epoch
end
W->>CLK: settle(ran)
ENG->>SNAP: snapshot(world)
SNAP-->>ENG: immutable, structured-cloneable
Note over ENG: - - - canonical above / presentation below - - -
ENG->>ENG: maintain floating origin
ENG->>SCN: buildScene(snapshot, origin)
SCN-->>ENG: placements, tiers, star field
ENG->>ENG: reconcile streamed terrain
R3F->>ENG: read scene, mutate Three.js objectsThree properties of this loop are load-bearing:
deltareaches the clock and stops. The number of ticks is the only thing wall time decides. It is handed over raw, from a component whose only job is to step the simulation — the tick runs at an explicit R3F priority so that the components reading the result are not depending on JSX sibling order. Simulation timenmay be zero, or eight, or eight million. The loop is not "one tick per frame", and a step budget inside the clock prevents a backgrounded tab from returning and freezing the page. That budget lives in exactly one place: a second clamp in the view changed nothing and corrupted the dropped-tick count the HUD shows. What it caps is integration — a tick every entity coasts through is propagated from an epoch rather than stepped, so a frame at 100,000× over a coasting ship is a single jump. ADR-0025- The snapshot is a copy. The renderer holding it cannot mutate the world, and the same structure crosses a worker boundary unchanged if the simulation ever moves off the main thread.
Where a position lives
A single position passes through four representations. Each conversion is lossless in the direction it is used, and each has a reason to exist.
flowchart TB
UV["<b>UniverseVector</b><br/>int32 sector + double offset<br/><code>[-229507999, 583732, -1] + (932.6e9, 815.7e9, 1055.2e9)</code>"]
LOCAL["<b>frame-local Vec3</b><br/>meters from a frame origin<br/><code>(1408267.67, -19516.01, 2944871.77)</code>"]
RENDER["<b>render-space Vec3</b><br/>meters from the floating origin<br/><code>(-1874.1, -41.3, 2294.0)</code>"]
GPU["<b>float32 attribute</b><br/>what the GPU stores"]
UV -->|"universeToLocal(pose)"| LOCAL
LOCAL -->|"localToUniverse(pose)"| UV
UV -->|"toRenderSpace(origin)"| RENDER
RENDER -->|"fromRenderSpace(origin)"| UV
RENDER -->|"LOD + compression"| GPU
UVNOTE["sub-millimeter<br/>anywhere in 249,000 ly"]
LOCALNOTE["exact near its own frame,<br/>lossy far from it"]
RENDERNOTE["kept within ±4096 m<br/>of the camera"]
UV -.- UVNOTE
LOCAL -.- LOCALNOTE
RENDER -.- RENDERNOTE
style UV fill:#0369a1,stroke:#0c4a6e,color:#fff
style GPU fill:#334155,stroke:#1e293b,color:#fff
classDef note fill:none,stroke:none,color:#64748b,font-style:italic
class UVNOTE,LOCALNOTE,RENDERNOTE noteThe middle box carries the subtlety worth internalizing: a frame-local Vec3
is only precise near its own frame. Expressing a point in a frame four
light-years away degrades to meters, because a Vec3 is a double. That is not a
defect to be fixed; it is why canonical state is a UniverseVector and why an
approaching ship is re-framed into the system it is entering.
There is a test that asserts the degradation rather than hiding it —
packages/spatial/src/frame.test.ts, "degrades predictably when a position is
expressed in a far-away frame".
Details: coordinates · frames · rendering
Where the universe comes from
Generated content is derived from a seed and address under declared algorithm versions. Measured catalog records, Solar System data, maps and models are explicit inputs supplied by the host. The manifest identifies which inputs produced the result; it does not select archived implementations.
flowchart LR
SEED["global seed<br/><code>'inertialref'</code>"]
ADDR["address<br/><code>g:milky-way/s:SOL/b:2</code>"]
VER["algorithm version<br/><code>system@4</code>"]
GEN(("derive"))
OUT["the same planet,<br/>every time,<br/>everywhere"]
SEED --> GEN
ADDR --> GEN
VER --> GEN
GEN --> OUT
style GEN fill:#0369a1,stroke:#0c4a6e,color:#fffBecause the address is also the seed path, a body's content depends only on its own identity — not on what was generated before it, in what order, or in how many workers. Determinism · identity
Except where somebody measured it. The catalog is a second generation input
(docs/design/galaxy.md Rule 1), and so are packages/universe/src/solar/ and
data/shapes/ — 7,123 real stars within 150 ly and the 7,514 naked-eye stars
beyond, 702 confirmed exoplanets, the Solar System's 129 bodies, and
twenty-five published shape models. The split is not per object
but per field: an observed body uses the published number for everything
somebody published and derives the rest from its own seed, and the absence of a
value is what decides which. Phobos's whole shape is a measurement; 67P's is a
draw on measured half-extents; a generated moon is a draw all the way down.
ADR-0013
The consequence for storage is stark: a save is the seed, the tick and the dynamic state and references that cannot be reconstructed from those inputs. The self-test's flown session is about 1 KB; a larger session can grow without storing a single generated planet. Persistence
The runtime picture
flowchart TB
subgraph MAIN["main thread"]
ENGINE["GameEngine<br/><i>owns world, origin, scene</i>"]
WORLD["World<br/><i>canonical state</i>"]
REACT["React + R3F<br/><i>UI and view</i>"]
HARNESS["GameHarness<br/><i>window.ir</i>"]
end
subgraph POOL["worker pool (n = cores − 2, max 8)"]
W1["worker 1"]
W2["worker 2"]
W3["worker …"]
end
subgraph STORE["browser storage"]
IDB[("IndexedDB<br/><i>saves</i>")]
SW[("Cache Storage<br/><i>app shell</i>")]
end
GPU["GPU<br/><i>heightfield tiles, WebGPU only</i>"]
ENGINE --> WORLD
ENGINE -->|"typed jobs"| POOL
POOL -->|"transferable buffers"| ENGINE
ENGINE -->|"sixteen tiles a dispatch"| GPU
GPU -->|"read back"| ENGINE
REACT -->|"reads scene"| ENGINE
HARNESS --> WORLD
HARNESS --> ENGINE
ENGINE <--> IDB
SW -.->|"serves the app offline"| MAIN
style WORLD fill:#0369a1,stroke:#0c4a6e,color:#fff
style POOL fill:#065f46,stroke:#064e3b,color:#fff- Workers run galaxy surveys, the terrain detail floor, and the canonical
heightfield — the fallback, and the whole of the ground on WebGL 2. On a
WebGPU page the drawn tiles come from a TSL compute kernel on the GPU,
sixteen a dispatch, held to the workers' field by a measured bound. One
module constructs a
Worker; everything else talks to a port, and the streamer asks aHeightfieldSourcethat both implement. Workers · Streaming - The harness is the same object the headless runner uses, so a bug reproduced in Chrome replays in a test. Harness
- The service worker caches route-specific HTML and fetched assets. A warmed production build passes the twelve capability checks offline; an uncached document, catalog, model or texture still needs a connection. Persistence defines the cache policies.
- The sky combines a bounded magnitude-selected source field with diffuse light from the same luminosity population. Source selection and its diffuse subtraction envelope publish together. GPU projection follows the observer; physical cubes and temporal history retain transported light independently of camera exposure. ADR-0038.
Applications
| App | What it is for |
|---|---|
apps/game |
Astro pre-renders the readable shell; React owns navigation and UI, R3F the persistent scene, and the engine simulation and presentation. |
apps/headless |
The same core in Node — no DOM, no React, no WebGL. Proves the boundary and runs the capability checks via pnpm sim --self-test. |
apps/ingest |
Offline conversion of published catalogs, textures, reference data and shape models into committed assets. |
apps/server |
Cloudflare adapter for static assets, media and the current API/WebSocket boundary. Multiplayer authority remains future work. |
The game and headless runner call openSession in devtools. It wires the
same world and harness while each host supplies storage, workers and catalog
bytes. The server and ingest application do not reconstruct that client
session. ADR-0039 describes how the
readable shell remains independent of renderer startup.
Invariants
The short list. Violating one of these is a rewrite later, not a refactor. The full set is in AGENTS.md; each rule's technical home is in the invariant map.
| # | Invariant | Enforced by |
|---|---|---|
| 1 | Only UniverseVector is an absolute position |
convention + review |
| 2 | No Math.random, Date.now or performance.now in canonical paths |
golden vectors, determinism tests |
| 3 | Generation never depends on order | shuffled-order tests |
| 4 | Canonical state is never in a React component | components read snapshots; packages/* has no DOM lib |
| 5 | One module constructs a Worker |
port interface in workers |
| 6 | Nothing regenerable is persisted | save-size test |
| 7 | No vendor SDK below the adapter layer | pnpm graph — packages/* may have no third-party dependency |
| 8 | Terrain is sampled in body-fixed axes | a branded BodyFixedDirection type |
| 9 | Landedness is a consequence, never asserted | teleport has no landed flag |
| 10 | Entity state is written through World's verbs alone |
world.entities is a read view; spawnShip takes a velocity |
Invariant 8 is worth calling out as a pattern. It began as a bug — terrain was
sampled with an inertial direction, so the mountains stood still while the
planet rotated underneath them, and a ship landed 83 m above the ground it had
just touched. The fix was correct code, and it was applied to one of the two
terrain samples in stepFlight; the other went on feeding the atmosphere an
inertially-sampled altitude, where nothing rendered wrong and no test failed.
The durable fix was giving the body-fixed direction its own type, so the wrong
vector no longer compiles. Adding the brand immediately surfaced a third call
site nobody had found by reading.
Invariants 9 and 10 have the same shape. teleport used to take a landed
boolean, and the harness used it to declare a ship landed three meters above the
pad — stepFlight short-circuits for an already-landed entity, so the contact
test never ran and the ship hovered there for the rest of the session while the
overlay reported an altitude of zero. Removing the parameter makes the state
unreachable rather than merely discouraged.
Verification
pnpm check # every stage below as one graph under the core budget — scripts/check.mjs| Stage | What it proves |
|---|---|
graph |
layering intact, no cycles |
spelling |
no British identifier declarations |
brand:check |
generated brand artifacts match their source |
presets:check |
every picture still has a plate, and every composition it names still resolves |
format:check |
committed files match Prettier |
lint |
oxlint across the workspace |
typecheck |
five tsconfig projects plus Astro templates |
test |
the regular Vitest suite runs in plain Node — packages/* and apps/* alike |
test:slow |
terrain descent and galaxy convergence/population checks run separately from the regular suite |
sim |
the twelve capability checks, against the assembled world in Node |
build |
the server renders every public route and bundles the client and workers |
The twelve capability checks execute the milestone's claims against the
live build — in Node as the sim stage, which is pnpm sim --self-test, and
in the browser via await ir.selfTest(). They are the definition of done made
executable rather than described. The thirteen preset pictures are the fixture
for anything visible: a headless ledger in pnpm test, and
pnpm presets:compare through the renderer. Testing
Reading on
- Concepts — how each mechanism actually works
- ADRs — why, and what was rejected
- Development — commands, toolchain, conventions
- Agent handbook — how to change it
- Roadmap — what is deliberately not built yet