Skip to content
Ayotl

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

The server doesn't trust your score

✕
1/9

A number can be changed in the console

The naive way to save a high score is for the phone to send { puntaje: 340 }. The problem: anyone can open the browser's tools and send { puntaje: 999999 }. The server has no way to know if it's true.

Daily Challenge sends something else: the button log of the game, one entry per tick. Each tick is a bitmask between 0 and 31 (unit 3), and to travel as short text it's written as one character per tick: the one with code 48 + mask. So 0 is '0', up is '1', right is '8' and the A button (16) is '@'. A two-minute game is 3,600 characters.

leccion.js

$ node leccion.js

Press RUN (or Ctrl+Enter).

What to look at

  • RIGHT | A is 8 + 16 = 24, and its character is 'H' (48 + 24 = 72).
  • From 48 to 79 there are only digits, capital letters and a few symbols (: ; < = > ? @): nothing that breaks when sent as JSON.
  • Decoding gives back exactly the original array.

The note: the client never sends a number →

The whole lesson on one page

A number can be changed in the console

The naive way to save a high score is for the phone to send { puntaje: 340 }. The problem: anyone can open the browser's tools and send { puntaje: 999999 }. The server has no way to know if it's true.

Daily Challenge sends something else: the button log of the game, one entry per tick. Each tick is a bitmask between 0 and 31 (unit 3), and to travel as short text it's written as one character per tick: the one with code 48 + mask. So 0 is '0', up is '1', right is '8' and the A button (16) is '@'. A two-minute game is 3,600 characters.

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

// From net.js: one character per tick
const codificar = (log) => log.map((m) => String.fromCharCode(48 + m)).join("");
// From /api/score: back to numbers
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("masks:", log);
console.log("texto:   ", texto);
console.log("back again:", decodificar(texto));
console.log("two minutes at 30 ticks/s:", 30 * 120, "characters");
  • RIGHT | A is 8 + 16 = 24, and its character is 'H' (48 + 24 = 72).
  • From 48 to 79 there are only digits, capital letters and a few symbols (: ; < = > ? @): nothing that breaks when sent as JSON.
  • Decoding gives back exactly the original array.

Replaying the game

With the log, the server does what the phone would do: it creates the game with the same seed and feeds it the buttons one by one. Since the simulation is deterministic (unit 5), it reaches the same ending and the same score. This is verifyRun, from public/arcade/verify.js, almost as is:

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

And the detail that makes it work: the server doesn't have a copy of the game, it imports the same file games/snake.js that runs in your browser. If they were two programs, sooner or later they'd disagree.

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

// A toy game: a dot on a row of 10 cells collecting coins
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 from verify.js, with a single game
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 };
}

// ON THE PHONE: someone plays and their buttons are recorded
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("the phone saw:", partida.score, "points");
console.log("and claims it got:", 999999);

// ON THE SERVER: it ignores the number and replays
console.log("the server computes:", verifyRun(semilla, log));

The 999999 never enters the calculation: the server reaches the same score the phone saw on its own. To inflate it you'd have to send other buttons, and then it's a different game that would also have to be played well.

What the gate checks, and what it can't know

Before re-simulating, the /api/score route checks the cheap stuff: that the date is today, that the log is text and no longer than 36,000 ticks (20 minutes), that each character is a mask from 0 to 31, and that you haven't already played that day (a second try only with a ticket).

And it's worth being honest about what this does not guarantee: the server knows the score comes from those buttons, but not who pressed them. A program that plays by itself and sends its log would pass. What ends is the cheap trick of changing a number.

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

// /api/score's checks on the log, before re-simulating
function revisar(texto) {
  if (typeof texto !== "string" || !texto.length || texto.length > MAX_TICKS) return "invalid log";
  const log = Array.from(texto, (ch) => ch.charCodeAt(0) - 48);
  if (log.some((m) => m < 0 || m > 31)) return "invalid log";
  return "goes on to re-simulate (" + log.length + " ticks)";
}

console.log(revisar("0088@H0"));
console.log(revisar(""));
console.log(revisar("00a0"));            // 'a' is 97: 97 - 48 = 49, not a mask
console.log(revisar(12345));             // a number is not a log
console.log(revisar("0".repeat(MAX_TICKS + 1)));

Only the first one passes. Checking the format before simulating is cheap and keeps junk out without spending server time on it.

Changing the game without breaking old games

If the server re-simulates, changing the rules is delicate: on 21 October 2026 Snake gained the bonus bug and hunger (30 s without eating and it's over). Under the new rules, a game from the 15th would no longer give the same score, and the ghost and the audit would break.

The fix lives in games/legado/: the previous version is frozen in snake-1.js and LEGADO says until which date it applies. claseDe(id, fecha) picks the class according to the game's day:

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

// A toy game: a dot on a row of 10 cells collecting coins
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;
  }
}

// New version: coins are worth 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]] };   // before the 21st, the old one

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);
}
  • The same log gives 10 per coin if the game is from the 15th and 15 per coin from the 21st.
  • The cutoff date is after the deploy day: on the day the change goes up, people keep playing the old rules, on the phone and on the server.
  • With no date (free practice, party) the current version runs.