starmap

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

A purity test walks every source file and rejects non-relative imports, browser globals, 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: x really is a finite number, name a non-empty string;
  • ids unique across the whole galaxy, hyperlanes included;
  • documented ranges: security and density in [0, 1], arcSweep in [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 meta match 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 call

Every 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.