Drawing the Real Night Sky in a BrowserSection 10 of 10

Design Principles, in Summary

The five principles that run through decisions as different as astronomy and asset delivery, and how each of them connects back to one constraint: no server-side code.


The nine sections before this one — the big picture, computing star positions, coordinate frames, generating the data, drawing the atmosphere and the stars, conventions, state management, delivery, verification — deal with problems that have little to do with one another.

The question here is a single one. Seen across all of them, how many principles do those decisions reduce to? The answer is five, and they come below in this order. Principle 1: where state lives is decided by how often it changes. Principle 2: exactly one actor may write any given value. Principle 3: dependencies between layers point one way. Principle 4: duplication is accepted where it keeps pages isolated. Principle 5: every bug met is written into the code, the docs and the tests. Principles 3 and 4 are close enough in subject that they share one heading; the rest have one each.

The constraint they start from is that Asterarium has no server-side code: no program runs in response to a request, and all the delivery servers hand back are files finished at build time. Principles 1, 3 and 4 — where state lives, the direction of dependencies, the isolation of pages — follow directly from that constraint. Principles 2 and 5, the single owner and the writing down of incidents, gained their weight under it. Without a single owner, the rule about where a value lives is overwritten by whichever writer runs last and becomes a formality; without incidents written down, the copies deliberately kept apart for isolation drift out of step unnoticed. So without principles 2 and 5, principles 1, 3 and 4 do not hold up in practice.

Principle 1: Where state lives is decided by how often it changes

State lives in four places, and the axis of classification is not what a value means but how often it changes. Settings that change on user action go into a store; values that change go into module singletons; the one scene as it stands at the moment of each share goes into the URL query string; and settings to carry into the next visit go into localStorage. Which value goes where, and how each is handed across, is the subject of the section "State and the Frame Loop".

Why the line matters is best shown by what happens when it is crossed. Put a per-frame value in React state and every frame triggers a re-render. At 60 frames a second a frame has about 16.7 milliseconds to spend (a division by 60, not a measurement), and the judgement that a re-render inside each of them is heavy rests on general reasoning, not on a measurement. Put a rarely-changing setting in a singleton, on the other hand, and the UI has to invent its own way of hearing about changes.

So the clock singleton that holds simulation time lives outside React, and the UI reads it through a . No timer of its own sits behind it. The per-frame call that advances the time is itself what drives the notifications, and that call comes from useFrame, the hook that runs once a frame. A subscriber whose interval has not yet elapsed since its last notification is skipped for that frame.

useSyncExternalStore, React's own hook for subscribing to an outside store, is unusable here. It requires that reading an unchanged value hand back the very same object every time (the comparison is Object.is), and the clock returns a fresh object on every read, so the pair causes an endless re-render. In its place, the first value is read once in a useState initialiser and every later one arrives by calling setState from the subscription.

The principle is not "put everything in a singleton". On the interstellar flight, one of the , only position and attitude, that is, which way the ship points, live in the singleton; the selected star and the loading progress stay in React state. Low-frequency values belong in React, and that half of the line is as much a part of the rule as the other.

Principle 2: One owner, and a protocol for asking

For any value, exactly one actor may write it, and everyone else goes through the API that owner publishes. Camera orientation is the clearest case. Drags and wheel gestures are handled by the owner itself, and four further purposes ask for the camera to move: aiming from a search result or a share link, the auto-pan of , the device orientation sensor, and playback. If those four wrote the angles directly they would overwrite one another within the same frame, and which one won would depend on the accident of execution order among React components.

The answer is to make the component that actually moves the camera its sole owner, and to let the four outsiders do nothing but call the entry points that owner publishes. Those requests have two properties.

  • A request is a request, not a command. A smooth move started by the request that swings the view to a given direction (cameraBus.slewTo) is cancelled the instant the user drags, scrolls or presses a key, and sensor input is ignored while a drag is in progress. However many automatic movers are added, direct user input always outranks them.
  • Handover of ownership is explicit. Simulation time is normally owned by the clock singleton, but during a tour transition that fast-forwards time the playback engine drives it. The engine drives the clock by interpolation, deriving the current time from nothing but the start time, the end time and the fraction elapsed, so a dropped frame or a jittery wall clock still replays the same scene the same way. That situation is marked by a flag named driven, and while it is set the per-frame call that would advance time (clock.advance) does nothing at all. Rather than leaving it to implicit timing, the conflict is made into state and given a name.

The same shape appears in the section "Coordinate Frames and the Orientation of the Sky": the that orients the is assembled in one place each frame, and all 38,000-odd stars turn by that single matrix. With one writer, nobody ever has to ask who wrote a value last.

Principle 3 and principle 4: One-way dependencies, and duplication chosen for isolation

Both of these are rules about which code may know about which. Principle 3 fixes the direction between layers; principle 4 accepts duplication in order to hold a boundary between pages. Principle 3 first, the one-way dependency. The sits upstream, the scene layer and the UI layer downstream import it, and nothing points back the other way. Because the direction is fixed, upstream code can be tested without launching a browser. The testing structure described in the section "Verification", where that pure layer runs under a plain Node environment, only works because of it. The shared navigation, which every page includes and which imports no 3D code at all, is the same rule seen from another angle.

Principle 4 is the decision to accept duplication when duplication is what keeps the isolation. As described in the section "Delivery and the Standalone Pages", scene, that is, the night sky on the front page, and the eight standalone 3D pages share none of their 3D scene code with each other. The page chrome, that is, the navigation and other parts every page carries, is the exception. Several implementations therefore exist in near-identical copies, and the reason for not sharing differs from case to case, as the three below show.

  • The singleton behind a throttled subscription is written separately for the sky clock and for each standalone page. Here the reason is the bundle boundary, and which page loads which bundle is settled at build time. The comment atop the singleton holding the interstellar flight state says so outright: the shape of the sky clock is copied, not imported, because the sky store must never enter that bundle. One import line would drag the main store, and everything hanging off it, into what that page ships.
  • URL encoding and decoding exists once in the module that reads and writes the main share link, and once per standalone page. This time the reason is not the bundle but the parameters: each page carries different ones. The has its own clock and its own closed list of bodies to focus on, nothing the main sky could share a shape with. What they do share is only the idiom: clamp values into range, ignore garbage instead of throwing, omit anything left at its default.
  • For the deep field, a standalone 3D page that flies among galaxies photographed by JWST, the projection that turns a and on the celestial sphere into a position on the flat image, together with the binary-format constants, is implemented separately on the runtime side and in the data-generation script. Two reasons here. The script reaches for Node-only facilities to read files and fetch source data, which the pure computation layer on the runtime side is not allowed to use. And a shared implementation would put the same mistake on both sides, where comparing them would still agree, whereas two independent ones surface a mistake as a disagreement. What this cannot catch is a mistake common to both, which is what happens when one person writes both from the same formula.

Accepting duplication means accepting the risk that two copies drift apart. That risk is managed by checking rather than by unifying the two implementations. For the duplicated projection, a test drives both with 1,000 pairs of right ascension and declination generated from a fixed seed and asks that the two results differ by less than 1e-12 , a trillionth of an arcsecond. That bound comes out of double-precision rounding. One rounding step in double precision is about 2.2e-16 times the value itself. What the test compares is not a sky coordinate but an offset from the field centre, and it keeps every point within 0.3° — about 1,000 arcseconds — of that centre, which puts a step at roughly 2e-13 arcseconds, so 1e-12 arcseconds is five of them: a few rounding steps wide, which is what showing the same arithmetic looks like. The number of pairs is simply a figure that keeps the test instantaneous. For the bundle boundaries, a script reads the JavaScript in the finished and looks, byte by byte, for each page's marker strings inside code that other pages load. How the markers are chosen, and how the check is kept from quietly passing on nothing once a marker is renamed, is described in the section "Delivery and the Standalone Pages".

Principle 5: Burn incidents into the code, the docs and the tests

The last principle is not about how code is structured but about what is written down in it. The camera discussed in the section "Depth Rules and the Bugs Behind Them" is the model case: a single number, near, carries five lines of comment. What is recorded is not only the right value but the wrong one, the symptom, the cause, and where the full account lives. Its opening, the part that gives the reason for the value, reads:

// near=2 (NOT 0.1): every depth-writing far body (Moon/Sun/planets at
// 2e6) must keep near/z ≥ 1e-6 so its 24-bit MSAA depth never
// quantizes to 1.0
Camera settings at the entry component of the scene (excerpt)

In one sentence: the sky is a that runs after the scene is drawn, and it repaints only those whose depth is still at the farthest value. The Moon is placed about 2,000 km from the camera. If it sat any closer than the top of the atmosphere, fixed at 60 km above the surface, almost no scattered light would be applied to it, and it would turn into a black disc against the daytime sky. Right at the top the scattering has not yet flattened out, so the body is pushed to more than thirty times that height. The real Moon is about 384,000 km away, far outside the — the outer bound of what is drawn — so that distance is unusable; 2,000 km is a drawing distance that matches the apparent direction only. With the near plane left at 0.1, the depth of the Moon at that distance rounds until it can no longer be told apart from the farthest value, so the lunar surface is repainted as sky and flickers. Raising the near plane to 2 opens the gap and the symptom goes. That black-disc bug, the size of the rounding step, and the distances the stars and the Sun, Moon and planets are each placed at, are set out in the section "Depth Rules and the Bugs Behind Them".

There are three places to put it, and they play different parts. Two of them record. A comment in the code puts the history where anyone about to change the setting will see it. The design docs note, for instance, that the cut-off for discarding pixels that contribute almost nothing ranges from 0.0015 to 0.01 across objects. The quantity tested against it is usually , and for the Milky Way luminance times the ; the spread is recorded as the result of measured tuning against holes and flicker, which stops a later reader from tidying the values into one. The remaining place, an automated test that drives a real browser, does not record but prevents: it fails, and so blocks the change, when the symptom returns. Comments and docs can explain a regression; they cannot stop one.

From a constraint to principles, from principles to decisions

All five principles lead back to the constraint stated at the top of this page: no server-side code. What makes it concrete is one line of Next.js configuration, output: 'export', after which the build does nothing but emit finished static files into out/. Why that choice was made is the subject of the section "The Big Picture: No Server-Side Code". From there the chain runs as follows, keeping the split made at the top: where state lives, one-way dependencies and the isolation of pages follow directly from the constraint, while the single owner and the writing down of incidents are the two that gained weight under it.

  • First, placing state by how often it changes: even with something running on a server, a round trip per frame would never arrive in time. Nor is there a route for precomputing the answers, since the combinations of place, time and viewpoint are endless and could never be shipped as static files. Every value that moves while the viewpoint or the time does therefore gathers inside the browser. Avoiding React re-renders therefore came first, which moved per-frame values outside React, and that widened into the discipline of placing state by how often it changes: a principle that follows directly from the constraint.
  • Second, the single owner and its request protocol: once the per-frame writer sits outside React, the framework no longer decides who may write a value. Ownership has to be assigned by hand, which is what gave that protocol its extra weight under the constraint.
  • Third, one-way dependencies: with no option of putting a layer on the server, every seam between computation and rendering has to be drawn inside the browser, and the direction between layers is then the only thing that still lets the computation be lifted out and checked on its own, so this rule too follows directly from the constraint.
  • Fourth, duplication chosen for isolation: what ships is a prebuilt set of static files, and which code loads on which page is decided at build time by the configuration of the bundler alone. Nothing else guarantees that pages stay independent, so the constraint produced both the acceptance of duplication and the byte-level check over the built output directly.
  • Fifth, writing incidents into the code, the docs and the tests: running entirely in the browser means there are no server logs to read when something breaks. Understanding comes only from reproduction and reading code, and having to keep that understanding is what gave writing it into the code, the docs and the tests its extra weight under the constraint.

One constraint thus selects five principles, and those five set the centre of gravity for the individual decisions. That chain is what gives code as different as astronomy, and deployment scripts a consistent feel. Choosing to run no server-side code narrowed what was possible: no accounts, and no public gallery of shared sky tours, because both would need storage that lives on a server. In exchange, it kept the basis for every decision down to a single thread. That is the end of the article; the contents of all ten sections, and the glossary of astronomy and rendering terms, are on its front page (/about/tech/).