Why I built it
I like space, and I wanted something on this site that would look like something the moment it loaded. This is one of three browser demos I put together over about a month of evenings in mid-2026, which is why the write-up talks more about decisions than about a long development history. The decisions are still the interesting part.
Why nothing is saved
Most generators build a world once and then store it. This one stores nothing at all. Ask for a galaxy and it computes a galaxy; ask for the mountains on a particular patch of a particular planet and it computes those too, from the seed, right then. Everything is a function of a chain of seeds hanging off the twelve characters you typed.
Deciding that early is what made the rest of it work. Because the URL contains the seed and
the path you took, the URL is the save file: paste
#seed=7F8A-2C91-BD44&view=planet&star=118427&planet=2 to a friend and
they land where you were standing. Because anything thrown away can be rebuilt on demand, the
level-of-detail cache is free to be brutal about eviction. And because generation is
deterministic, a bug report is one string long. Someone sends me a seed, I see exactly what
they saw.
The chain runs root = hash(code) to galaxy to
sector:sx:sy to star i to planet j to
tile:lat:lon. Structural nodes get string labels because they're readable. The
250,000-star hot path gets allocation-free integer mixing because it isn't.
How it's laid out
Twenty-five modules, concatenated into one HTML file by a build script of about ninety lines. The boundary that matters is that the generation code never touches the DOM or three.js. That restriction looked arbitrary for the first week and then paid for itself, because it's the reason the same functions can run inside a Web Worker without modification.
src/core/ rng, noise, seed, perf determinism + instrumentation
src/gen/ galaxy, star, planet, system,
sector, factions, economy generation only. no DOM, no three.js
src/render/ stage, orbit, galaxyView, systemView,
planetView, planetTexture, surfaceView
one renderer, four swappable views
src/ui/ dom, hud, controls, inspector, devpanel
src/app.js state, navigation, URL hash
Decisions I had to think hardest about
The worker has no source file of its own
A separate worker script would have drifted from the main thread copy within a month; they
always do. So the worker bundle gets assembled at runtime out of the same function objects the
main thread is using, via Function.prototype.toString(). One implementation
running in two places. If the browser has no workers, it generates synchronously and says so
in the dev panel.
Stars that dim below one pixel
A point sprite can't be drawn smaller than a pixel, so naive distance attenuation has an embarrassing failure mode: a galaxy viewed from further away gets brighter, because every star in it has been rounded up to a full pixel. The fix is to clamp the size and push the area you lost into the intensity instead, which makes the total light behave.
Picking one star out of 250,000 in about 2 ms
No raycaster, no octree. The position buffer gets projected through the view-projection matrix in a flat loop over a typed array, and the closest hit in screen space wins. A spatial tree would be faster in theory and slower in practice, since it would need rebuilding every time the galaxy regenerates. A linear pass over contiguous memory doesn't, and 2 ms is under the threshold where anyone notices.
Sea level comes from a histogram
Elevation is noise, so picking 0.5 as sea level gives you a planet whose stated water coverage is a lie. Instead the generator drops elevation into a 256-bucket histogram and solves for the threshold that produces the coverage it promised. A world that claims 71% water has 71% of its surface underwater, which matters because the number is printed on screen next to the planet.
Planet classes fall out of physics
Equilibrium temperature gets computed properly,
T = 278.6 · L^¼ · a^-½ · (1 − albedo)^¼, with a greenhouse term on top. Class
then follows from temperature, mass and water. Rolling dice for the class would have been
quicker, but you end up with ocean worlds at 900 K and ice giants sitting on their star, and
the whole thing stops being believable.
Two levels of detail
Level zero is the entire galaxy as one point cloud: a single draw call, fourteen bytes a star. Level one is per-sector detail, streamed as you approach and dropped from an LRU cache when you leave. Dropping it is safe because the cache is never the only copy of anything.
What it costs
Chrome on an M-series laptop, 220,000 stars:
- Galaxy generation, about 120 ms, off the main thread in the worker
- Spatial index 4 ms, faction Voronoi 20 ms, sector detail 2 ms
- Star system build 0.3 ms, star pick 2 ms
- The planet texture bake, 512×256, takes a few hundred milliseconds behind a loading indicator. It's still on the main thread and it's the next thing I want to move
- One draw call for the galaxy, holding 60 fps
How I know it isn't quietly broken
npm run verify drives the shipped file through headless Chromium and runs
seventeen checks. The two that matter: the same seed has to produce a
bit-identical star buffer across runs while a different seed doesn't,
and the Web Worker path has to match the main-thread path byte for byte. It also
confirms all five morphologies generate and differ, reads pixels back off the canvas so
a silently black render fails instead of shipping, and checks that a URL hash restores
the seed, morphology and star count it encodes.
determinism pass same seed produces a bit-identical position buffer pass a different seed produces a different galaxy worker pass the worker actually started pass worker output matches the main thread bit for bit 17 passed, 0 failed
The determinism check isn't a nicety. It's the property the whole architecture rests on, since nothing is stored. If generation stopped being reproducible, the URL would quietly stop working as a save file and the cache could no longer evict safely. The suite is in the repo.
Five bugs worth knowing about
- A navigation race.
location.replace()fireshashchangeon a later task, so asetTimeout(0)guard loses the race and every navigation regenerates the entire galaxy. Fixed by comparing the written hash by value, because trying to out-time the event loop never works. - Frame-delta clamping ate the slow frames. Genuinely slow frames were being reset to 16 ms, so camera tweens never arrived at their targets on software renderers. Now anything over a second is treated as the tab coming back and ignored, and anything over 100 ms is capped.
- A cache that only filled by accident. After a regeneration the sector cache sat empty until the camera happened to cross a sector boundary. It re-streams explicitly now.
- A hole in the classifier. A temperate world with 45% surface water came out classed as rocky and rendered bone dry, because wet worlds with unbreathable air had nowhere to go. They have their own class now, and the comment explaining why is still next to it in the code.
- A scoring bug that flattered bad planets. Habitability was coming out at 67% for a dry world that happened to have breathable air. Water is a gate now, not a bonus.
Try this
Set the star count high, then pull the camera back and watch the brightness. That's the sub-pixel dimming doing its job. Then copy the URL, paste it into another browser, and you'll land on the same galaxy, because the address bar is the only thing that was ever saved.
What I'd do next
- Move the planet texture bake into the worker and get the last blocking call off the main thread.
- Generate the galaxy in chunks so a million stars streams in instead of arriving all at once.
- A screen-space bloom pass, and ring shadows on the gas giants.
- Move the shell to TypeScript and Vite, leaving
gen/as a package with no framework in it.