Skip to content

Same Seed Same Galaxy

m4bwav edited this page Oct 4, 2026 · 1 revision

The seed promise: from 1.0.0, a seed, the options and the generator version (the v1 in every address) give the same output on Windows, Linux and macOS, on .NET 10 and .NET Framework 4.8, for the whole 1.x line. The README extends it to Unity (Mono and IL2CPP); this wiki's runs did not cover Unity. A change that would alter any seed's output must add a generator version and keep the old one.

Twice, and a part alone

The same call twice gives the same galaxy, down to every system. And any object regenerated alone from its address equals the same object generated in place:

using UniverseGeneration;

var first = Galaxy.Generate("my-seed").ToJson(children: true);
var second = Galaxy.Generate("my-seed").ToJson(children: true);
Console.WriteLine(first == second);

var galaxy = Galaxy.Generate("my-seed");
var inPlace = galaxy.System(31);
var alone = (StarSystem)Universe.At(inPlace.Address);
Console.WriteLine(inPlace.ToJson() == alone.ToJson());

prints:

True
True

A fingerprint to compare across machines

ToJson(children: true) writes every field of the galaxy and of all its systems, the same text on every runtime. Its hash is a fingerprint of the whole galaxy:

using System.Security.Cryptography;
using System.Text;
using UniverseGeneration;

var json = Galaxy.Generate("my-seed").ToJson(children: true);
using var sha = SHA256.Create();
var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(json));
Console.WriteLine($"{json.Length} characters, SHA-256 {BitConverter.ToString(hash).Replace("-", "").Substring(0, 16)}...");

prints, on .NET 10 and on .NET Framework 4.8 alike:

909995 characters, SHA-256 D486B72BA56ACAEF...

Run it on your own machine: the same line means you have the same galaxy.

Addresses

Every object has an Address, such as v1-my-seed/galaxy/system/31/planet/1, and Universe.At(address) regenerates that object alone. Save the address, not the object. An address does not carry options: pass the options the object was made with, or save a link from Universe.Link(address, options), which carries them after a ? (see Recipes).

Generating a level on its own gives it its own address, so StarSystem.Generate("my-seed") (v1-my-seed/system) is a different system from Galaxy.Generate("my-seed").System(0) (v1-my-seed/galaxy/system/0).

Seeds that look alike

Seeds are compared as exact text. Case and spaces make a different seed, and a space is escaped as %20 in the address:

using UniverseGeneration;

foreach (var seed in new[] { "my-seed", "My-Seed", "my-seed ", "hello world" })
{
    var galaxy = Galaxy.Generate(seed);
    Console.WriteLine($"\"{seed}\": {galaxy.Address}, {galaxy.Name}, first system {galaxy.Map[0].Name}");
}

prints:

"my-seed": v1-my-seed/galaxy, Wyvern Galaxy, first system HD 147927
"My-Seed": v1-My-Seed/galaxy, Pallas Galaxy, first system 5 Trianguli
"my-seed ": v1-my-seed%20/galaxy, Quill Galaxy, first system HD 129976
"hello world": v1-hello%20world/galaxy, Wyvern Galaxy, first system Southern Crab

Galaxy names come from a fixed list, so two seeds can share a galaxy name ("my-seed" and "hello world" both make a Wyvern Galaxy) and still be different galaxies. Trim or normalise user-typed seeds yourself if "My-Seed" and "my-seed " should mean the same world. An empty seed is accepted and is a seed like any other.

Options are part of the answer

The options are part of what a seed means. DangerShift, ExtraLanes and Arms change values after every draw (the CHANGELOG for 1.0.0), so DangerShift moves danger and nothing else. Systems is a different galaxy: here its first system keeps the name but not the danger.

using UniverseGeneration;

var plain = Galaxy.Generate("my-seed");
var shifted = Galaxy.Generate("my-seed", Preset.Default with { DangerShift = 2 });
var bigger = Galaxy.Generate("my-seed", Preset.Default with { Systems = 61 });
Console.WriteLine($"{plain.Map[0].Name}, {shifted.Map[0].Name}, {bigger.Map[0].Name}");
Console.WriteLine($"danger {plain.Map[0].Danger}, {shifted.Map[0].Danger}, {bigger.Map[0].Danger}");
Console.WriteLine(plain.Map.Zip(shifted.Map, (a, b) => a.Name == b.Name && a.X == b.X).All(same => same));

prints:

HD 147927, HD 147927, HD 147927
danger 9, 10, 7
True

Danger stays within 1 to 10 (the DangerShift documentation), so the first system's 9 shifted by 2 shows as 10, and the last line says every system kept its name and position.

Hooks never move a draw

GeneratorHooks run your code on each object after it is made. They can rename or tag, but the generator's draws happen before them, so nothing else changes:

using UniverseGeneration;

var hooks = new GeneratorHooks { OnSystem = s => s with { Name = "New " + s.Name } };
var plain = Galaxy.Generate("my-seed").System(0);
var hooked = Galaxy.Generate("my-seed", null, hooks).System(0);
Console.WriteLine($"{plain.Name} / {hooked.Name}");
Console.WriteLine(plain.Star == hooked.Star && plain.Planets.Count == hooked.Planets.Count);

prints:

HD 147927 / New HD 147927
True

Pass the same hooks to Universe.At and it runs them again on what it regenerates. A hook that is not itself deterministic (one that reads the clock or a random number) breaks the promise for its own fields only.

What breaks the promise

  • A different seed text, different options, or different registered tables under the same id.
  • A hook that gives different results for the same object.
  • Reading positions through Unity's float adapters and comparing them as if they were the generated doubles: the README says those floats are for drawing, and the doubles are what the promise covers.
  • A future generator version. Addresses start with v1; a version 2 would be asked for by v2- addresses, and 1.0.0 refuses those with "This package generates version 1; the address asks for version 2." (errors).

How it is kept

These rules come from the repository's determinism rules, with the evidence for each:

  1. Its own random numbers, specified exactly: PCG32 for draws and SplitMix64 to derive seeds. Never System.Random (its seeded sequence is not promised across .NET versions, and new Random(s) and new Random(-s) give the same sequence) or UnityEngine.Random.
  2. Hierarchical seeds: each object's seed comes from its parent's seed and its label, hashed with FNV-1a over UTF-8 (never string.GetHashCode, which .NET randomises per process). Each purpose (layout, star, planets, names, lanes) draws from its own stream, so a new field or a tuned probability shifts nothing else.
  3. Decisions on integer draws against integer weights; a continuous value is quantised before it decides anything.
  4. Maths from + - * / and square root only, which IEEE 754 rounds the same everywhere. Exp, Log, Pow, Sin, Cos and Atan2 are built from those, because the platform versions can differ in the last bit between Windows, Linux, macOS, Mono and IL2CPP.
  5. Doubles inside, rounded to fixed decimals before a value is stored or written, with invariant formatting.
  6. A fixed number of draws per object and stream; an optional feature draws from its own stream.
  7. A seed's identity is the seed text and the generator version, and golden files per version are never regenerated.

The proof is in the repository: golden files in tests/Golden/v1/ that the tests compare on .NET 10 and .NET Framework 4.8, run by CI on Windows, Linux and macOS. 1.0.0 added new golden files for its new features and changed none of 1.0.0-beta.1's, so every 1.0.0-beta.1 seed gives the same output on 1.0.0 (Versions and upgrading).

Clone this wiki locally