Skip to content
Ayotl

← How a game is made: Daily Challenge's Snake · Lesson 10/16

The same luck for everyone

✕
1/10

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.

leccion.js

$ node leccion.js

Press RUN (or Ctrl+Enter).

What to look at

  • The Math.random ones change every time you press RUN.
  • a and b give exactly the same three numbers: same seed, same series.
  • Each next() returns a number between 0 (included) and 1 (excluded), just like Math.random.

Pseudorandom number generator (Wikipedia, Spanish) ↗

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.random ones change every time you press RUN.
  • a and b give exactly the same three numbers: same seed, same series.
  • Each next() returns a number between 0 (included) and 1 (excluded), just like Math.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.
  • >>> 0 keeps 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.ts does hashString(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.