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.
// 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
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
{ 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-p1encode a position; insert a planet and every later id shifts.id: 'earth'survives. - Bump nothing silently.
meta.formatVersionrecords 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:
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 listedThe 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