Brogue shows the seed of every run and lets you type one in, so you can play the same dungeon as a friend. DCSS added seeded play in 0.24. Spelunky built a daily challenge around the idea. It's one of the best features a roguelike can have, and it's cheap, as long as you plan for it from the first line.
Math.random can't be seeded. That's the first problem, and the small one.
A generator you can seed
// src/rng.ts
export type Rng = () => number;
// mulberry32: 32 bits of state, good enough for a dungeon, four lines long
export function mulberry32(seed: number): Rng {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
Mulberry32 is a tiny generator with 32 bits of state, published by Tommy Ettinger. It is not for cryptography and not for statistics papers. For deciding where the orc stands it is more than enough, it runs anywhere, and it's four lines I can read. The >>> 0 keeps the state an unsigned 32 bit integer, so it never drifts into floating point after a few million calls.
Put the seed in the URL and the problem is half solved. ?seed=1234 gives the same first floor to everyone who opens it.
// src/main.ts
const params = new URLSearchParams(location.search);
const seed = Number(params.get('seed')) || Date.now() >>> 0;
history.replaceState(null, '', `?seed=${seed}`);
The second floor
Here's the problem that bites later. Say there's one generator for the whole game. Level one is generated, then the player fights a rat. Every attack roll draws from the generator. Then the player takes the stairs and level two is generated from wherever the generator happens to be, which depends on how many swings the rat took.
Same seed, different second floor. The seed only reproduces the game if the player reproduces every key press, and that's a replay, not a seed.
The fix is to never share a generator between things that shouldn't affect each other. Derive a fresh generator from the seed and a label, and give each level and each system its own.
// src/rng.ts
// A fresh generator for one purpose, from the run seed and a label
export function stream(seed: number, label: string): Rng {
let h = seed >>> 0;
for (let i = 0; i < label.length; i++) {
h = Math.imul(h ^ label.charCodeAt(i), 0x01000193);
}
return mulberry32(h);
}
Level seven is always stream(seed, 'level:7'), no matter what happened on the way down. Combat draws from stream(seed, 'combat'), item drops from stream(seed, 'loot'), and the one for the shuffled potion colours is created once at the start of the run. The mixing loop is FNV style: xor a character in, multiply by the FNV prime, repeat. It doesn't need to be strong, it needs to give different labels different seeds, and it does.
The helpers
A generator that returns a float between 0 and 1 is the primitive. The game wants dice.
// src/rng.ts
export function int(rng: Rng, min: number, max: number): number {
return min + Math.floor(rng() * (max - min + 1)); // inclusive on both ends
}
export function pick<T>(rng: Rng, list: readonly T[]): T {
return list[Math.floor(rng() * list.length)];
}
export function roll(rng: Rng, dice: number, sides: number): number {
let total = 0;
for (let i = 0; i < dice; i++) total += int(rng, 1, sides);
return total;
}
// Fisher-Yates, in place
export function shuffle<T>(rng: Rng, list: T[]): T[] {
for (let i = list.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[list[i], list[j]] = [list[j], list[i]];
}
return list;
}
Every function takes the generator as its first argument, and no module imports a global random, so nothing can reach into the wrong stream by accident.
What must not roll
Anything that runs a different number of times on different machines must stay out of the game's streams. Animation frames, the particle that fades differently at 60 and 144 hertz, the menu that picks a random tip. Give those Math.random and keep the game logic pure.
The test for the whole thing is short: generate a level twice with the same seed and compare the arrays. It takes a minute to write for each generator, and it's the test that catches the leak when someone adds a roll in the wrong place.
If you want a different generator, bryc's Pseudorandom number generators page is where mulberry32 and its relatives live, with notes on which to pick.