5,942 words
The canonical imperatives live in AGENTS.md. This page maps
each one to its technical home and carries the full constraints, exceptions,
and reasoning below. Read the relevant rule in full before changing its area;
the short imperative is an index, not a substitute for those constraints.
Path-scoped copies in .claude/rules/ carry
only the imperative. If a copy disagrees, AGENTS.md wins.
branching.md, writing.md and browser.md are the three exceptions and have
no row here. They are unscoped process rules, not invariants about the code, and
they mirror working.md § "Starting work",
STYLE.md and the
drive skill. A rule missing from this
table is one of those three, not drift.
| Invariant | Technical home |
|---|---|
No absolute position in a Vec3 |
Coordinates · ADR-0001 |
No wall-clock or Math.random in canonical code |
Determinism · ADR-0005 |
| Never hold an algorithm version because the draw order is intact | Determinism · ADR-0027 |
| Generation does not depend on order | Determinism · Identity |
| The three emit APIs live only in the sink | ADR-0022 · Observability |
| Canonical state is not in React | Architecture · ADR-0011 |
| Presentation switches go through the stance stack | Client |
One Worker constructor |
Workers · Development |
| One session constructor | Client |
| Terrain is sampled in body-fixed axes | Frames |
| No field value read off a rank | ADR-0019 · Determinism |
| No planetary radius subtracted in a shader | ADR-0020 · Rendering |
| The ground and the sphere behind it shade alike | ADR-0020 · Rendering |
No dispose() on a mesh holding the shared index |
ADR-0021 · Rendering |
| No stored value derived from a captured snapshot | ADR-0012 · Client |
| No varying named after an attribute | ADR-0020 · Rendering |
| No attribute object under two names | ADR-0021 · Rendering |
A stand-in DataTexture is filtered like its map |
Testing |
| Never build two texture nodes over one stand-in object | ADR-0021 · Testing |
| No canonical reader of the drawn ground, or the reverse | ADR-0021 · Terrain |
| No fine lattice coordinate from an absolute float32 direction, no lattice decision in a float | ADR-0023 · Rendering |
Entity writes go through World's verbs; world.entities is the read half |
Architecture · ADR-0025 |
| Landedness is a consequence, never asserted | Architecture |
| A coasting entity's epoch does not survive a move it did not make | Time · ADR-0025 |
| Saves store references, not regenerable content | Persistence · ADR-0007 |
| The star catalog is an argument, not a singleton | Galaxy · Catalog |
| Do not store what the catalog can derive | Catalog |
| A survey is not a search box | Catalog |
| Issue order is not orbital order | Identity · ADR-0009 |
| No hosting SDK below the adapter | Architecture · Hosting |
| Do not let the compiler memoize a mutable read | Client |
| The canvas is not inside a route | Client · ADR-0011 |
| Mode is a function of the path | Client |
| Overlays resolve location through one helper | Client |
| Render compression is radial about the eye | Rendering · ADR-0003 |
placement.scale is a radius, compression is the ratio |
Rendering · ADR-0015 |
A body with a figure is not also flattened |
ADR-0013 |
figure: null means round, not unknown |
ADR-0013 |
| An unmeasured field is a row, in the universe's voice | ADR-0014 · Planetarium |
| Chrome is not sized or positioned against the viewport | Client · DESIGN.md |
| One producer of the camera | Client · ADR-0011 |
| One producer of the lens; the field of view is derived | ADR-0017 · Art |
| One window-level key listener; actions by id | ADR-0018 · UX § controls |
No localStorage outside state/preferences.ts |
ADR-0018 · Persistence |
| No key name written into a label | ADR-0018 · UX § controls |
| The aim is an offset on the pose; drag by the pixel angle | ADR-0018 · Planetarium |
| The planetarium does not write canonical state | Planetarium |
Presentation asks at renderTime, not clock.time |
ADR-0006 · Planetarium |
Mode chrome needs pointer-events-auto |
Client |
Overlay presence is not mode="wait" |
Client |
| Do not latch a "run once" effect with a ref | Client |
Dock moves go through layout.ts |
Client · ADR-0012 |
A new document under docs/ goes in a wing |
Development · ADR-0016 |
A plan goes in design/, never under docs/ |
Development · ADR-0016 |
| One component per file | Development |
| Do not hand-roll a registry control | Development · DESIGN.md |
| Cinematic effects are staging, not scripts | Cinematics |
| A scripted camera clears the prop it stages | Cinematics |
| Shots hand a prop over on a shared knot | Cinematics |
| Labels are sentence case in source | DESIGN.md · UX |
Import three/webgpu, not three |
Rendering · Development |
| Compile-ahead goes through one recipe | Rendering |
Do not edit files pnpm brand writes |
Development |
| Site metadata is duplicated on purpose | Hosting · Development |
| Third-party tags load through the analytics module | Hosting |
| A readout neither changes the frame nor stops it | ADR-0022 · ADR-0044 |
When a defect exposes a missing invariant, add the rule to AGENTS.md, a
one-liner under .claude/rules/, a row here, and a regression test that can
actually fail.
Full rules and reasoning
Rule 1
Never put an absolute position in a Vec3. UniverseVector is the only
type that may claim to be one. A Vec3 is a displacement or a frame-local
coordinate.
Rule 2
Never use Math.random(), Date.now(), or performance.now() in
canonical code. Generation derives from seeds. Simulation depends on the
integer tick. Wall clock enters at exactly one call, clock.plan, which
World.advance hands the frame's delta; settle takes a count of ticks run,
and nothing downstream of either sees a second.
Rule 3
Never call console.timeStamp, performance.mark or performance.measure
outside engine/browserTiming.ts, and never name performance. in
packages/* at all. Emit through a Timer from
packages/shared/src/timing.ts; the sink is the one place that knows a
platform API. Two reasons, and the first is the rule above wearing a different
hat: canonical code may write to the wall clock and may not read one, and
Span.end() returning void is what makes that true by construction rather
than by discipline — there is no expression a caller can write that observes a
duration, so no canonical value can be a function of wall time. The second is
that the level, the drain and the clear are each impossible unless the set of
emitters is known in one place, which is the same argument the localStorage
rule makes. apps/headless/src/coreHostApis.test.ts greps for it, because
performance is a global rather than an import and pnpm graph structurally
cannot see it. ADR-0022.
Rule 4
Never make generation depend on order. Derive a seed from the address. Do not draw from a shared stream.
Rule 5
Never hold an algorithm version because the draw order is intact. Order
protects a body's neighbors and says nothing about the body: a draw that
consumes the values it always did and returns a different number is still a
different universe at that address, and world.stateHash() cannot see it.
If a generated value moved, spend the version.
ADR-0027.
Rule 6
Never put canonical state in a React component, and never put gameplay
behavior in a lifecycle callback. Components consume snapshots from
apps/game/src/state/engineStore.ts. Subscribe to the narrowest slice you
need, and do not add a timer of your own — one sampler owns the rate.
Rule 7
Never write a presentation switch directly. showShip, showOrbits,
orbitScope, labels, flareArtifacts, chrome and the observatory's
target go through engine.presentation: a mode pushes a stance on mount and releases it on
unmount, a panel's override is another push, and release() restores what
was underneath rather than a literal. The rule has no carve-out for the
fields that look like preferences — orbitScope is read by the frame loop,
which is the other half of why it cannot be React state.
Rule 8
Never construct a Worker outside apps/game/src/engine/browserWorker.ts.
Tasks are typed and versioned; the pool owns dispatch, cancellation, and
instrumentation.
Rule 9
Never assemble a session by hand. openSession in packages/devtools
builds the world, the ship, the pool, the store, and the harness, in the
one order that works. A host passes adapters in.
Rule 10
Never pass a bare Vec3 to anything that samples terrain. The argument
is a BodyFixedDirection. The only producers are bodyFixedDirection,
geodeticDirection, and regionDirection.
Rule 11
Never read a field value off something chosen by rank. Nearest,
second-nearest, the neighbor across an edge — the value of a ranked distance
is continuous and its identity is not, so a property read off the identity is
a cliff along the whole locus where the ranking changes. Those loci do not run
where the boundary you were guarding runs: which plate is second changes along
curves through every plate's interior, which was 3,081 m of wall on Earth
and 1,532 m on Proxima Centauri II with the boundary blend already correct, and
a cube-sphere ring walk counts one of seven neighbors twice at each of the
eight corners. Weight every candidate within a margin by a smooth function of
how much farther it is than the nearest, normalize, and sum — plateProperty —
or put the field on a lattice with no ranking in it, which is what craters.ts
does. The weight has to reach zero before a candidate can leave the set, and
the margin has to be no wider than the search that collected them.
ADR-0019.
Rule 12
Never write entity state around the world's verbs. world.entities is
the store's read half, so there is no update to reach for. A ship that
starts moving is spawned moving — spawnShip takes the velocity — and after
that it is teleport for a discontinuous move and setControl /
setThrottle / setFlightAssist / killRotation for input. Each carries the bookkeeping a
write needs: the interpolation history, the landed set, the rails epoch.
Rule 13
Never assert that something is landed. Landedness is a consequence of
the contact test, owned by World.#land. teleport has no such flag.
Rule 14
Never let a coasting entity keep its epoch through a move it did not
make. An entity with rails set is propagated from that epoch, not from
its state, so a state written beside a stale epoch is a ship in two places at
once. teleport, reframeEntity, a non-neutral setControl, and a frame
change all drop it, and the epoch is in the state hash and the save because
two worlds that agree on a state and not on its epoch part in the low bits
on the next tick. ADR-0025.
Rule 15
Never persist anything regenerable. A save stores references and mutations. Generated content belongs in a cache, not a save.
Rule 16
Never make the star catalog ambient. Pass it as an argument —
resolveSystem, systemsWithin, new World({ catalog }). A singleton
would hide the catalog version, which is what invalidates saves when
astronomy publishes. Galaxy, rule 1.
Rule 17
Never store what the catalog can derive. The packed file carries measurements. Temperature, luminosity, radius, mass, and color are computed at load.
Rule 18
Never filter a survey to serve a search box. travelTargets is a star
sweep with a radius; StarCatalog.search is an index over the whole catalog.
They answer different questions and only one of them can run per keystroke.
Rule 19
Never sort a system's planets by orbit and call it order. b:2 is the
third body issued, not the third one out. orbitalOrder is for display.
ADR-0009.
Rule 20
Never import a hosting vendor's SDK below the adapter layer. Nothing in
packages/* may know what a Durable Object is.
Rule 21
Never let React Compiler memoize a component that reads mutable state.
Opt out with 'use no memo'. See apps/game/src/hud/PerfPanel.tsx. This is
not license to hand-write useMemo.
Rule 22
Never put the <Canvas> inside a route, and never let a mode assume it
owns the page. Root keeps the browser runtime and server-rendered page shell
outside every route. App owns the persistent canvas; PageShell owns routed
chrome. ADR-0039.
Rule 23
Never hold the current mode in React state. It is
modeForPath(resolvedLocation(location).pathname) in pages/paths.ts.
Rule 24
Never read the raw pathname when a dialog could be open over a mode.
Use resolvedLocation. Links that stay inside a dialog, and controls that
close one, go through pages/useOverlay.ts.
Rule 25
Never apply flattening to a body that has a figure. A figure is
present exactly when a body is not a spheroid, and its mesh already carries
all three measured half-extents. flattening is polarRadius / radius, which
the mesh has already spent — applying it as well squashes the body a second
time by the same ratio, which on Phobos is 26%. render/bodyUniforms.ts
branches once, on whether the body has a figure, and everything downstream of
that branch is on one side of it: the mesh's scale, and the cloud deck's and
the atmosphere's shells, which are round about a figured body.
bodyUniforms.test.ts holds all three.
ADR-0013.
Rule 26
Never leave a field out of a record because nothing has measured it.
Fact.value is nullable, a null draws as no data with its reason attached,
and the panel counts them. An absent row cannot distinguish "this body has no
atmosphere" from "nobody has measured this body's atmosphere", and those are
opposite claims about the same world. The reason is written in the
universe's voice, never in the engine's — "no magnetometer has been flown
through it", not "the generator does not produce one". A projected world is
real; projected is a claim about the record rather than about the place,
and the planetarium is a reading room for a galaxy that is there rather than
a debugger with a starfield behind it. dossier.test.ts greps every reason
for the vocabulary that would break it.
ADR-0014.
Rule 27
Never read a body's figure as "unknown". figure: null means round.
Every planet, every large moon, Pluto and Ceres are spheroids and carry none;
the ninety-two bodies in Sol that are not, and every generated body below
ROUNDING_RADIUS, carry one. A renderer that treated null as "no data
available" and fell back to a sphere would be right by accident and wrong the
moment the fallback changed.
Rule 28
Never place a compressed body about the render origin. placeAt takes
the eye in render space and compresses radially about that, and so does
placePathInto, which is the same arithmetic written straight into a vertex
buffer for a whole path. The origin is a snapped grid point that lags the
camera and jumps; compressing about it gives every far object a parallax
error that sawtooths at the rebase cadence, which is small bodies visibly
vibrating in their orbits.
ADR-0003.
Rule 29
Never scale metric geometry by placement.scale. It is the drawn radius
in meters, which is what a unit sphere wants; anything carrying its own
geometry wants placement.compression, the dimensionless ratio beside it.
Terrain is the case that found this: a patch's vertices are true meters from
its anchor, and multiplying them by a radius rather than a ratio put them
10^12 m away. The two fields sit next to each other and only one of them
reads like a factor. ADR-0015.
Rule 30
Never size or position chrome against the viewport. No 100vh, no
100vw, no env(safe-area-inset-*) at a call site. .hud-layer spends the
four insets as offsets — padding cannot do it, because an absolutely
positioned child resolves against the padding edge — so an absolute child
is already inside them; a surface that is picture and must reach the
display's own edges says so with hud-bleed, and a corner readout inside one
of those is laid out in flow so the padding can reach it. The document itself
is 100dvh and cannot scroll.
Rule 31
Never add a second producer of the camera. The order is cutscene, then
observatory, then the walker, then the ship, written once in
engine/frameOwner.ts and resolved once a frame by GameEngine.#step, which
publishes the result as engine.owner: the arm, its eye, and the player's
ship. No arm of that order may depend on a later one resolving. Only the
walker and ship arms need a player, only the ship arm hands buildScene an
entity as its eye, and buildScene takes the owner's eye, so a playerless
observatory frame is drawn like any other. A consumer switches on
engine.owner.arm and names the ship by engine.owner.ship; it never
re-derives either from engine.cinematic === null or isCamera, which on foot
is the walker.
Rule 32
Never add a second producer of the lens. It follows the camera's own
precedence through engine.lens: the cinematic lens wins, an active fixed
galaxy instrument supplies its declared lens within the observatory arm, and
the flight lens is the fallback. Ordinary observatory framing reads
framingLens(), the flight lens alone; a fixed galaxy instrument owns its
pose and optics together. Entering or leaving that instrument preserves the
flight lens. ADR-0038 records
the active galaxy view.
The field of view is derived from focal length, gauge and zoom and is never
stored beside them; CameraRig writes camera.fov and nothing else does —
the aspect it writes beside it is the viewport's, not the lens's — never
filmGauge or setFocalLength, because Three's gauge is the sensor's
long side divided by the aspect ratio and a lens whose angle moved on a resize
would move the terrain selection, the observatory's standoff and every
composed shot with it. A consumer that cannot see the lens is a bug, not a
case to have a default for: a camera.fov ?? 65 fallback fires exactly when
the camera is not a PerspectiveCamera, which is when the picture is least
like the one any fixed angle describes. <Canvas camera> sets the initial
angle from DEFAULT_LENS and is the one exception, because it is a
constructor argument rather than a writer.
ADR-0017.
Rule 33
Never add a second window-level key listener. input/keymapStore.ts owns
the one keydown in apps/game/src, and there were six: two read event.key
and four read event.code, which is why + carried a comment about Shift. A
mode registers a handler for an action id (useAction) and declares which
context it is (useKeyContext); it never sees a key. Conflicts are checked
against LIVE_SETS — every set of contexts that can be live at one moment —
because global is live alongside everything, and "conflicts within a context"
misses exactly the pair that shipped as a bug: Space was the pause key and
the cinema transport, both handlers ran, and clock.paused flipped twice.
ADR-0018.
Rule 34
Never call localStorage outside state/preferences.ts. Every key is
declared there once — default, guard, revive, migrate, group — and a call site
takes the definition rather than a key string, so an unregistered preference
is a name that does not resolve. The rule is not tidiness: the export, the
import and the live subscription each have to know the whole set, and none of
them is possible with the calls spread out. An import reaches mounted hooks
through that subscription, because a reload here rebuilds the renderer.
Rule 35
Never write a key name in a label. Not in a title, not in an aria-label,
not in a help table. useActionTitle(id, text) and KeySheet read the live
chord, which is the only kind of help a rebindable build can have — and the
two hand-maintained tables of prose that named keys as string literals are
exactly what /settings/controls was printing while it said rebinding was not
built.
Rule 36
Never turn the head at a constant radians-per-pixel. Drag sensitivity is
pixelAngle(lens, viewport), so the ground under the pointer follows the
pointer; a bare constant swung the frame through three of its own field-widths on a 100 px
drag at 8× zoom. The aim is a LookOffset on the pose, and it is cleared by
whatever replaces the pose — a focus, a frame, a stand, a composed set of
angles — and by nothing else, so a viewer who turned to look at Io beside
Jupiter is still looking at Io after the wheel.
ADR-0018.
Rule 37
Never let the planetarium write canonical state. The observatory
resolves an address, asks the world where that is at renderTime, and
returns a pose. No teleport, no clock, no entity write, no save.
Planetarium.
Rule 38
Never ask where something is at clock.time in order to put it in a
frame. Live presentation happens at SimulationClock.renderTime — one tick
back, plus the interpolation alpha. A photographic preset supplies an explicit
observatory instant to the same snapshot, camera, terrain and orbit traces
(ADR-0033). clock.time is the tick, which
moves in 1/64 s steps. Anything that places, points at, aims at or measures
against a body for the picture uses the same instant the picture is drawn at,
or it is aiming at where that body used to be by its velocity times up to
15.6 ms, sawtoothing as alpha resets. The cost is that error in units of the
thing's own radius, so it is invisible on a planet and enormous on a small
fast moon: at clock.time the observatory vibrated Phobos and Deimos by 11
and 19 pixels in the planetarium at 1×, while Mars and Luna held inside a
twentieth of a pixel. Three sites have now had to learn this — the terrain
streamer, the observatory, and the orbit traces — and nothing mechanical
catches a fourth, so it is a rule rather than three comments.
ADR-0006.
Rule 39
Never give a mode its chrome without pointer-events-auto.
.hud-layer is pointer-events: none so the scene stays reachable.
Rule 40
Never give AnimatePresence mode="wait" over the overlay routes, and
key it on the dialog's surface (overlaySurface), not its pathname.
Rule 41
Never guard a "run once" effect with a ref. Reconcile against the
state's actual owner (observatory.target?.address === wanted).
Rule 42
Never move a workspace panel by splicing an array at a call site.
dock/layout.ts owns every move. Use the updater form of the setter.
ADR-0012.
Rule 43
Never derive a stored value from a captured snapshot.
set((held) => ({ ...held, field })), never set({ ...value, field }). Two
writes composed against one snapshot discard the first, and the window is not
a frame: a preference drawn in two places — the dock's panel and its
/settings twin — holds two snapshots of one key for as long as both are
mounted. The dock's splice rule above is one instance of this, not the
general case.
Rule 44
Never put two components in one file. react/no-multi-comp is an
error. A constant or type goes in a sibling .ts. Exemption:
apps/game/src/components/ui/*.tsx.
Rule 45
Never hand-roll a control the registry already has. Go through
hud/Action.tsx, hud/SwitchRow.tsx, or hud/TransportButton.tsx.
Rule 46
Never add a markdown file under docs/ without listing it in
scripts/docs/wings.mjs. Everything there is published at /docs and the
wing table is what says where, so a file no wing lists is one the site cannot
place — pnpm docs:build refuses rather than guessing, and takes pnpm build
and pnpm check with it. An ADR is the common case, and the rule has already
caught its own author: the record arguing for the documentation site was the
one page the site would not publish.
ADR-0016.
Rule 47
Never write a plan under docs/. Plans, working reviews and tooling
reports go in design/, which is outside the published tree. docs/ is the
finished account of what the system does; a reader who reaches a
documentation page expects the system to already behave that way, and a plan
promises the opposite. Once a plan's phase lands, the decision it settled
belongs in an ADR and the plan loses the section — the ADR is what the code
and the guides then cite.
Rule 48
Never let a cinematic effect fire off a script. An effect is staging.
It belongs in CinematicEffects, where a shot turns it on, and it is 0
everywhere else. Cinematics.
Rule 49
Never fly a scripted camera through the prop it is staging. Beats are
authored in screen terms, so nothing in them says how close the camera comes
to the geometry — and a frame-diff against a reference cannot tell you,
because the inside of a hull is as large and as lit as the outside. It is
checked against the shipped asset, headlessly, by
apps/headless/src/hullClearance.test.ts, and it is a test rather than a
clamp: a director that quietly pushed the camera out would make an
authoring mistake invisible and put a conditional inside the one thing a
scripted scene has to be, which is reproducible.
Cinematics.
Rule 50
Never treat a beat past a shot's last frame as dead. A Catmull-Rom
segment is shaped by the knot beyond its far end, so exit beats authored
"after the cut" set the tangent of the segment the shot still renders.
tng-intro's cruise flew an entire warp-out across its own last twelve
frames this way — 432 m to 17.4 km in the clear — and the next shot's
entry knot snapped the hull back. Where two shots hand a prop over, they
share the knot: change one and change the other.
Cinematics.
Rule 51
Never write a label in the case you want to see it in. Source strings
are sentence case; text-transform on the type step decides what is shouted.
Rule 52
Never subtract two planetary radii from each other in a shader, and never take a
screen-space derivative of a planetary position. At Earth's radius one float32
step is half a meter, so length(anchor + local) − radius arrives quantized to
half a meter — and the morph walks it across those steps every frame, which is a
coastline visibly warping several times a second from two kilometers up. Use
(2(a·l) + l·l)/(|p| + |a|), which never lets the large numbers meet. The
derivative is worse than the value: a tenth of noise and biased per patch, which
moved the albedo map's mip level at every patch boundary. And local is linear
across a triangle, so dFdx(local) is constant over the whole triangle — a detail
fade measured that way steps per polygon, where distance times the lens's pixel
angle does not. A varying may not take an attribute's name either: both become
identifiers in the generated WGSL, and the redeclaration surfaces as
[Invalid ShaderModule "vertex"] with the real message on a channel the page
console does not carry.
ADR-0020.
Rule 53
Never read the drawn ground where the canonical one belongs, or the
reverse. groundElevation and surfaceRadius are the field the contact
test integrates, the saves record and the survey sites name;
drawnElevation and drawnSurfaceRadius are that field plus a
presentational tail, and they are what the material and any camera that
composes a picture of them are made from; drawnGroundElevation is the
same with the sea clamp left off — the seabed — and is what the mesh is
made from where the sea is a sheet drawn over it, and only there: a mapped
body has no sheet, its photograph is its sea, and its mesh keeps the clamp,
which the heightfield request says with seabed. drawnElevation and
drawnSurfaceRadius differ from the canonical field by at most
drawnDivergence, which is 1.25 m; drawnGroundElevation is below it by
the depth of the sea. Physics reading the drawn one puts a
landing behind a term the renderer is free to change; a mesh reading the
canonical one draws a plane at two meters, because the tolerance a patch is
refined against is the amplitude floor the canonical field stops at, so
nothing under it can ever deepen the selection.
ADR-0021. Under a walker the two are read in
one place: bodyGround in packages/rendering/src/ground.ts answers both
along a ray, its canonical half is supportUnder — the function
World.contactRadius reads — and its drawn half carries a deck onto the
drawn ground at its anchor, which is where the scene draws the model.
Rule 54
Never give two attribute names one BufferAttribute object. Two
vertex-rate attributes sharing one object is a pipeline that does not build.
It reports [Invalid ShaderModule "fragment"] is invalid due to a previous error, with the real message on a channel the page console does not carry
and the canvas never presenting — and warmCompile swallows its rejection,
so a warm-up making the same mistake fails silently first. The same aliasing
on an instanced attribute builds, which is how it was isolated; the
mechanism is unexplained and the rule is deliberately the wider claim. Two
attribute objects over one array is a few bytes and one fewer trap.
ADR-0021.
Rule 55
Never call geometry.dispose() on anything holding the shared index.
Go through render/groundWear.ts's disposeKeepingSharedIndex. Every patch
geometry references the one session-wide index attribute, and three's
dispose path destroys each referenced attribute's GPU buffer with no
refcount — so disposing one evicted mesh kills the 98 KB index under every
patch still drawn and re-uploads it next frame. It is the churn the shared
attribute exists to prevent, it is silent, and the ground, the sheet and the
orbital bake all evict through the one helper.
ADR-0021.
Rule 56
Never leave a stand-in DataTexture at its nearest default. Every
material here runs one graph whether or not its maps have arrived, on the
strength of a 1×1 stand-in, and the boot warm-up compiles the object the
first time it exists — before setTextures has run. That program is then
frozen and the real map is bound into it: a TSL texture() node's value
swap changes the binding and nothing else, no cache key observes it, and no
WGSL is rebuilt. Measured on the device — a node built over a nearest 1×1
compiles textureLoad with no sampler, and after assigning a linear map the
fragment shader is byte-identical. So a nearest stand-in is not a warm-up
that misses; it is the filtering the body then draws its 8K albedo with, at
mip 0, point sampled, with no anisotropy. The ground's version has no
textureLoad path at all: the gradient sample names a sampler that was
never declared, Tint refuses the module, and standing on a mapless body
streams 706 patches into a black frame with [Invalid ShaderModule "fragment"] on the console. Set both filters linear;
materials.gpu.test.ts holds each stand-in and a real map to one program.
Rule 57
Never build two texture nodes over one stand-in object. The builder
shares a uniform between nodes whose textures carry one uuid —
TextureNode.getUniformHash is the uuid, and UniformNode.generate
hands every later node with that hash the first node's binding — so two
nodes over one stand-in compile to a single binding owned by the first,
the warm-up freezes the program there, and the second node's value swap
binds nothing. The sphere's relief record read its slopes and its sea
mask out of the reflectance that way: an icy moon's 0.8 of albedo was a
sea mask of 0.8, and Enceladus was a dark disk under a sun-glint, over a
bake that read back correct to three places. One stand-in object per
node — RING_WHITE beside WHITE, BLANK_RELIEF beside
BLANK_REFLECTANCE — costs a few texels, and materials.gpu.test.ts
holds a bound bake to the stand-in's binding count.
Rule 58
Never take a fine lattice coordinate from an absolute float32 direction,
and never take a lattice decision in a float. The GPU tile producer is a
port of drawnElevation — of drawnGroundElevation where the request says
seabed — held to a measured bound; two things make that
bound small and both are easy to undo. A float32 unit vector resolves
6 × 10⁻⁸ of a radian and the tail's one-meter crater on Luna subtends
3 × 10⁻⁷, so direction · cells from an absolute direction quantizes every
fine rung to a fifth of a crater and does so differently on either side of
every patch edge — a rung reads its tile's frame, the cell and fraction the
patch center falls in from float64 (writeTileFrame), plus the sample's own
offset. And whether a cell holds a crater is a step: the sphere test's inputs
are integers over one float, so successive cells sit 1/cells² apart —
2 × 10⁻⁷ at 2,300 cells, 10⁻¹² across the tail — and a float32 comparison
lands on the wrong side wherever that is under its resolution, which was a
whole crater on one processor and not the other, 44 m on Luna. The test is
Σ m² > floor(cells²) in 48-bit integers on the GPU and in exact float64 on
the tail's CPU path (ChordForm), and a crater exists when its hash is under
a u32 threshold, never when toUnit(hash) < density.
ADR-0023.
Rule 59
Never add a shading term to the ground without adding it to the sphere.
render/terrain.ts and render/planet.ts draw the same body on either side
of the eight-pixel relief gate — the streamed ground below it, the archive's
sphere above — so they share the lunar-Lambert split, the terminator and the
published photograph, and a term on one side alone is a step at the switch a
descent flies straight through. Measured across it: 3.1% apart in mean value
on Mars, 1.5% on Earth. Three terms had to be matched to get there — skylight
comes out of the direct beam rather than beside it, the aerial veil the
disk wears is carried by the ground as well because the shell only survives
outside the silhouette, and where a photograph exists it supplies the albedo
outright. Each was 15%, 48% and 9% of the drawn value on its own.
ADR-0020.
Rule 60
Never import from three in apps/game. Import three/webgpu and
three/tsl. packages/* may not import Three.js at all.
Rule 61
Never hand-write a compile-ahead. render/warmup.ts owns the recipe —
the visibility toggle compileAsync silently needs, the renderer cast, the
swallowed rejection — and the census the boot progress totals. Registration
is idempotent by label, because StrictMode does everything twice.
Rule 62
Never edit a file pnpm brand writes. The mark is
design/brand/brandmark.svg. pnpm brand:check is in pnpm check.
Rule 63
Never change what the site says about itself in only one place.
src/site.ts supplies route metadata to the server-rendered head and to
pages/DocumentMeta.tsx for client navigation. documentHead.ts emits the
shared head that scrapers read.
Rule 64
Never load a third-party tag from the document head. src/analytics.ts is
the gate: production build, canonical host, no Global Privacy Control.
Rule 65
Never let a readout change the frame or stop it. A census, a diagnostic
getter, a timestamp or a byte total is an instrument, and an instrument that
can throw or can write is a way for the scene to disappear over a number
nobody is reading. The upscaler's byte census wraps the device's own
createTexture, so its throw on an unrecognized format abandoned configure()
with the kernel half-allocated and then threw on every frame after; it
over-counts an unknown format and warns. The sensor's diagnostics getter read
the drawing buffer into the closure-scoped vector that is the submitted frame's
viewport — the reference the defocus circle, the signature and the meter's
readback are interpreted against — so a property read between submission and
readback moved it on a resize frame; the report has its own vector. Reach for a
library's private field with ?., count an unknown quantity generously, log,
and hand back whatever the caller can still use.
ADR-0022,
ADR-0044.