starmap

Get started

Quickstart

In fifteen minutes you will describe a two-system universe, let the library complete and check it, then query it, animate it, and prove it holds together. Every output on this page is real — it came from running the code.

The mental model

Before any code, one idea. It explains every decision in the API, and holding it makes the rest obvious:

You describe the terrain. The library completes it, checks it, and helps you read it. It never invents your world, and it never draws it.

Concretely, that means three things:

  • You write an object, in TypeScript, by hand or from a script. The library has no generator — a universe is a design decision, not a random draw.
  • You get frozen terrain back. A Galaxy is what the world is, not what is happening in it. Your ship's position, a station's stock, who owns what — that is game state, and it lives in your own object, keyed by the same ids.
  • Nothing here is about pixels. No colours, no labels, no shapes. The library gives you positions; how they become an image is entirely yours.

Why this matters right away

If you catch yourself wanting to store selected: true on a planet, that is the signal you have crossed the line. Keep a Set of selected ids next to the galaxy instead — it will save you every time you serialize.

Build your first universe

Describe one system

A system needs five things: an id, a name, its position (x, y) in the galaxy, and a star with a radius. Everything else is optional.

universe.ts
import { createGalaxy, type GalaxyInput } from '@industrieh/starmap'

const input: GalaxyInput = {
  seed: 'quickstart',
  systems: [
    {
      id: 'sol',
      name: 'Sol',
      x: 0,
      y: 0,
      star: { class: 'G', radius: 18 },
    },
  ],
}

Coordinates are in world units — whatever you decide they mean. The library never converts them, so pick a scale that suits your game and stay consistent.

Give it planets, and a station

Planets go in orbital order: the array position becomes planet.index. A planet needs a name, an orbit (distance from the star) and a radius. Stations orbit a planet, so they nest inside it.

planets: [
  { name: 'Aurelia', type: 'rocky', orbit: 90, radius: 5, period: 12 },
  {
    name: 'Vesper',
    type: 'terran',
    orbit: 150,
    radius: 6,
    angle: 30,        // where it sits at t = 0, in degrees
    period: 24,       // seconds for a full orbit
    habitable: true,
    stations: [
      { name: 'High Anchorage', type: 'trade', orbit: 14, period: 4, docks: 8 },
    ],
  },
],

orbit is always measured from the parent

A planet's orbit is its distance from the star. A station's orbit is its distance from its planet — which is why orbit: 14 is a sensible number here and would be absurd for a planet.

Add a second system and connect them

A gate is the in-system object a ship flies to. A hyperlane is the galaxy-level edge that pathfinding walks. They are two different things, and you usually want both: the gate is what the player sees, the lane is what findRoute() reads.

// …inside Sol
gates: [{ name: 'Sol Gate', orbit: 300, angle: 45, links: ['vega'] }],

// …a second system, and the lane between them
{ id: 'vega', name: 'Vega', x: 420, y: 160, star: { class: 'A', radius: 22 } },

// …at the root of the input
lanes: [{ from: 'sol', to: 'vega' }],

Build it

createGalaxy() takes your description, fills in what it can compute, checks the result, and hands back a Galaxy.

const galaxy = createGalaxy(input)

If anything is inconsistent it throws — and it reports every problem it found, not just the first. More on that below.

What the library filled in

You never wrote an id for the star, the planets, the station, the gate or the lane. They are all there, derived from the parent id and the array position:

galaxy.systems[0].star.id                        // 'sol-star'
galaxy.systems[0].planets[0].id                  // 'sol-p0'
galaxy.systems[0].planets[1].id                  // 'sol-p1'
galaxy.systems[0].planets[1].stations[0].id      // 'sol-p1-st0'
galaxy.systems[0].gates[0].id                    // 'sol-gate0'
galaxy.lanes[0].id                               // 'lane-sol-vega'

So are the values that follow from what you gave it:

galaxy.lanes[0].length          // 449.44 — the distance between (0,0) and (420,160)
galaxy.systems[0].outerLimit    // 300    — the reach of the outermost object, the gate
galaxy.meta.planetCount         // 2
galaxy.meta.stationCount        // 1
galaxy.meta.galaxyRadius        // 449.44
galaxy.meta.formatVersion       // 2

// And the defaults, for what you left out:
galaxy.systems[0].planets[0].angle   // 0 — "starts where the axis points"
galaxy.systems[1].star.name          // 'Vega' — falls back to the system name

One rule governs all of it, and it is worth committing to memory because it predicts the entire API:

Compute what is derivable. Never invent a value that carries meaning.

An id, a counter, a distance, a system's edge — all of those follow from what you wrote, so the library fills them in. A planet's type, a system's faction, a field's richness do not follow from anything, so they stay undefined. The library will never quietly decide your world is “rocky”.

Derived ids can collide

The pattern is <parent>-p<rank>. If you give one planet the explicit id sol-p1 and let another land on that name by position, validation catches it and throws. Give explicit ids to the objects your game refers to by name — they read better in save files anyway.

Ask it questions

A Galaxy is a tree, which is excellent for serializing and awkward for lookups. The query helpers build the indexes a game needs constantly.

Build the index once and keep it. Every call walks the whole universe, so doing it per frame is the one performance mistake that is easy to make here.

import { indexGalaxy, findRoute, systemOf, stationsOf } from '@industrieh/starmap'

const index = indexGalaxy(galaxy)   // built once, kept around
index.size                          // 8 — every object, all kinds mixed

// Each entry carries its kind, so a check narrows the type with no cast.
const entry = index.get('sol-p1-st0')
if (entry?.kind === 'station') {
  entry.obj.docks       // 8 — typed as Station here
  entry.systemId        // 'sol'
}

// Shortest path, weighted by lane length.
findRoute(galaxy, 'sol', 'vega')
// { systemIds: ['sol', 'vega'], distance: 449.44, jumps: 1 }

// Which system does this object belong to? Works for any id.
systemOf(galaxy, 'sol-p1')?.name    // 'Sol'
systemOf(index, 'sol-p1')?.name     // same answer, no rescan — prefer this

// Every station in a system, across all its planets.
stationsOf(galaxy.systems[0]).map((s) => s.name)   // ['High Anchorage']

systemOf takes the index too

Passing the Galaxy re-indexes the whole universe on every call. Passing the GalaxyIndex is a map lookup. In a render loop, always pass the index.

Make it move

Orbits are computed, never stored. You give a time t in seconds and get back where everything is at that instant.

import { systemPositions } from '@industrieh/starmap'

const at = systemPositions(galaxy.systems[0], 6)   // t = 6 seconds
at.get('sol-p0')        // { x: -90, y: 0 }  — Aurelia, half way round its 12s orbit
at.get('sol-p1-st0')    // { x: -89, y: 129.9 } — the station, carried by its planet

Notice the station. Its position is relative to its planet, so it follows Vesper around while turning on its own four-second orbit. You never compute that yourself.

Which makes a render loop about as simple as it can be:

let t = 0

function frame(dt: number) {
  t += dt
  const at = systemPositions(system, t)

  for (const planet of system.planets) {
    const { x, y } = at.get(planet.id)!
    drawPlanet(x, y, planet.radius)   // your renderer, your rules
  }

  requestAnimationFrame(() => frame(1 / 60))
}

Nothing to cache

One call costs a single pass over the objects of one system — a few dozen. Call it every frame and move on; memoising it would cost more than it saves.

A period of 0means “never moves”, which is the default. A static system is not a special case — it is just a universe where nothing has a period.

The safety net

createGalaxy() validates what it built, so a structurally broken Galaxy is not a state you can reach through the front door. Try a dangling reference:

createGalaxy({
  systems: [{ id: 'sol', name: 'Sol', x: 0, y: 0, star: { radius: 18 } }],
  lanes: [{ from: 'sol', to: 'nowhere' }],
})

// GalaxyValidationError: Invalid universe (1 problem(s)):
//   • lanes[0].to: unknown system: "nowhere"

The error carries issues, an array of { path, message } — every problem, located precisely. That is the point: fixing five mistakes should take one run, not five.

TypeScript's guarantees do not survive JSON.parse, so there is a non-throwing version for data coming back from disk or from a server:

import { validateGalaxy, type Galaxy } from '@industrieh/starmap'

const galaxy = JSON.parse(saveFile) as Galaxy
const issues = validateGalaxy(galaxy)      // never throws, whatever you pass it

if (issues.length) {
  console.error('This save is not loadable:', issues)
}

One last check, and it is the one people forget. 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 and a broken game:

import { connectedComponents } from '@industrieh/starmap'

const parts = connectedComponents(galaxy)  // [['sol', 'vega']] — one group, healthy

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

Run this once, at build time

Connectivity is exactly the kind of bug that survives every test and shows up in a playtest. Assert it where you generate or load your universe, not in the render loop.

What you just learned

That is the whole library, in one sitting. You now know how to:

  • describe a universe as a typed object, and let createGalaxy() complete and check it;
  • tell what is derived from what is yours to decide;
  • build an index once and use it to look anything up by id, in any direction;
  • turn a time value into positions, with stations carried by their planets;
  • catch broken data at the door, and stranded systems before a player finds them.

Where to go next, depending on what you are doing:

  • Data model — every field of every object, what is required, and what gets filled in.
  • Querying a galaxy — the index, routes and neighbours in depth, with their costs.
  • Deterministic randomness — if you want to generate universes from a seed rather than write them by hand.
  • Recipes — save files, game state, colours, scaling up.