← How a game is made: Daily Challenge's Snake · Lesson 10/16
The same luck for everyone
Math.random can't be replayed
In Snake the fruit appears on a random free cell. If that randomness came from Math.random(), everyone would see the fruit somewhere else and the daily challenge wouldn't be the same for all. Worse: the server, which replays your game to check the score (you'll see it in unit 7), would get other fruit and another result.
The way out is a pseudorandom generator: a function that, starting from an initial number, the seed, produces a series that looks random but is always the same. Daily Challenge uses mulberry32: ten lines, no libraries. Run the program twice: Math.random changes; the two seed-42 series don't.
$ node leccion.js
Press RUN (or Ctrl+Enter).
What to look at
- The
Math.randomones change every time you press RUN. aandbgive exactly the same three numbers: same seed, same series.- Each
next()returns a number between 0 (included) and 1 (excluded), just likeMath.random.
The whole lesson on one page
Math.random can't be replayed
In Snake the fruit appears on a random free cell. If that randomness came from Math.random(), everyone would see the fruit somewhere else and the daily challenge wouldn't be the same for all. Worse: the server, which replays your game to check the score (you'll see it in unit 7), would get other fruit and another result.
The way out is a pseudorandom generator: a function that, starting from an initial number, the seed, produces a series that looks random but is always the same. Daily Challenge uses mulberry32: ten lines, no libraries. Run the program twice: Math.random changes; the two seed-42 series don't.
function mulberry32(seed) {
let a = seed >>> 0;
const next = () => {
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;
};
return {
next,
int: (min, max) => min + Math.floor(next() * (max - min + 1)),
pick: (arr) => arr[Math.floor(next() * arr.length)],
chance: (p) => next() < p,
};
}
console.log("Math.random:", Math.random().toFixed(4), Math.random().toFixed(4));
const a = mulberry32(42);
const b = mulberry32(42); // another series, same seed
console.log("a:", a.next().toFixed(4), a.next().toFixed(4), a.next().toFixed(4));
console.log("b:", b.next().toFixed(4), b.next().toFixed(4), b.next().toFixed(4));
- The
Math.randomones change every time you press RUN. aandbgive exactly the same three numbers: same seed, same series.- Each
next()returns a number between 0 (included) and 1 (excluded), just likeMath.random.
The date becomes the seed
Where does the day's seed come from? From the date. hashString turns a string like "2026-10-23" into a 32-bit integer with FNV-1a: for each character it mixes its code in with ^ (exclusive or) and multiplies by a prime. Consecutive dates only differ in the last character and give nearby seeds, but that's fine: mulberry32 scrambles them and their series look nothing alike.
From that seed, seeds() derives two independent series: one for the game and one for the music. That way, if the music someday asks for more random numbers, the fruit doesn't move.
function mulberry32(seed) {
let a = seed >>> 0;
const next = () => {
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;
};
return {
next,
int: (min, max) => min + Math.floor(next() * (max - min + 1)),
pick: (arr) => arr[Math.floor(next() * arr.length)],
chance: (p) => next() < p,
};
}
function hashString(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
// From public/arcade/core/rng.js: two seeds that don't collide
const seeds = (seed) => ({ game: (seed ^ 0x1234abcd) >>> 0, music: (seed ^ 0x9e3779b9) >>> 0 });
for (const fecha of ["2026-10-23", "2026-10-24", "2026-10-25"]) {
const s = hashString(fecha);
const rng = mulberry32(seeds(s).game);
const tres = [rng.next(), rng.next(), rng.next()].map((v) => v.toFixed(2)).join(" ");
console.log(fecha, "->", s, "| game:", tres);
}
- The three seeds look alike (only the last character changed), but the game series that come out of them have nothing in common: the generator does the scrambling.
>>> 0keeps the number as an unsigned 32-bit integer; without it, half would come out negative.- The server computes the same thing with the same function (
src/lib/reto.tsdoeshashString(fecha)), so nobody has to send it the seed.
pick, int and chance
On top of next() there are three helpers: int(min, max) gives an integer between the two (inclusive), pick(arr) chooses an item and chance(p) is "yes" with probability p.
Snake uses pick: it gathers the free cells in an array and chooses one (randomEmpty in games/snake.js). Here's that idea on a 4×3 board. Run it: with the same seed the three fruits always land on the same cells, on anyone's phone and on the server.
function mulberry32(seed) {
let a = seed >>> 0;
const next = () => {
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;
};
return {
next,
int: (min, max) => min + Math.floor(next() * (max - min + 1)),
pick: (arr) => arr[Math.floor(next() * arr.length)],
chance: (p) => next() < p,
};
}
function frutas(semilla) {
const rng = mulberry32(semilla);
const libres = []; // cells it can land on
for (let y = 0; y < 3; y++)
for (let x = 0; x < 4; x++) libres.push({ x, y });
const salen = [];
for (let i = 0; i < 3; i++) {
const f = rng.pick(libres);
libres.splice(libres.indexOf(f), 1);
salen.push(f.x + "," + f.y);
}
return salen.join(" ");
}
console.log("semilla 7:", frutas(7));
console.log("semilla 7:", frutas(7)); // again: the same
console.log("semilla 8:", frutas(8));
const dado = mulberry32(2026);
console.log("dado:", dado.int(1, 6), dado.int(1, 6), dado.int(1, 6));
console.log("moneda:", dado.chance(0.5) ? "heads" : "tails");
Both seed-7 lines are identical; seed 8 changes everything. Notice the game never stores "the fruit appeared at 2,1": the seed is enough, as long as the numbers are asked for in the same order.
The rule: only the seed and the buttons
For a game to be replayable, the simulation may depend on just two things: the seed and each tick's buttons. No Math.random, no clock (Date.now), no screen size inside update.
Outside the simulation it's fine: the white noise for percussion is filled with Math.random (core/audio.js) because it only makes sound; it doesn't move a single cell. And watch the order: asking for one extra number anywhere shifts the whole series. That's why the game and the music have separate series.
function mulberry32(seed) {
let a = seed >>> 0;
const next = () => {
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;
};
return {
next,
int: (min, max) => min + Math.floor(next() * (max - min + 1)),
pick: (arr) => arr[Math.floor(next() * arr.length)],
chance: (p) => next() < p,
};
}
const juego = mulberry32(99);
const otra = mulberry32(99);
otra.next(); // someone asked for ONE extra number (the music, say)
console.log("fruta 1:", juego.int(0, 15), "vs", otra.int(0, 15));
console.log("fruta 2:", juego.int(0, 15), "vs", otra.int(0, 15));
console.log("fruta 3:", juego.int(0, 15), "vs", otra.int(0, 15));
A single extra next() and every following fruit changes. If the music shared the game's series, changing a song would move the fruit and break saved games.