Ir al contenido
Ayotl

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

El servidor no se cree tu puntaje

✕
1/9

Un número se cambia en la consola

La forma ingenua de guardar un récord es que el teléfono mande { puntaje: 340 }. El problema: cualquiera abre las herramientas del navegador y manda { puntaje: 999999 }. El servidor no tiene cómo saber si es verdad.

Daily Challenge manda otra cosa: el registro de botones de la partida, uno por tick. Cada tick es una máscara de bits entre 0 y 31 (unidad 3), y para que viaje como texto corto se escribe un carácter por tick: el de código 48 + máscara. Así, 0 es '0', arriba es '1', derecha es '8' y el botón A (16) es '@'. Una partida de dos minutos son 3600 caracteres.

leccion.js

$ node leccion.js

Pulsa CORRER (o Ctrl+Enter).

Qué mirar

  • RIGHT | A es 8 + 16 = 24, y su carácter es 'H' (48 + 24 = 72).
  • Del 48 al 79 sólo hay números, letras mayúsculas y unos pocos signos (: ; < = > ? @): nada que se rompa al viajar en JSON.
  • Decodificar devuelve exactamente el arreglo original.

La nota: el cliente nunca manda un número →

Toda la lección en una página

Un número se cambia en la consola

La forma ingenua de guardar un récord es que el teléfono mande { puntaje: 340 }. El problema: cualquiera abre las herramientas del navegador y manda { puntaje: 999999 }. El servidor no tiene cómo saber si es verdad.

Daily Challenge manda otra cosa: el registro de botones de la partida, uno por tick. Cada tick es una máscara de bits entre 0 y 31 (unidad 3), y para que viaje como texto corto se escribe un carácter por tick: el de código 48 + máscara. Así, 0 es '0', arriba es '1', derecha es '8' y el botón A (16) es '@'. Una partida de dos minutos son 3600 caracteres.

const BTN = { UP: 1, DOWN: 2, LEFT: 4, RIGHT: 8, A: 16 };

// De net.js: un carácter por tick
const codificar = (log) => log.map((m) => String.fromCharCode(48 + m)).join("");
// De /api/score: de vuelta a números
const decodificar = (texto) => Array.from(texto, (ch) => ch.charCodeAt(0) - 48);

const log = [0, 0, BTN.RIGHT, BTN.RIGHT, BTN.UP, BTN.A, BTN.RIGHT | BTN.A, 0];
const texto = codificar(log);
console.log("máscaras:", log);
console.log("texto:   ", texto);
console.log("de vuelta:", decodificar(texto));
console.log("dos minutos a 30 ticks/s:", 30 * 120, "caracteres");
  • RIGHT | A es 8 + 16 = 24, y su carácter es 'H' (48 + 24 = 72).
  • Del 48 al 79 sólo hay números, letras mayúsculas y unos pocos signos (: ; < = > ? @): nada que se rompa al viajar en JSON.
  • Decodificar devuelve exactamente el arreglo original.

Volver a jugar la partida

Con el registro, el servidor hace lo que haría el teléfono: crea el juego con la misma semilla y le pasa los botones uno por uno. Como la simulación es determinista (unidad 5), llega al mismo final y al mismo puntaje. Ésta es verifyRun, de public/arcade/verify.js, casi tal cual:

for (const mask of log) { if (g.over) break; g.update(mask); }

Y el detalle que lo hace funcionar: el servidor no tiene una copia del juego, importa el mismo archivo games/snake.js que corre en tu navegador. Si fueran dos programas, tarde o temprano dirían cosas distintas.

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

const BTN = { UP: 1, DOWN: 2, LEFT: 4, RIGHT: 8, A: 16 };

// Un juego de juguete: un punto en una fila de 10 casillas que junta monedas
class Monedas {
  constructor(seed) {
    this.rng = mulberry32(seed);
    this.x = 0; this.moneda = this.rng.int(1, 9);
    this.score = 0; this.tick = 0; this.over = false;
  }
  update(mask) {
    if (this.over) return;
    this.tick++;
    if (mask & BTN.RIGHT) this.x = Math.min(9, this.x + 1);
    if (mask & BTN.LEFT) this.x = Math.max(0, this.x - 1);
    if (this.x === this.moneda) { this.score += 10; this.moneda = this.rng.int(0, 9); }
    if (this.tick >= 60) this.over = true;
  }
}

// verifyRun de verify.js, con un solo juego
function verifyRun(seed, log) {
  const g = new Monedas(seed);
  let ticks = 0;
  for (const mask of log) {
    if (g.over) break;
    g.update(mask);
    ticks++;
  }
  return { score: g.score, over: g.over, ticks };
}

// EN EL TELÉFONO: alguien juega y se guardan sus botones
const semilla = 20261023;
const partida = new Monedas(semilla);
const log = [];
while (!partida.over) {
  const mask = partida.moneda > partida.x ? BTN.RIGHT : partida.moneda < partida.x ? BTN.LEFT : 0;
  log.push(mask);
  partida.update(mask);
}
console.log("el teléfono vio:", partida.score, "puntos");
console.log("y dice que sacó:", 999999);

// EN EL SERVIDOR: no lee el número, vuelve a jugar
console.log("el servidor calcula:", verifyRun(semilla, log));

El 999999 nunca entra en la cuenta: el servidor llega solo al mismo puntaje que vio el teléfono. Para inflarlo habría que mandar otros botones, y entonces es otra partida que también tendría que salir bien jugando.

Qué revisa la puerta, y qué no puede saber

Antes de re-simular, la ruta /api/score revisa lo barato: que la fecha sea la de hoy, que el registro sea texto y no pase de 36 000 ticks (20 minutos), que cada carácter sea una máscara de 0 a 31 y que no hayas jugado ya ese día (el segundo intento sólo con un ticket).

Y hay que ser honesto con lo que esto no garantiza: el servidor sabe que el puntaje sale de esos botones, pero no quién los apretó. Un programa que juegue solo y mande su registro pasaría la revisión. Lo que se acaba es la trampa barata de cambiar un número.

const MAX_TICKS = 30 * 60 * 20;   // 20 minutos

// Las revisiones de /api/score sobre el log, antes de re-simular
function revisar(texto) {
  if (typeof texto !== "string" || !texto.length || texto.length > MAX_TICKS) return "log inválido";
  const log = Array.from(texto, (ch) => ch.charCodeAt(0) - 48);
  if (log.some((m) => m < 0 || m > 31)) return "log inválido";
  return "pasa a re-simular (" + log.length + " ticks)";
}

console.log(revisar("0088@H0"));
console.log(revisar(""));
console.log(revisar("00a0"));            // 'a' es 97: 97 - 48 = 49, no es máscara
console.log(revisar(12345));             // un número no es un log
console.log(revisar("0".repeat(MAX_TICKS + 1)));

Sólo el primero pasa. Revisar el formato antes de simular es barato y deja fuera la basura sin gastar tiempo de servidor en ella.

Cambiar el juego sin romper las partidas viejas

Si el servidor re-simula, cambiar las reglas es delicado: el 21 de octubre de 2026 Snake ganó el bicho de premio y el hambre (30 s sin comer y se acaba). Con las reglas nuevas, una partida del día 15 ya no daría el mismo puntaje, y el fantasma y la auditoría se descompondrían.

La solución está en games/legado/: la versión anterior se congela en snake-1.js y LEGADO dice hasta qué fecha vale. claseDe(id, fecha) elige la clase según el día de la partida:

if (fecha) for (const [hasta, Clase] of LEGADO[id] ?? []) if (fecha < hasta) return Clase;

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

const BTN = { UP: 1, DOWN: 2, LEFT: 4, RIGHT: 8, A: 16 };

// Un juego de juguete: un punto en una fila de 10 casillas que junta monedas
class Monedas {
  constructor(seed) {
    this.rng = mulberry32(seed);
    this.x = 0; this.moneda = this.rng.int(1, 9);
    this.score = 0; this.tick = 0; this.over = false;
  }
  update(mask) {
    if (this.over) return;
    this.tick++;
    if (mask & BTN.RIGHT) this.x = Math.min(9, this.x + 1);
    if (mask & BTN.LEFT) this.x = Math.max(0, this.x - 1);
    if (this.x === this.moneda) { this.score += 10; this.moneda = this.rng.int(0, 9); }
    if (this.tick >= 60) this.over = true;
  }
}

// Versión nueva: las monedas valen 15
class Monedas2 extends Monedas {
  update(mask) {
    const antes = this.score;
    super.update(mask);
    if (this.score > antes) this.score += 5;
  }
}

const GAMES = { monedas: Monedas2 };
const LEGADO = { monedas: [["2026-10-21", Monedas]] };   // antes del 21, la vieja

function claseDe(id, fecha) {
  if (fecha) for (const [hasta, Clase] of LEGADO[id] ?? []) if (fecha < hasta) return Clase;
  return GAMES[id];
}

const log = Array(60).fill(BTN.RIGHT);
for (const fecha of ["2026-10-15", "2026-10-21", "2026-10-23", undefined]) {
  const g = new (claseDe("monedas", fecha))(7);
  for (const m of log) g.update(m);
  console.log(String(fecha).padEnd(10), "->", g.constructor.name, g.score);
}
  • El mismo registro da 10 por moneda si la partida es del 15 y 15 por moneda desde el 21.
  • La fecha de corte es posterior al día del despliegue: el día en que se sube el cambio se sigue jugando con lo viejo, en el teléfono y en el servidor.
  • Sin fecha (práctica libre, party) va la versión vigente.