← Así se hace un juego: Snake de Daily Challenge · Lección 13/16
El servidor no se cree tu puntaje
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.
$ node leccion.js
Pulsa CORRER (o Ctrl+Enter).
Qué mirar
RIGHT | Aes 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.
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 | Aes 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.