starmap

Under the hood

Architecture

How the source is laid out, and where a change belongs. Written for whoever opens the repository next — including you, in six months.

The map

Seven files, about 1,700 lines including the comments. Small enough to read in an afternoon, which is deliberate.

src/
  index.ts          the public surface exports, and nothing else
  core/
    types.ts        the data contract: what you get back
    input.ts        the input schema: what you pass in
    create.ts       normalization the entry point
    validate.ts     checking, and the error type
    query.ts        indexes, neighbours, connectivity, routes
    geometry.ts     orbital positions
    rng.ts          deterministic randomness

Which module knows what

The dependency graph is a line, not a web. Nothing in core/ imports from a sibling that imports it back.

FieldTypeDescription
types.tsimports nothingPure declarations. The bottom of the stack.
input.tstypes.tsReuses the open unions, so the two layers cannot drift.
geometry.tstypes.tsPure functions over positions. No knowledge of building or checking.
rng.tsimports nothingEntirely standalone. Usable without touching a galaxy at all.
validate.tstypes.tsReads a finished galaxy. Never builds one.
create.tsinput, types, geometry, validateThe only module that composes the others.
query.tstypes.tsReads a finished galaxy. Never builds one either.

Why geometry has no idea validation exists

Positions are needed by callers who never build a galaxy — a renderer given a system, a worker given one system's worth of data. Keeping the dependency one-directional is what lets a bundler drop the parts you do not import.

Normalization

create.ts is a series of small normalizeX() functions, one per object kind, each taking the input shape and returning the output shape. They all obey one rule:

function normalizePlanet(input: PlanetInput, systemId: string, index: number): Planet {
  const id = input.id ?? `${systemId}-p${index}`
  return {
    id,
    name: input.name,
    index,                          // derived: its rank in the array
    type: input.type,               // yours: passed through untouched
    radius: input.radius,
    orbit: input.orbit,
    angle: input.angle ?? 0,        // a default that means "does not move"
    period: input.period ?? 0,
    stations: (input.stations ?? []).map((s, i) => normalizeStation(s, id, i)),
  }
}

Three specific decisions are worth knowing about:

  • outerLimit has no margin. It is exactly how far the outermost object reaches. A margin would be an arbitrary number, and an arbitrary number in a library is a number every user has to work around.
  • Derived ids can collide. Nothing renames them silently — validation reports the duplicate and createGalaxy() throws. A library that quietly renamed your objects would be worse.
  • Rounding happens at two places only — lane lengths and outerLimit — because both end up in JSON. Nothing is rounded mid-computation.

Validation, in four passes

validate.ts runs in a fixed order, and the order is what makes cross-references checkable.

FieldTypeDescription
1. Indexid → { kind, systemId }Claim every id, reporting duplicates as it goes. Later passes resolve references against this map.
2. Systemsper-system checksTypes, ranges, and the references inside each system — local routes, gate links.
3. Lanesthe networkEndpoints exist and are systems, no self-links, no duplicate pairs, non-negative lengths.
4. MetacountersRe-derive every count and compare. This is what catches a hand-edited save.

Hyperlane ids are not in the index

They count towards global uniqueness, but they are not referenceable objects— otherwise a gate could claim to “reach” a hyperlane, and a local route could point at one. They live in a separate set for the uniqueness check only.

Invariants

Things that hold for every Galaxy the library returns. They are worth knowing because your code may rely on them:

  • every id is unique across the whole galaxy, hyperlanes included;
  • every collection exists — planets, gates, stations, asteroidFields, localRoutes, rocks, links are arrays, never undefined;
  • planet.index equals its position in the array;
  • station.planetId points at the planet that contains it;
  • every id in gate.linksis an existing system, and never the gate's own;
  • both ends of a localRoute belong to the system that declares it;
  • the counters in meta match the contents.

This is why the non-null assertion is safe

at.get(planet.id)! in a render loop cannot be undefined: systemPositions() emits an entry for every object of the system, and the invariants guarantee the id is there.

Adding something

A new field on an existing object touches four places, in this order:

1. input.ts     add it to the …Input interface, optional
2. types.ts     add it to the output interface
3. create.ts    pass it through in the matching normalize…()
4. validate.ts  check its type and range, if it has one

A whole new object kind additionally needs:

  • an entry in ObjectKind and in the IndexEntry union;
  • indexing in indexGalaxy(), so it is reachable by id;
  • a position in systemPositions(), if it has one;
  • a counter in meta, and its check in the fourth validation pass.

Two rules the tooling enforces, and that a change must respect: every exported declaration carries an explicit type (so the .d.ts can be emitted file by file), and no source file may import anything non-relative or touch a browser global. See Design decisions for why.