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 randomnessWhich module knows what
The dependency graph is a line, not a web. Nothing in core/ imports from a sibling that imports it back.
| Field | Type | Description |
|---|---|---|
types.ts | imports nothing | Pure declarations. The bottom of the stack. |
input.ts | types.ts | Reuses the open unions, so the two layers cannot drift. |
geometry.ts | types.ts | Pure functions over positions. No knowledge of building or checking. |
rng.ts | imports nothing | Entirely standalone. Usable without touching a galaxy at all. |
validate.ts | types.ts | Reads a finished galaxy. Never builds one. |
create.ts | input, types, geometry, validate | The only module that composes the others. |
query.ts | types.ts | Reads a finished galaxy. Never builds one either. |
Why geometry has no idea validation exists
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:
outerLimithas 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.
| Field | Type | Description |
|---|---|---|
1. Index | id → { kind, systemId } | Claim every id, reporting duplicates as it goes. Later passes resolve references against this map. |
2. Systems | per-system checks | Types, ranges, and the references inside each system — local routes, gate links. |
3. Lanes | the network | Endpoints exist and are systems, no self-links, no duplicate pairs, non-negative lengths. |
4. Meta | counters | Re-derive every count and compare. This is what catches a hand-edited save. |
Hyperlane ids are not in the index
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,linksare arrays, neverundefined; planet.indexequals its position in the array;station.planetIdpoints at the planet that contains it;- every id in
gate.linksis an existing system, and never the gate's own; - both ends of a
localRoutebelong to the system that declares it; - the counters in
metamatch 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 oneA whole new object kind additionally needs:
- an entry in
ObjectKindand in theIndexEntryunion; - 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.