src/domain/rng.ts — repeatable random numbers
src/domain/rng.ts — repeatable random numbers
In one sentence: makes random-looking numbers that are actually repeatable, so a test can recreate the exact same game board every time.
Size: 78 lines. Depends on: nothing at all.
Why this file exists
The game needs randomness — where to put collectibles, where to drop a new player.
The obvious choice is Math.random().
The problem is that Math.random() gives a different answer every single run. That
makes testing painful:
- A test cannot say "put a collectible at exactly x=400, y=300".
- If a test fails once in fifty runs because of an unlucky layout, you can never reproduce it to find out why.
So instead we make our own generator that takes a seed (a starting number). The
same seed always produces the same sequence of numbers. Give it seed 42 and you
get the same board today, tomorrow, and on your colleague's laptop.
The trade-off: our generator is not as statistically perfect as a professional one. That's fine. Scattering coins on a map does not need mathematical rigour, it needs repeatability. Anything security-sensitive (like the player reconnect token) deliberately does not use this file — see GameRoom.md.
The code, line by line
1. The shape of the generator (lines 34–39)
export interface Rng {
/** A number from 0 up to (but not including) 1. */
next(): number;
/** A number from min up to (but not including) max. */
range(min: number, max: number): number;
}
export interface Rng {— an interface is a description of a shape, not real code. It says "anything calling itself an Rng must have these two functions". It exists so thatGameRoomcan say "give me an Rng" without caring how the numbers are actually produced.next(): number;— a function taking nothing and returning a number. It will give something like0.7134. Never exactly 1.range(min, max): number;— a convenience.range(10, 20)gives a number somewhere between 10 and 20.
Interfaces vanish when TypeScript compiles. They exist only to catch mistakes while you write.
2. The two magic constants (lines 41–43)
const MULTIPLIER = 16807;
const MODULUS = 2147483647;
MULTIPLIER = 16807— the number we multiply by on every step.MODULUS = 2147483647— the number we divide by to take the remainder. It is a prime number, and it happens to be the largest whole number that fits in 31 bits.
Why these two specifically? They are a published, tested pair known as MINSTD (short for "minimal standard"), from a paper by Park and Miller. If you choose the two numbers carelessly the sequence repeats quickly, or shows visible patterns like always alternating odd and even. With these two, the sequence runs about 2.1 billion steps before it repeats.
If asked "why 16807?", the honest answer is: "it's a standard well-tested pair; picking them yourself is how you get a bad generator."
3. The warm-up constant (lines 45–47)
const WARMUP_DRAWS = 10;
Ten results are generated and thrown in the bin before anyone gets to use the generator. The reason is explained in step 6 below — it fixes a real flaw.
4. Preparing the starting number (lines 49–53)
export function createRng(seed: number): Rng {
let state = (Math.abs(Math.floor(seed)) % (MODULUS - 1)) + 1;
state is the generator's memory — the current number, which becomes the next
number, forever. Read that line from the inside out:
| Piece | What it does | Why |
|---|---|---|
Math.floor(seed) | chops off any decimal part | we need a whole number |
Math.abs(...) | removes a minus sign | a negative state would break the maths |
% (MODULUS - 1) | remainder after dividing by 2147483646 | squashes a huge number (like a timestamp) into range |
+ 1 | shifts the range from 0…N-1 to 1…N | zero must be avoided |
Why zero is fatal: the next step multiplies the state. Zero times anything is zero, so once the state hits zero it is stuck there and every future "random" number is 0. Adding 1 guarantees we never start there.
let (not const) because this value changes on every call.
5. Producing one number (lines 55–62)
const next = (): number => {
state = (state * MULTIPLIER) % MODULUS;
return state / MODULUS;
};
const next = (): number => {— defines a function callednext. The=>is an arrow function, a shorter way of writingfunction.state = (state * MULTIPLIER) % MODULUS;— the entire algorithm. Multiply the current number by 16807, then keep only the remainder after dividing by 2147483647. That new number becomes the state for next time.return state / MODULUS;— the state is a big number between 1 and 2147483646. Dividing by 2147483647 squashes it to a decimal between 0 and 1, which is what callers expect.
Does the multiplication overflow? No, and this is worth knowing. The biggest
possible product is 2147483646 × 16807, roughly 3.6e13 (36 trillion).
JavaScript handles whole numbers exactly up to about 9e15 (9 quadrillion), so
we have plenty of room. Had we picked a bigger multiplier, the maths would
silently go wrong — numbers past that limit lose their last digits.
Walking through it with tiny numbers
The real constants are too big to follow in your head. Use 3 and 7 instead,
starting at 1:
| Step | Multiply | Take remainder | Result |
|---|---|---|---|
| 1 | 1 × 3 = 3 | 3 ÷ 7 leaves 3 | 3 |
| 2 | 3 × 3 = 9 | 9 ÷ 7 leaves 2 | 2 |
| 3 | 2 × 3 = 6 | 6 ÷ 7 leaves 6 | 6 |
| 4 | 6 × 3 = 18 | 18 ÷ 7 leaves 4 | 4 |
| 5 | 4 × 3 = 12 | 12 ÷ 7 leaves 5 | 5 |
| 6 | 5 × 3 = 15 | 15 ÷ 7 leaves 1 | 1 ← back to the start |
Output: 3, 2, 6, 4, 5, 1 then it repeats. Scattered, no obvious pattern — and
completely determined by starting at 1.
6. The warm-up (lines 64–72)
for (let i = 0; i < WARMUP_DRAWS; i++) next();
Call next() ten times and ignore the answers.
This fixes a genuine flaw. The very first result is just
seed × 16807 ÷ 2147483647. For a small seed that is a tiny number, and it grows
in step with the seed. Measured before the fix:
seed 1 -> 0.000016 seed 2 -> 0.000023 seed 3 -> 0.000031
Seeds 1, 2 and 3 would all put the first collectible in almost exactly the same top-left corner. After the warm-up:
seed 1 -> 0.767004 seed 2 -> 0.150506 seed 3 -> 0.534008
Properly scattered. And because we always discard exactly ten, the generator is still perfectly repeatable — same seed, same sequence.
7. Handing back the two functions (lines 74–77)
return {
next,
range: (min: number, max: number) => min + next() * (max - min),
};
}
next,— shorthand fornext: next. Hands out the function built above.range: ...— buildsrangefromnext. Ifnext()gives0.5and you ask forrange(10, 20):10 + 0.5 × (20 - 10)=10 + 5=15. Halfway, as expected.
Note that state is not returned. It lives inside createRng and nothing
outside can reach it or tamper with it. This is called a closure — the two
returned functions still remember state even after createRng has finished.
How the rest of the project uses it
// GameManager creates a room and hands it a generator seeded from the room code
const room = new GameRoom(id, config, createRng(this.hashSeed(id, ++this.roomSeq)), now);
Each room gets its own generator with its own seed, so two rooms lay out their collectibles differently. And because the seed comes from the room code, the same room code always produces the same board.
In tests:
const a = new GameRoom('SEED01', cfg, createRng(99), 0);
const b = new GameRoom('SEED01', cfg, createRng(99), 0);
expect(a.getSnapshot().collectibles).toEqual(b.getSnapshot().collectibles); // passes
If an interviewer asks
"Why not just use
Math.random()?""Because it can't be repeated. Seeding the generator means a test can recreate the exact same board, so a failure caused by an unlucky layout can be reproduced and debugged instead of just being flaky."
"What is a linear congruential generator?"
"Multiply the current number by a constant and keep the remainder. That's the whole algorithm. The results look scattered but are entirely determined by the starting seed."
"Is this secure enough for tokens?"
"No, and it isn't used for them. It's seeded from the room code, which players share, so anyone with the code could predict the sequence. Reconnect tokens use
crypto.randomUUID()instead. Game randomness wants to be replayable; a credential must never be."