starmap

Guides

Data model

Every object, field by field: what you must provide, what the library computes, and what stays undefined until you say otherwise.

Two layers of types

Every object in this library exists twice, and telling the two apart removes most of the confusion people have at the start.

  • …Input — what you write. Forgiving: ids, counts and empty collections can all be left out.
  • The plain type — what you get back. Complete: every id is filled in, every collection exists (possibly empty), every derivable number is computed.
import type { PlanetInput, Planet } from '@industrieh/starmap'

// What you write — no id, no index, no stations array.
const input: PlanetInput = { name: 'Vesper', orbit: 150, radius: 6 }

// What you get back — all three are there.
const planet: Planet = {
  id: 'sol-p1', name: 'Vesper', index: 1, radius: 6,
  orbit: 150, angle: 0, period: 0, stations: [],
}

Reading the tables below

A field marked required must be in your input. A field marked derived is computed by createGalaxy() — writing it yourself is allowed but rarely useful. Anything else is optional and stays undefined if you omit it; the library never invents a value that carries meaning.

Ids

Ids must be unique across the whole galaxy — not per system, not per kind. Hyperlanes count too. That is what lets a single index hold everything, and what lets you store a bare string in a save file.

When you leave one out, it is built from the parent id and the array position:

star      <systemId>-star        // 'sol-star'
planet    <systemId>-p<rank>     // 'sol-p0', 'sol-p1'
station   <planetId>-st<rank>    // 'sol-p1-st0'
gate      <systemId>-gate<rank>  // 'sol-gate0'
field     <systemId>-ast<rank>   // 'sol-ast0'
lane      lane-<from>-<to>       // 'lane-sol-vega'

Give explicit ids to things you refer to by name

sol-p1 is a position, not a name. Insert a planet before it and everything shifts — breaking every save that stored the old id. If your game refers to Earth, write id: 'earth'.

System

The unit of place. Everything else lives inside one. In a system's local coordinates the star sits at the origin, so a planet's orbit is its distance from (0, 0); x and y on the system itself place it in the galaxy.

FieldTypeDescription
idrequiredstringUnique across the whole galaxy.
namerequiredstringNon-empty.
x, yrequirednumberPosition in the galaxy, in world units.
starrequiredStarInputEvery system has exactly one.
faction{ id, name }Who holds the system. Free-form — the library imposes no vocabulary.
securitynumber0 (lawless) to 1 (safe). Rejected outside that range.
planetsPlanetInput[]default []In orbital order — the array position becomes planet.index.
gatesGateInput[]default []Jump points.
asteroidFieldsAsteroidFieldInput[]default []Belts and clusters.
localRoutesLocalRouteInput[]default []Links between two objects of this system.
outerLimitderivednumberThe system's edge. Defaults to how far its own contents reach — with no margin. Set it yourself if you want empty space past the outermost object.

Star

FieldTypeDescription
radiusrequirednumberIn world units. Must be strictly positive.
idstringdefault <systemId>-starRarely worth setting.
namestringdefault the system nameSol the system, Sol the star.
classStarClassSpectral class: O B A F G K M, or any other string.

Planet

FieldTypeDescription
namerequiredstringNon-empty.
orbitrequirednumberDistance from the centre of the star. Strictly positive.
radiusrequirednumberThe planet itself. Strictly positive.
anglenumberdefault 0Where it sits at t = 0, in degrees.
periodnumberdefault 0Seconds for a full orbit. 0 means it never moves.
indexderivednumberIts rank in the array. Validation rejects a mismatch.
typePlanetTypeterran, gas, frozen… or any other string.
moonsnumberA count. The library never models moons as objects.
habitablebooleanYours to interpret.
resourcesstringFree-form. Never read.
stationsStationInput[]default []Orbiting it.

Station

A station orbits a planet, not the star — so its orbitis measured from the planet's centre, and it is carried along as the planet travels.

FieldTypeDescription
namerequiredstringNon-empty.
orbitrequirednumberDistance from the centre of its planet. Strictly positive.
anglenumberdefault 0Degrees, at t = 0.
periodnumberdefault 0Seconds per orbit around its planet.
planetIdderivedstringAlways filled in. The only back-pointer in the model.
typeStationTypetrade, mining, shipyard… or any other string.
docksnumberFree-form. Never read.

Gate

A gate is an object inside a system — something a ship flies to. The hyperlane is the galaxy-level edge that pathfinding walks. Modelling both lets a system have three gates on one lane, or a lane with no gate at all.

FieldTypeDescription
namerequiredstringNon-empty.
orbitrequirednumberDistance from the star. Strictly positive.
anglenumberdefault 0Degrees. Gates never move.
linksstring[]default []System ids this gate reaches. Every one must exist, and none may be its own system. An empty list is a dead gate — allowed on purpose.
statusGateStatusdefault 'nominal'nominal or unstable, or any other string.

Asteroid field

Discriminated by kind. A belt is an arc of a ring around the star; a cluster is a pocket anywhere in the system.

// A belt: an arc between two orbits.
{ kind: 'belt', name: 'Inner Belt', orbit: 220, width: 30, arcStart: 0, arcSweep: 360 }

// A cluster: a pocket at a fixed spot.
{ kind: 'cluster', name: 'The Shoals', x: -180, y: 90, radius: 40 }
FieldTypeDescription
namerequiredstringNon-empty.
kindrequired'belt' | 'cluster'Decides which of the two shapes below applies.
orbit, widthrequirednumberBelt only. Ring radius and radial thickness.
arcStart, arcSweeprequirednumberBelt only, in degrees. arcSweep: 360 is a full ring; it is rejected past 360.
x, y, radiusrequirednumberCluster only. Centre and reach, in system coordinates.
rocks[x, y, r][]default []Pre-placed asteroids. The library never generates any. A tuple rather than an object, because a field can hold hundreds and {"x":1,"y":2,"r":3} is four times the JSON of [1,2,3].
densitynumber0 to 1. A hint for your own logic — yield, danger. Never read.
richnessRichnessFree-form label.

Galaxy

The root. Three keys, and only the first is required of you.

FieldTypeDescription
systemsrequiredSystemInput[]At least the array.
lanesLaneInput[]default []The inter-system network. You decide what connects to what.
seedstringFree-form, copied to meta.seed. Put your generator's seed here so you can rebuild this universe later.
generatedAtstringFree-form timestamp, copied to meta. Never read.

Hyperlane

Undirected: from and to are interchangeable, and declaring the same pair twice is an error. A lane from a system to itself is rejected.

FieldTypeDescription
from, torequiredstringSystem ids. Both must exist.
lengthderivednumberStraight-line distance between the two systems. Override it to make a short lane cost more — findRoute() weighs by this number, not by geometry.
idstringdefault lane-<from>-<to>

length is a cost, not just a distance

Because pathfinding reads length and nothing else, it doubles as a travel-cost knob. A dangerous shortcut can be given a large length so routing avoids it unless it is the only way through.

Meta

Counters and a couple of labels, all computed. Validation re-derives them and rejects a galaxy whose meta disagrees with its contents — which is how a hand-edited save file gets caught.

galaxy.meta
// {
//   seed: 'quickstart',
//   formatVersion: 2,
//   systemCount: 2, laneCount: 1,
//   planetCount: 2, stationCount: 1,
//   gateCount: 1, asteroidFieldCount: 0,
//   galaxyRadius: 449.44,   // the disc enclosing every system
// }

formatVersion is your save-compatibility check

It records the shape of the data at the time the galaxy was built. Compare it to the current FORMAT_VERSION when loading a save and you will know the contract moved on before you hit a missing field.

Local route

A link between two objects inside the same system — a docking approach, a mining run, a shortcut. Both ends are { kind, id } references, and validation checks three things: the id exists, the kind matches, and both ends belong to this system.

localRoutes: [
  {
    from: { kind: 'planet', id: 'sol-p1' },
    to: { kind: 'station', id: 'sol-p1-st0' },
    type: 'docking',
  },
]

Open unions

PlanetType, StarClass, StationType, FactionId, Richness, GateStatus and RouteType are open: the listed values appear in autocomplete, and any other string is still accepted.

type PlanetType = 'lava' | 'rocky' | 'terran' | /* … */ | (string & {})

{ type: 'terran' }        // suggested
{ type: 'shattered' }     // yours, and just as valid

The (string & {}) is what stops TypeScript from collapsing the union down to plain string and losing the suggestions. The library never reads these values — they exist for you to map to appearance or behaviour.

What is deliberately missing

Things you might expect, and the reason they are not here:

  • Colours, labels, sprites. Rendering is not this library's job. Map planet.type to an appearance in your own code.
  • Moons as objects. Only a moons count. Modelling them would double the depth of the tree for something most games draw as a detail.
  • Elliptical orbits. Circles only. Ellipses need a focus, an eccentricity and an argument of periapsis to mean anything, and almost no game needs them.
  • Game state. No ownership, no stock, no visited flag. That is the frozen-terrain rule, and it is the one decision the whole library leans on.
If a field would mean different things in two different games, it does not belong in the model.