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 needEach value is an IndexEntry: the object itself, plus the two things you cannot recover from an id alone.
| Field | Type | Description |
|---|---|---|
kind | 'system' | 'star' | 'planet' | 'station' | 'gate' | 'field' | What this is. Discriminates the union, so a check narrows the type. |
obj | the object itself | Typed according to `kind`. |
systemId | string | Which 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
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 idA 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 systemnull means two different things
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 999Blockades, 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
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.
| Field | Type | Description |
|---|---|---|
indexGalaxy | one full walk | Every object, once. Call it once per galaxy and keep the result. |
indexSystems | one pass over systems | Cheaper. Still not free. |
systemOf(index, …) | two map lookups | Safe anywhere, including a render loop. |
systemOf(galaxy, …) | one full walk | Re-indexes on every call. Convenience only. |
lanesOf | one pass over lanes | Filters the lane array. |
neighboursOf | one pass over systems + lanes | Builds a system index internally on every call. |
stationsOf | one pass over one system | Cheap. Operates on a single system, not the galaxy. |
findRoute | Dijkstra, linear-scan queue | Fine to a few thousand systems. Not a per-frame call. |
connectedComponents | one breadth-first sweep | A 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.