Anthony Ellis
← All projects

Procedural generation · WebGL

Procedural Galaxy Generator

A galaxy, the stars in it, the planets around those stars and the ground you land on, all of it worked out from a single seed while you're looking at it.

A spiral galaxy of 160,000 stars rendered as a glowing point cloud, with generator parameter panels on the left and a stellar census and faction list on the right.

Seed F80D-8FB6-2DB3: a four-arm spiral, 160,000 stars, nine factions. Anyone who types that code gets this galaxy.

Role
Sole developer
Stack
Vanilla JS · three.js r149 · GLSL
Scale
25 modules · ~252 kB source
Ships as
One self-contained HTML file
Dependencies at runtime
None. three.js is inlined

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:

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

  1. A navigation race. location.replace() fires hashchange on a later task, so a setTimeout(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.
  2. 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.
  3. 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.
  4. 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.
  5. 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