starmap

Guides

Recipes

Short answers to the questions that come up twice — the ones about fitting this library into a real game rather than about the API itself.

Keep game state alongside

This is the decision the whole library leans on, so it is worth stating plainly: a Galaxy is terrain, not a game. Nothing in it changes once it is built.

If a value would differ between two save files of the same universe, it is game state, and it does not belong in the model.

The pattern is a separate object, keyed by the same ids. The galaxy is the map; your state is what has happened on it.

state.ts
// The terrain — built once, never mutated.
const galaxy = createGalaxy(input)
const index = indexGalaxy(galaxy)

// Your game — keyed by the same ids.
interface GameState {
  shipSystemId: string
  visited: Set<string>
  owners: Map<string, string>          // systemId → factionId, as it changes hands
  stock: Map<string, Map<string, number>>  // stationId → goods
}

const state: GameState = {
  shipSystemId: 'sol',
  visited: new Set(['sol']),
  owners: new Map(),
  stock: new Map(),
}

// Reading the two together is a lookup, not a search.
const here = index.get(state.shipSystemId)
if (here?.kind === 'system') {
  console.log(`${here.obj.name} — ${state.visited.has(here.obj.id) ? 'known' : 'unexplored'}`)
}

Do not write to a Galaxy

Adding planet.owner or system.visited feels convenient for about a day. Then you serialize, and your save carries a copy of the entire universe — which you cannot migrate, cannot regenerate, and cannot diff.

Save and load

A Galaxy is plain JSON — no classes, no methods, no circular references. It round-trips through JSON.stringify without ceremony.

Coming back the other way, though, TypeScript guarantees nothing. A save file may have been hand-edited, truncated, or written by a version that no longer matches. That is what validateGalaxy() is for:

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

function loadGalaxy(json: string): Galaxy {
  const galaxy = JSON.parse(json) as Galaxy

  const issues = validateGalaxy(galaxy)     // never throws, whatever you pass it
  if (issues.length) {
    throw new Error(
      `This save is not loadable:\n` +
        issues.map((i) => `  • ${i.path}: ${i.message}`).join('\n'),
    )
  }

  if (galaxy.meta.formatVersion !== FORMAT_VERSION) {
    console.warn(`Save written for format ${galaxy.meta.formatVersion}, current is ${FORMAT_VERSION}`)
  }

  return galaxy
}

If you stored the seed, you may not need the file at all

A generated universe rebuilds from its seed. Saving { seed, t, state } instead of the whole galaxy is smaller, migrates better, and cannot go stale relative to your generator.

Change the world without breaking saves

Your universe will change — a new system, a renamed planet, a reworked lane network. Old saves hold ids from the old world. Three habits keep that from hurting:

  • Give explicit ids to anything you refer to. Derived ids like sol-p1 encode a position; insert a planet and every later id shifts. id: 'earth' survives.
  • Bump nothing silently. meta.formatVersion records the shape of the data at build time. Compare it on load, and you find out before a missing field does.
  • Treat unknown ids as data, not as crashes. A save referring to a system you removed is a normal event once a game ships.
// Reconcile a save against the current universe.
function reconcile(state: GameState, index: GalaxyIndex): GameState {
  const stillThere = (id: string) => index.has(id)

  return {
    ...state,
    // The ship's system may no longer exist — put it somewhere real.
    shipSystemId: stillThere(state.shipSystemId) ? state.shipSystemId : 'sol',
    visited: new Set([...state.visited].filter(stillThere)),
    owners: new Map([...state.owners].filter(([id]) => stillThere(id))),
  }
}

Attach colours and labels

The model carries no appearance on purpose — type, class and status are labels, and what they look like is a decision your game makes. Keep the mapping in one table:

appearance.ts
import type { PlanetType } from '@industrieh/starmap'

const PLANET_STYLE: Record<string, { fill: string; label: string }> = {
  terran:      { fill: '#4ade80', label: 'Terran' },
  ocean:       { fill: '#38bdf8', label: 'Ocean' },
  gas:         { fill: '#fbbf24', label: 'Gas giant' },
  frozen:      { fill: '#e0f2fe', label: 'Frozen' },
  rocky:       { fill: '#a8a29e', label: 'Rocky' },
}

const FALLBACK = { fill: '#6b7280', label: 'Unknown' }

// Types are open unions, so a value you invented is still valid — which is
// exactly why the lookup needs a fallback rather than an exhaustive switch.
export function styleOf(type: PlanetType | undefined) {
  return (type && PLANET_STYLE[type]) ?? FALLBACK
}

Why a fallback and not a switch

PlanetType accepts any string. An exhaustive switch would compile today and silently miss the type you add next month — the fallback is not defensive programming, it is the contract.

Load a universe from a file

Writing a universe by hand in TypeScript gets you autocomplete and typechecking. Writing it in JSON gets you a file a designer can edit without a build step. Both end at createGalaxy():

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

// The JSON is *input*, not a finished galaxy — so it can stay terse, and
// createGalaxy fills in the ids, the counts and the lane lengths.
const input = JSON.parse(await readFile('universe.json', 'utf8')) as GalaxyInput

const galaxy = createGalaxy(input)   // throws, with every problem listed

The distinction matters. A hand-written input file omits everything derivable and stays readable. A serialized galaxy is complete and verbose, and is what you save at runtime.

Scale up

Where the naive implementations stop being enough, and what to do about it:

  • Pathfinding past a few thousand systems. findRoute() uses a linear-scan priority queue. Build the adjacency list once and swap in a binary heap — the graph is yours, and the function is forty lines to reimplement.
  • Very large galaxies in memory.A galaxy is plain data, so it splits: keep the system positions and lanes loaded, and load each system's contents when the player arrives. Validation runs per galaxy, so validate the chunks as you build them.
  • Hot paths you have already checked. createGalaxy(input, { validate: false }) skips the pass. Only worth it if you are rebuilding constantly from input you produced yourself — the check is what stops a broken universe from existing.
  • Rendering thousands of objects.Not this library's problem, but worth saying: systemPositions() handles one system, and one system is a few dozen objects. It is not what will slow you down.

Turning validation off is a last resort

It is the one thing standing between a typo and a universe that fails three screens later, somewhere unrelated. Measure first; the check is a single pass.