Under the hood
Design decisions
Why the library draws its lines where it does — and, just as usefully, what it gives up in exchange.
The shape of it
The whole library is one pass with no state and no side effects:
GalaxyInput (your object)
│
▼
normalize ──────────► derived ids, empty collections,
index, planetId, lengths, outerLimit, meta
│
▼
validate ───────────► GalaxyIssue[] → GalaxyValidationError
│
▼
Galaxy ──────────► queries (indexes, neighbours, routes)
└────► geometry (positions at time t)createGalaxy() reads no clock, calls no Math.random(), touches no filesystem, and does not mutate the object you hand it. Call it twice on the same input and you get two equal galaxies. Everything downstream is a pure read.
It does not generate
Procedural generation is game design, not infrastructure. The shape of a galaxy, the faction table, how many planets a system gets — those are decisions about your game.
Building them into a library leaves two bad options:
- expose every knob, until configuring the generator is harder than writing the twenty lines it replaced;
- pick one universe for everybody, which is fine until your game needs a different one.
So createGalaxy() takes the finished description, and the script that builds it lives in your app. What the library keeps is the one genuinely reusable piece of a generator: a seeded PRNG.
It does not draw
No components, no hooks, no stylesheet — and, less obviously, no display fields in the data model. There is no color, no typeName, no drawable shape.
A colour in the model looks convenient and forces a UI decision at the moment you are describing your world. It also stops being usable the day the project needs a dark theme, a colourblind mode, or a fixed brand palette. The mapping is one line, written where the context to write it exists:
const COLORS: Record<string, string> = { terran: '#4fae6a', gas: '#d8a06a' }What stays in the model are facts about the world: geometry, topology, identity, and gameplay attributes like security, docks, habitable, resources, richness. Nothing that assumes how you look at them.
Two mechanisms enforce this, so it does not rely on review
Math.random, Date, and any trace of color, #rrggbb, svg, canvas or viewBox. And the TypeScript config drops DOM from lib, so a stray document. does not even compile.It checks, but does not judge
Validation covers structural and referential integrity — the things that are unambiguously broken:
- runtime types:
xreally is a finite number,namea non-empty string; - ids unique across the whole galaxy, hyperlanes included;
- documented ranges:
securityanddensityin[0, 1],arcSweepin[0, 360], radii and orbits strictly positive; - every reference by id resolves to an object that exists and has the right kind;
- the counters in
metamatch the real contents.
What is accepted
Everything else. Orbits that shrink as they go outward, planets that overlap, a gate inside its own star, a network split in two — all of it passes.
Those are design choices, and they are yours to make. A library that rejects a “wrong” universe is a library that decides what your game is about.
The one you most likely care about is connectivity — and because it is a judgement call, it is offered as a question rather than enforced as a rule:
const parts = connectedComponents(galaxy)
if (parts.length > 1) throw new Error('Stranded systems') // your rule, your callEvery error, not the first
GalaxyValidationError.issues holds all the problems found, each with the path of the offending value. Fixing five mistakes should take one run, not five.
// GalaxyValidationError: Invalid universe (3 problem(s)):
// • systems[0].planets[1].orbit: must be strictly positive
// • systems[2].id: duplicate id: "vega"
// • lanes[4].to: unknown system: "nowhere"Frozen terrain
A Galaxy describes what the world is, never what is happening in it. No ownership, no stock, no visited flag.
That single rule pays for itself repeatedly:
- an index never goes stale, because the thing it indexes never changes;
- a save file is small — your state, not a copy of the universe;
- a generated universe rebuilds from its seed, so you can ship the recipe instead of the result;
- nothing needs to be defensively cloned, because nothing writes.
The practical shape of this is in Keep game state alongside.
What this costs you
Every one of these lines buys something and costs something. Being honest about the second half:
- You write the generator. There is no
generateGalaxy(seed, 40)to call on day one. The Quickstart is longer than it would be otherwise. - You write the renderer, or reach for starmap-render. The library hands you numbers, not pixels.
- An invalid-but-accepted universe is on you. Overlapping orbits will not be caught. If your game cares, assert it yourself.
- Circles only. No ellipses, no inclination, no n-body anything. If you are writing an orbital-mechanics simulator, this is the wrong model.
If those trades sound wrong for what you are building, that is useful information early — which is the point of writing them down.