Every time you die in a roguelike and start a new run, you get a different dungeon. Different enemies, different items, different layout. That variety is the whole point, and it all comes from a single number.
That number is the seed. Pass it to a pseudo-random number generator and you get an infinite stream of "random" values that are, in reality, completely deterministic. Same seed, same stream, every time.
For a player, that's a fun curiosity, something to share on Discord with friends so they can see the same run. For a developer, it's the difference between a bug you can reproduce in five minutes and one that only exists in the memory of a player who can never recreate the exact conditions.
This article digs into how that works under the hood, why determinism is actually a feature rather than a limitation, and how to implement a seed-based RNG system in Unity/C# that you can use in your own projects.
1. Random Isn't Really Random
Computers are deterministic machines. They can't generate true randomness (at least not from software alone). What we call "random" in games is pseudo-random: a mathematical function that produces a sequence of numbers that looks random but is entirely determined by its starting value.
The most common family of algorithms used for this is the Linear Congruential Generator (LCG). The formula is almost embarrassingly simple:
Feed in a seed, apply the formula, get a number. Apply again, get the next number. The sequence repeats eventually (after billions of steps with good constants), but within the range of a game session, it behaves as if it's genuinely random.
Unity's built-in Random.value uses a Xorshift algorithm, not LCG, but the core idea is the same: a deterministic function seeded with an initial value. The algorithm doesn't matter much for games. Predictability and speed do.
2. What a Seed Actually Does
A seed is just the starting state of the generator. Two runs with the same seed will consume the same sequence of numbers in the same order, as long as they make the same calls in the same order.
This is why you can share seeds in games like Spelunky, Dead Cells, The Binding of Isaac, or Hades. The level generation, enemy placement, and item drops are all driven by RNG calls that happen in a fixed, predictable order. The seed pins the entire run.
A seed doesn't describe a world. It describes a recipe for building one.

The practical consequence is that seeds are lightweight. You don't need to serialize the entire dungeon state. Just store the seed and replay the generation. This is how games ship thousands of unique levels without storing any of them on disk. It's less expensive to save a string than the full data for every item, enemy, and world element.
3. Determinism Is a Feature
When players talk about seeds, they usually mean "interesting layouts to share." But for developers, determinism solves a much harder problem: reproducibility.
A player reports a crash that only happens in a specific dungeon configuration. With non-deterministic generation, that bug might be impossible to reproduce. With seeded RNG, you ask for the seed, replay the run, and the bug appears on your screen in under a minute.
Log the seed at session start. Even if you never expose it to players, having it in your crash reports will save you hours of debugging. A one-line log call pays for itself the first time you need to reproduce a reported bug.

Determinism also enables seeded unit tests. Instead of testing "does the dungeon have at least one room?" with a random dungeon every run, you test with a fixed seed and assert exact outcomes. Tests become stable, reproducible, and meaningful.
4. The Problem With Global State
Unity's Random class is a global singleton. Call Random.InitState(seed) at startup, and every system that calls Random.value or Random.Range shares the same sequence.
This creates a fragile coupling between unrelated systems. If you add a new particle effect that calls Random.Range once, every dungeon generated after that point will be different, even with the same seed. The entire run breaks.
1// Fragile: any new Random call anywhere breaks determinism
2Random.InitState(1337);
3
4// System A consumes call 1, 2, 3
5int roomCount = Random.Range(5, 10);
6
7// New visual effect added by another dev — consumes call 4
8float particleOffset = Random.Range(0f, 1f);
9
10// System B now gets a different value than expected
11ItemType loot = (ItemType)Random.Range(0, itemPool.Length);The solution is to isolate RNG streams. Each major system gets its own generator, seeded independently from the root seed. Changes to the particle system can't affect dungeon generation because they're drawing from different pools.
5. Building the System in Unity/C#
The core abstraction is an IRng interface. Every system that needs randomness takes a dependency on this interface rather than calling Random directly. This decouples gameplay code from the underlying generator and makes the system testable.
1public interface IRng
2{
3 int Next(int min, int max);
4 float NextFloat(float min = 0f, float max = 1f);
5 bool Chance(float probability);
6 T Pick<T>(IList<T> list);
7 void Shuffle<T>(IList<T> list);
8 T PickWeighted<T>(IList<T> items, IList<float> weights);
9}The concrete implementation wraps System.Random from .NET (fast, well-tested, and independent of Unity). The key is that it's seeded explicitly, not from time or system state:
1public class SeededRng : IRng
2{
3 private readonly System.Random _random;
4
5 public SeededRng(int seed)
6 {
7 _random = new System.Random(seed);
8 }
9
10 public int Next(int min, int max) =>
11 _random.Next(min, max);
12
13 public float NextFloat(float min = 0f, float max = 1f) =>
14 min + (float)_random.NextDouble() * (max - min);
15
16 public bool Chance(float probability) =>
17 NextFloat() < probability;
18
19 public T Pick<T>(IList<T> list) =>
20 list[Next(0, list.Count)];
21
22 public void Shuffle<T>(IList<T> list)
23 {
24 for (int i = list.Count - 1; i > 0; i--)
25 {
26 int j = Next(0, i + 1);
27 (list[i], list[j]) = (list[j], list[i]);
28 }
29 }
30
31 public T PickWeighted<T>(IList<T> items, IList<float> weights)
32 {
33 float total = 0f;
34 foreach (var w in weights) total += w;
35 float roll = NextFloat(0f, total);
36 float cumulative = 0f;
37 for (int i = 0; i < items.Count; i++)
38 {
39 cumulative += weights[i];
40 if (roll < cumulative) return items[i];
41 }
42 return items[items.Count - 1];
43 }
44}6. Splitting the Seed Into Streams
One root seed drives all systems, but each system gets a derived seed so their streams are isolated. The simplest way is to hash the root seed with a domain-specific constant:
1public class RngFactory
2{
3 private readonly int _rootSeed;
4
5 public RngFactory(int rootSeed)
6 {
7 _rootSeed = rootSeed;
8 }
9
10 public IRng For(RngDomain domain)
11 {
12 // XOR with a domain-specific prime to derive a unique seed
13 int derivedSeed = _rootSeed ^ (int)domain * 2654435761;
14 return new SeededRng(derivedSeed);
15 }
16}
17
18public enum RngDomain
19{
20 DungeonLayout = 1,
21 EnemySpawns = 2,
22 LootDrops = 3,
23 AmbientEffects = 4,
24 ShopInventory = 5,
25}Now each system requests its own IRng instance from the factory. Adding a new particle system under AmbientEffects has zero impact on dungeon layout or loot drops:
1public class DungeonGenerator
2{
3 private readonly IRng _rng;
4
5 public DungeonGenerator(RngFactory factory)
6 {
7 _rng = factory.For(RngDomain.DungeonLayout);
8 }
9
10 public Dungeon Generate(DungeonConfig config)
11 {
12 int roomCount = _rng.Next(config.minRooms, config.maxRooms);
13 // Every call consumes from the DungeonLayout stream only
14 // ...
15 }
16}Isolated streams only work if each system makes a consistent number of RNG calls per operation. If room generation calls _rng.Next() a variable number of times depending on earlier results, adding a new room type can still shift the sequence. Design generators so that the call count is deterministic given the same inputs.
With this architecture, each system's stream evolves completely independently:
- seed 12345, DungeonLayout → hash(12345, 1) → layout stream
- seed 12345, EnemySpawns → hash(12345, 2) → spawn stream
- seed 12345, LootDrops → hash(12345, 3) → loot stream
- seed 12345, AmbientEffects → hash(12345, 4) → particle stream
Each stream evolves independently. A new particle effect consuming from AmbientEffects never touches the loot stream, no matter how many calls it makes.
7. Weighted Random Selection
Flat random gives every item in a pool equal odds. That works for room types, but not for loot, where each item generally needs a different probability of appearing (depending on rarity, for example). Weighted random is how roguelikes encode intent into probability.
The idea: each item carries a weight. The generator rolls against the total weight and returns whichever item's cumulative range the roll lands in. A sword with weight 60 and a relic with weight 10 out of a total of 100 gives the sword a 60% drop chance and the relic 10%.
1// Item pool with weights — weights are data, not code
2var items = new List<ItemType> { ItemType.Sword, ItemType.Shield, ItemType.Relic };
3var weights = new List<float> { 60f, 30f, 10f };
4
5// _rng draws from the LootDrops stream
6ItemType reward = _rng.PickWeighted(items, weights);
7
8// Sword: 60%, Shield: 30%, Relic: 10%In practice, weights belong in data files, not code. Attach a weight field to each item definition so designers can adjust drop rates in a spreadsheet without a code change or rebuild.
For large pools (hundreds of entries), consider the Alias Method: O(n) setup, O(1) per draw. For typical roguelike loot pools (10 to 100 items), the linear walk in PickWeighted is fast enough and far simpler to debug.
8. Case Study: Otter's Hell
Otter's Hell is a bullet-hell roguelite developed in Unity over four years, where Sparkles (the last general of the Otter Empire) navigates procedurally generated planets fighting enemy Hamster armies. Every run feels different, and that variety is entirely driven by seeded RNG split across three independent streams.
Procedural level generation
Each planet is assembled at runtime from a pool of room templates. The dungeon generator uses a layout stream to pick obstacles, environment elements, traps, and points of interest. The result is a different map every run, while still respecting structural constraints.
The level visit order is also generated at the start of the run, selecting layouts from a pool of different options.

Enemy spawns
Enemy composition is handled by a separate spawn stream. When a room is entered, the system draws from its RNG to select enemy types, count, and starting positions, all within design-defined budgets per planet tier. Because this stream is isolated from the layout stream, adjusting room layouts never accidentally changes which enemies appear in them.
Reward generation
Completing a room triggers a third stream: rewards. The system weighs available items against the player's current build, applies weighted selection, and presents three options. Kepler, the in-run shop, uses the same stream seeded per-floor, so the shop inventory is fixed for that floor once generated, but different across runs and seeds.
1// Simplified reward selection from Otter's Hell
2public Item[] GenerateRoomRewards(int count = 3)
3{
4 var pool = _itemRegistry.GetUnlocked()
5 .Select(item => (item, weight: ComputeWeight(item, _playerBuild)))
6 .Where(e => e.weight > 0)
7 .ToList();
8
9 var chosen = new List<Item>();
10 for (int i = 0; i < count && pool.Count > 0; i++)
11 {
12 var items = pool.Select(e => e.item).ToList();
13 var weights = pool.Select(e => (float)e.weight).ToList();
14 var pick = _rng.PickWeighted(items, weights);
15 chosen.Add(pick);
16 pool.RemoveAll(e => e.item == pick); // no duplicates in one offer
17 }
18 return chosen.ToArray();
19}
20
21// Weight biases toward items that synergize with the current build
22private float ComputeWeight(Item item, PlayerBuild build)
23{
24 float baseWeight = item.DropWeight;
25 float synergyBonus = build.CountSynergiesWith(item) * 15f;
26 return baseWeight + synergyBonus;
27}Separating spawn, layout, and reward streams meant we could rebalance item drop rates without touching a single line of dungeon generation code, and vice versa. That independence became critical during the final months of balancing.
9. Generating and Displaying Seeds
For player-facing seeds, raw integers are ugly and hard to remember. A common pattern is to generate a random seed and encode it as a short alphanumeric string, easier to share in a screenshot caption or Discord message.
1public class SeedManager
2{
3 public int CurrentSeed { get; private set; }
4
5 // Generate a new random seed from system time
6 public void NewRun()
7 {
8 CurrentSeed = Environment.TickCount;
9 Debug.Log($"[RNG] Run seed: {ToDisplayString()}");
10 }
11
12 // Replay a specific run by seed
13 public void SetSeed(int seed)
14 {
15 CurrentSeed = seed;
16 Debug.Log($"[RNG] Replaying seed: {ToDisplayString()}");
17 }
18
19 public RngFactory CreateFactory() =>
20 new RngFactory(CurrentSeed);
21
22 // Encode as 7-char base-36 string (e.g. "3K9FWQZ") for display
23 public string ToDisplayString() =>
24 EncodeBase36((uint)CurrentSeed);
25
26 public void SetFromDisplayString(string s) =>
27 CurrentSeed = (int)DecodeBase36(s);
28
29 private static string EncodeBase36(uint value)
30 {
31 const string chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
32 var result = new char[7];
33 for (int i = 6; i >= 0; i--)
34 {
35 result[i] = chars[(int)(value % 36)];
36 value /= 36;
37 }
38 return new string(result);
39 }
40
41 private static uint DecodeBase36(string s)
42 {
43 const string chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
44 uint result = 0;
45 foreach (char c in s.ToUpperInvariant())
46 result = result * 36 + (uint)chars.IndexOf(c);
47 return result;
48 }
49}Always log the seed before anything else. If the game crashes, the seed is in the log file. If a player reports a weird dungeon, they can share the display string from the pause menu. Building this from day one will save several headaches down the line, both for debugging and for your players.
10. Saving and Restoring RNG State
If your game supports mid-run saves, you need to preserve each stream's exact position alongside the rest of the game state. A player who saves before a boss room and reloads should get the same dungeon, enemies, and rewards, not a freshly shuffled version.
System.Random doesn't expose its internal state directly. The simplest solution: track how many calls each stream has consumed, then fast-forward to that position on restore.
1public record RngState(int Seed, int CallCount);
2
3// Add state tracking to SeededRng:
4public class SeededRng : IRng
5{
6 private System.Random _random;
7 private readonly int _seed;
8 private int _callCount;
9
10 public SeededRng(int seed)
11 {
12 _seed = seed;
13 _random = new System.Random(seed);
14 }
15
16 public int Next(int min, int max)
17 {
18 _callCount++;
19 return _random.Next(min, max);
20 }
21
22 public float NextFloat(float min = 0f, float max = 1f)
23 {
24 _callCount++;
25 return min + (float)_random.NextDouble() * (max - min);
26 }
27
28 // Chance, Pick, Shuffle, PickWeighted unchanged — they call Next/NextFloat
29
30 public RngState CaptureState() => new(_seed, _callCount);
31
32 public void RestoreState(RngState state)
33 {
34 _random = new System.Random(state.Seed);
35 for (int i = 0; i < state.CallCount; i++)
36 _random.NextDouble(); // fast-forward to saved position
37 _callCount = state.CallCount;
38 }
39}Fast-forwarding is O(n) in call count. In practice, a full run generates thousands of calls per stream, so restoring at load takes milliseconds. If call counts ever grow large, switch to a generator with serializable state: .NET 8's Random has GetState()/SetState(), and mt19937 can be serialized via stream operators.
11. Testing With Fixed Seeds
The best part of this architecture is that tests become trivial to write and completely stable. There's no flakiness. A test with a fixed seed will produce the same result in CI, on every machine, forever.
1[Test]
2public void DungeonAlwaysHasAtLeastOneRoom()
3{
4 var factory = new RngFactory(seed: 42);
5 var generator = new DungeonGenerator(factory);
6 var config = DungeonConfig.Default;
7
8 var dungeon = generator.Generate(config);
9
10 Assert.IsTrue(dungeon.Rooms.Count >= 1);
11}
12
13[Test]
14public void SameSeedProducesSameDungeon()
15{
16 var config = DungeonConfig.Default;
17
18 var dungeonA = new DungeonGenerator(new RngFactory(99)).Generate(config);
19 var dungeonB = new DungeonGenerator(new RngFactory(99)).Generate(config);
20
21 Assert.AreEqual(dungeonA.Rooms.Count, dungeonB.Rooms.Count);
22 Assert.AreEqual(dungeonA.Layout, dungeonB.Layout);
23}
24
25[Test]
26public void DifferentSeedsProduceDifferentDungeons()
27{
28 var config = DungeonConfig.Default;
29
30 var dungeonA = new DungeonGenerator(new RngFactory(1)).Generate(config);
31 var dungeonB = new DungeonGenerator(new RngFactory(2)).Generate(config);
32
33 // Not guaranteed, but astronomically likely with good constants
34 Assert.AreNotEqual(dungeonA.Layout, dungeonB.Layout);
35}Running these tests costs nothing and guarantees the fundamental contract: determinism. If a refactor breaks seeded reproducibility, the test fails immediately, before it reaches players.
Conclusions
Seeds and deterministic RNG aren't an advanced feature. They're a foundational design decision. Building them in from the start costs almost nothing. Retrofitting them later, after systems have grown dependent on global random state, is genuinely painful.
- Pseudo-random generators are deterministic: same seed, same sequence.
- Isolate RNG streams per system so unrelated changes don't break reproducibility.
- Log seeds always, even if you never expose them to players.
- Use weighted selection for loot and enemy pools; keep weights in data, not code.
- Track call counts to enable mid-run save/restore without serializing generator internals.
- Use fixed seeds in tests for stable, meaningful assertions.
- Design generators with a consistent RNG call count to keep streams predictable.

The roguelikes that get seeding right (Spelunky, Hades, Caves of Qud) feel fair even when they're brutal. Part of that fairness comes from knowing that the game isn't cheating: the same seed will always build the same world, and that world plays by consistent rules.
Determinism isn't the opposite of randomness. It's what makes randomness trustworthy.


