Ir al contenido
Ayotl

← Así se hace un juego: Snake de Daily Challenge · Lección 10/16

El mismo azar para todos

✕
1/10

Math.random no se puede repetir

En Snake la fruta sale en una casilla libre al azar. Si ese azar fuera Math.random(), cada quien vería la fruta en otro lado y el reto del día ya no sería el mismo para todos. Peor: el servidor, que vuelve a jugar tu partida para revisar el puntaje (lo verás en la unidad 7), sacaría otras frutas y otro resultado.

La salida es un generador pseudoaleatorio: una función que, a partir de un número inicial, la semilla, da una serie que parece azar pero es siempre la misma. Daily Challenge usa mulberry32: diez renglones, sin librerías. Corre el programa dos veces: Math.random cambia; las dos series de la semilla 42 no.

leccion.js

$ node leccion.js

Pulsa CORRER (o Ctrl+Enter).

Qué mirar

  • Los de Math.random salen distintos cada vez que pulsas CORRER.
  • a y b dan exactamente los mismos tres números: misma semilla, misma serie.
  • Cada next() devuelve un número entre 0 (incluido) y 1 (sin incluir), como Math.random.

Generador de números pseudoaleatorios (Wikipedia) ↗

Toda la lección en una página

Math.random no se puede repetir

En Snake la fruta sale en una casilla libre al azar. Si ese azar fuera Math.random(), cada quien vería la fruta en otro lado y el reto del día ya no sería el mismo para todos. Peor: el servidor, que vuelve a jugar tu partida para revisar el puntaje (lo verás en la unidad 7), sacaría otras frutas y otro resultado.

La salida es un generador pseudoaleatorio: una función que, a partir de un número inicial, la semilla, da una serie que parece azar pero es siempre la misma. Daily Challenge usa mulberry32: diez renglones, sin librerías. Corre el programa dos veces: Math.random cambia; las dos series de la semilla 42 no.

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);   // otra serie, misma semilla
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));
  • Los de Math.random salen distintos cada vez que pulsas CORRER.
  • a y b dan exactamente los mismos tres números: misma semilla, misma serie.
  • Cada next() devuelve un número entre 0 (incluido) y 1 (sin incluir), como Math.random.

La fecha se vuelve semilla

¿De dónde sale la semilla del día? De la fecha. hashString convierte un texto como "2026-10-23" en un entero de 32 bits con FNV-1a: por cada letra, mezcla su código con ^ (o exclusivo) y multiplica por un primo. Fechas seguidas sólo cambian en la última letra y dan semillas cercanas, pero no importa: mulberry32 las revuelve y sus series no se parecen en nada.

De esa semilla salen dos series independientes con seeds(): una para el juego y otra para la música. Así, si un día la música pide más números al azar, la fruta no se mueve de lugar.

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;
}

// De public/arcade/core/rng.js: dos semillas que no se pisan
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, "| juego:", tres);
}
  • Las tres semillas se parecen (sólo cambió la última letra), pero las series del juego que salen de ellas no tienen nada que ver: el generador hace el revoltijo.
  • >>> 0 deja el número como entero sin signo de 32 bits; sin él, la mitad saldrían negativos.
  • El servidor calcula lo mismo con la misma función (src/lib/reto.ts hace hashString(fecha)), así que nadie tiene que mandarle la semilla.

pick, int y chance

Sobre next() hay tres ayudas: int(min, max) da un entero entre los dos (incluidos), pick(arr) elige un elemento y chance(p) es «sí» con probabilidad p.

Snake usa pick: junta las casillas libres en un arreglo y elige una (randomEmpty en games/snake.js). Aquí está esa idea en un tablero de 4×3. Corre el programa: con la misma semilla las tres frutas caen siempre en las mismas casillas, en el teléfono de cualquiera y en el servidor.

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 = [];   // casillas donde puede caer
  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));   // otra vez: igual
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) ? "águila" : "sol");

Las dos líneas de la semilla 7 son idénticas; la 8 cambia todo. Fíjate en que el juego nunca guarda «la fruta salió en 2,1»: le basta con la semilla y con pedir los números en el mismo orden.

La regla: sólo la semilla y los botones

Para que una partida se pueda repetir, la simulación sólo puede depender de dos cosas: la semilla y los botones de cada tick. Nada de Math.random, ni de la hora (Date.now), ni del tamaño de la pantalla dentro de update.

Fuera de la simulación sí se vale: el ruido blanco de la percusión se llena con Math.random (core/audio.js) porque sólo suena; no mueve ni una casilla. Y ojo con el orden: pedir un número de más en un lugar corre toda la serie. Por eso el juego y la música tienen series separadas.

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();   // alguien pidió UN número de más (por ejemplo, la música)

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));

Un solo next() de más y todas las frutas siguientes cambian. Si la música compartiera la serie del juego, cambiar una canción movería las frutas y rompería las partidas guardadas.