starmap

Guides

Deterministic randomness

The library does not generate universes — but it ships the tool to write a generator yourself, and to derive game content that comes back identical tomorrow.

Why not Math.random()

Math.random() is not seedable. You cannot ask it to replay a sequence, which means you cannot rebuild a world from a seed, cannot reproduce a bug report, and cannot have a server and a client agree on what they generated.

So the library never calls it — not once, anywhere. Everything random goes through createRng(), and every draw is a pure function of a seed.

Store the seed, not the world. Six characters instead of six megabytes, and it rebuilds exactly.

The generator

createRng() takes any string and hands back six methods. Each instance carries its own state, so two generators made from the same seed produce the same sequence.

import { createRng } from '@industrieh/starmap'

const rng = createRng('my-game-001')

rng.next()                 // 0.721389…  float in [0, 1)
rng.rand(10, 20)           // float in [10, 20)
rng.randInt(1, 6)          // 4 — integer, BOTH bounds included
rng.pick(['a', 'b', 'c'])  // 'a'
rng.chance(0.5)            // true — true with probability p
rng.weighted([{ w: 9, id: 'common' }, { w: 1, id: 'rare' }])
// { w: 9, id: 'common' }  — picked in proportion to w
FieldTypeDescription
next()() => numberFloat in [0, 1). Every other method builds on this one.
rand(min, max)(number, number) => numberFloat in [min, max).
randInt(min, max)(number, number) => numberInteger in [min, max] — both bounds included, unlike rand. A dice roll is randInt(1, 6).
pick(arr)<T>(readonly T[]) => TA uniformly chosen element. The array must not be empty.
chance(p)(number) => booleanTrue with probability p. 0 never, 1 always.
weighted(table)<T extends { w: number }>(readonly T[]) => TAn element chosen in proportion to its w. Weights are relative, not probabilities — they need not sum to anything.

Weights are relative

[{ w: 9 }, { w: 1 }] and [{ w: 90 }, { w: 10 }] behave identically. Over ten thousand draws, the first table returned the rare entry 943 times — about the 10% you would expect.

Keep your sequences separate

This is the single most useful habit, and the one that is painful to retrofit. Give each mechanic its own generator, keyed by seed, subject and purpose:

const seed = 'world-7'

const loot       = createRng(`${seed}:sol:loot`)
const encounters = createRng(`${seed}:sol:encounters`)

loot.randInt(1, 100)         // 41
encounters.randInt(1, 100)   // 51 — a different sequence entirely

Now adding a mechanic — a new ${seed}:sol:weather stream — does not disturb the loot anyone has already seen. With a single shared generator, every insertion shifts everything downstream of it.

A stream per entity works too

${seed}:${systemId}:lootgives each system its own independent sequence, which means you can generate a system's contents lazily, when the player first arrives, and still get the same result as if you had generated everything up front.

Draw order is a contract

A seeded generator replays a sequence. The values it returns depend on how many times it has been called — so the order of your draws becomes part of what the seed means.

// Version 1
const name   = rng.pick(NAMES)
const radius = rng.randInt(10, 20)

// Version 2 — one innocent line inserted
const name   = rng.pick(NAMES)
const rarity = rng.pick(RARITIES)   // ← everything after this shifts
const radius = rng.randInt(10, 20)  //    now a different number

Same seed, different universe. That is not a bug in the generator; it is what a sequence is. Two ways to live with it:

  • Snapshot-test your generator. One test that builds a galaxy from a fixed seed and compares it to a stored result. The day a draw moves, the diff tells you — and you decide whether it was intended.
  • Use separate streams (above), so an addition is confined to its own sequence instead of shifting all of them.

Append, do not insert

When you must extend an existing stream, add your draws at the end. Everything already drawn keeps its value, and old seeds keep producing old worlds.

Writing a universe generator

Put the two halves together and a generator is about thirty lines: draw the numbers, build the input object, hand it to createGalaxy().

generate.ts
import { createGalaxy, createRng, type Galaxy } from '@industrieh/starmap'

const NAMES = ['Sol', 'Vega', 'Rigel', 'Altair', 'Lyra', 'Cygnus']
const TYPES = [
  { w: 4, type: 'rocky' },
  { w: 3, type: 'gas' },
  { w: 2, type: 'frozen' },
  { w: 1, type: 'terran' },
] as const

export function generate(seed: string, count: number): Galaxy {
  const rng = createRng(`${seed}:layout`)

  const systems = Array.from({ length: count }, (_, i) => {
    // One stream per system: adding a mechanic later stays local.
    const local = createRng(`${seed}:system-${i}`)
    const planetCount = local.randInt(0, 5)

    return {
      id: `sys-${i}`,
      name: `${rng.pick(NAMES)} ${i}`,
      x: rng.rand(-1000, 1000),
      y: rng.rand(-1000, 1000),
      star: { radius: local.randInt(12, 26) },
      planets: Array.from({ length: planetCount }, (_, p) => ({
        name: `Planet ${p}`,
        type: local.weighted(TYPES).type,
        orbit: 80 + p * local.randInt(40, 70),
        radius: local.randInt(3, 9),
        angle: local.rand(0, 360),
        period: local.randInt(10, 60),
      })),
    }
  })

  // A ring, so the network is connected by construction.
  const lanes = systems.map((s, i) => ({
    from: s.id,
    to: systems[(i + 1) % systems.length].id,
  }))

  // Record the seed: this galaxy can be rebuilt from it alone.
  return createGalaxy({ seed, systems, lanes })
}

Put the seed in the input

createGalaxy() copies seed straight to meta.seed. A save file then carries the recipe for its own world — useful when a player reports something odd.

Connectivity is worth a thought at generation time: a ring guarantees one component. If you wire lanes by proximity instead, check the result with connectedComponents() before returning it.

The lower level

Two primitives are exported under createRng(), for when you want the machinery rather than the convenience.

import { hashSeed, mulberry32 } from '@industrieh/starmap'

// FNV-1a: any string to an unsigned 32-bit integer.
hashSeed('sol')       // 3795205537
hashSeed('orion-7')   // 1161567822

// mulberry32: 32 bits of state, very fast. Returns a plain () => number.
const next = mulberry32(42)
next()   // 0.601104…
next()   // 0.448291…

createRng(seed) is exactly mulberry32(hashSeed(seed)) plus the six conveniences. Reach for the primitives if you need a numeric seed, or a bare function to hand to something else.

Not for anything that must be unguessable

mulberry32 is a content generator, not a cryptographic one. Thirty-two bits of state are trivially recoverable. Never use it for tokens, keys, shuffles that must resist inspection, or anything an adversary benefits from predicting.