starmap

Guides

Querying a galaxy

A galaxy is a tree of nested objects — excellent for saving to disk, awkward for the questions a game asks sixty times a second. These helpers build the lookups you need.

Why indexes exist

Suppose a player clicks a station and you hold its id. Finding it in the raw structure means walking every system, every planet, every station until it turns up. Doing that once is fine. Doing it per click is fine. Doing it per frame is not.

So the rule for this entire page is one line:

Build an index once, right after createGalaxy(), and keep it for the session.

A galaxy is frozen terrain — it never changes after it is built — so an index of it never goes stale. There is no invalidation to think about.

The global index

indexGalaxy() returns a ReadonlyMap from id to object, covering every kind at once: systems, stars, planets, stations, gates and asteroid fields.

import { indexGalaxy, indexSystems } from '@industrieh/starmap'

const index = indexGalaxy(galaxy)     // everything, keyed by id
const systems = indexSystems(galaxy)  // just systems — smaller, when that is all you need

Each value is an IndexEntry: the object itself, plus the two things you cannot recover from an id alone.

FieldTypeDescription
kind'system' | 'star' | 'planet' | 'station' | 'gate' | 'field'What this is. Discriminates the union, so a check narrows the type.
objthe object itselfTyped according to `kind`.
systemIdstringWhich system it belongs to — the context an id does not carry.

Narrowing an entry

IndexEntry is a discriminated union, so checking kind tells TypeScript what obj is. No cast, no assertion.

const entry = index.get('sol-p1-st0')

if (entry?.kind === 'station') {
  entry.obj.docks       // ✅ typed as Station
  entry.obj.planetId    // ✅ and its parent planet
}

// A switch is exhaustive, and TypeScript checks it for you.
switch (entry?.kind) {
  case 'planet':  return drawPlanet(entry.obj)   // obj is Planet
  case 'station': return drawStation(entry.obj)  // obj is Station
  case 'gate':    return drawGate(entry.obj)     // obj is Gate
}

This is why ids are globally unique

One id, one object, one map. If ids were only unique per system you would need to know the system before you could look anything up — which is exactly the information you are usually trying to find.

Finding the owner

Only a Station carries a pointer back to its parent. A planet, a gate or an asteroid field does not know where it sits. systemOf() walks the ownership the other way, for any kind of object:

import { systemOf } from '@industrieh/starmap'

systemOf(index, 'sol-p1')?.name      // 'Sol'   — from a planet
systemOf(index, 'sol-star')?.name    // 'Sol'   — from a star
systemOf(index, 'sol')?.name         // 'Sol'   — a system returns itself
systemOf(index, 'ghost')             // null    — no object carries that id

A system id returning that same system is what makes the helper safe to call on an id you have not inspected — you never need to branch on “is this already a system?”.

Pass the index, not the galaxy

systemOf() accepts either. Given a Galaxy it re-indexes the entire universe on every call; given a GalaxyIndex it is two map lookups. Both are correct; only one belongs in a loop.

Neighbours and lanes

Two helpers for the immediate surroundings of a system:

import { lanesOf, neighboursOf, stationsOf } from '@industrieh/starmap'

lanesOf(galaxy, 'b').map((l) => l.id)         // ['lane-a-b', 'lane-b-c']
neighboursOf(galaxy, 'b').map((s) => s.id)    // ['a', 'c']

// And, inside one system, every station across all its planets:
stationsOf(system).map((s) => s.name)         // ['High Anchorage']

There is no guarantee a system has any neighbours — you build the network, so an isolated system is a thing you can create. Check for it.

Pathfinding

findRoute() is Dijkstra over the hyperlane graph, weighted by length.

import { findRoute } from '@industrieh/starmap'

findRoute(galaxy, 'a', 'c')
// { systemIds: ['a', 'b', 'c'], distance: 200, jumps: 2 }

findRoute(galaxy, 'a', 'a')
// { systemIds: ['a'], distance: 0, jumps: 0 }   — trivially true, not null

findRoute(galaxy, 'a', 'island')   // null — no path; the network is split
findRoute(galaxy, 'a', 'ghost')    // null — no such system

null means two different things

Either the system does not exist, or nothing links the two. If the difference matters to your UI — “unknown destination” versus “unreachable” — check the index first, then route.

Length is a cost

Pathfinding reads lane.length and nothing else. It never looks at the positions again — which means the shortest route is not necessarily the one that looks shortest on screen. That is a feature: override length and you have a travel-cost model.

const galaxy = createGalaxy({
  systems: [
    { id: 'x', name: 'X', x: 0,   y: 0, star: { radius: 5 } },
    { id: 'y', name: 'Y', x: 10,  y: 0, star: { radius: 5 } },   // 10 units away
    { id: 'z', name: 'Z', x: 500, y: 0, star: { radius: 5 } },
  ],
  lanes: [
    { from: 'x', to: 'y', length: 999 },   // adjacent, but expensive: a blockade
    { from: 'x', to: 'z' },                // 500, derived
    { from: 'z', to: 'y' },                // 490, derived
  ],
})

findRoute(galaxy, 'x', 'y')
// { systemIds: ['x', 'z', 'y'], distance: 990, jumps: 2 }
//  ↑ the long way round, because 990 beats 999

Blockades, tolls, dangerous space, faster lanes — all of it is a number on an edge. You do not need a second graph.

The queue is naive on purpose

The priority queue is a linear scan, which is fine up to a few thousand systems. Past that, swap it for a binary heap — the graph is yours, and this function is forty lines.

Connectivity

This is the check people skip, and the one that costs an evening later. The library verifies that your references resolve; it does not decide whether your network makes sense. A system nobody can fly to is valid data.

import { connectedComponents } from '@industrieh/starmap'

const parts = connectedComponents(galaxy)
// [['a', 'b', 'c'], ['island']]
//   ↑ largest first. A healthy galaxy returns exactly one group.

if (parts.length > 1) {
  throw new Error(`Stranded systems: ${parts.slice(1).flat().join(', ')}`)
}

Run it once, where you build or load your universe. It is the single assertion that turns a whole class of “the quest is impossible” bug reports into a build failure.

What each call costs

Nothing here is expensive in absolute terms — but knowing which calls walk the universe tells you which ones belong outside a loop.

FieldTypeDescription
indexGalaxyone full walkEvery object, once. Call it once per galaxy and keep the result.
indexSystemsone pass over systemsCheaper. Still not free.
systemOf(index, …)two map lookupsSafe anywhere, including a render loop.
systemOf(galaxy, …)one full walkRe-indexes on every call. Convenience only.
lanesOfone pass over lanesFilters the lane array.
neighboursOfone pass over systems + lanesBuilds a system index internally on every call.
stationsOfone pass over one systemCheap. Operates on a single system, not the galaxy.
findRouteDijkstra, linear-scan queueFine to a few thousand systems. Not a per-frame call.
connectedComponentsone breadth-first sweepA build-time or load-time check, not a runtime one.

Next: Animating orbits — the one family of calls that is meant to run every frame.