Skip to content
Ayotl

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

The ghost: watching someone play without video

✕
1/7

Send buttons, not pictures

In party mode, while you wait your turn, you watch someone else's game live: the ghost camera. Not a single frame travels. The player sends their buttons over a Supabase Realtime channel (*broadcast* messages, no database) and your phone runs the same simulation with the same seed and map. What you see is your own copy of their game.

ghost.js's Emisor collects the buttons and every 15 ticks (half a second) sends the new ones as text, with desde: the position of the first one. The Espectador slots them into its copy of the log and moves forward.

leccion.js

$ node leccion.js

Press RUN (or Ctrl+Enter).

What to look at

Four 15-character packets and the copy ends exactly where the player did: same position, same score. The copy runs half a second behind because the buttons arrive in batches.

Daily Challenge's party mode ↗

The whole lesson on one page

Send buttons, not pictures

In party mode, while you wait your turn, you watch someone else's game live: the ghost camera. Not a single frame travels. The player sends their buttons over a Supabase Realtime channel (*broadcast* messages, no database) and your phone runs the same simulation with the same seed and map. What you see is your own copy of their game.

ghost.js's Emisor collects the buttons and every 15 ticks (half a second) sends the new ones as text, with desde: the position of the first one. The Espectador slots them into its copy of the log and moves forward.

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

const canal = [];   // a fake channel: whatever is sent lands here
const CADA = 15;

// The player (Emisor from ghost.js, summarized)
const jugador = new Monedas(99);
const log = [];
let enviado = 0;
function enviar() {
  const masks = log.slice(enviado).map((m) => String.fromCharCode(48 + m)).join("");
  canal.push({ desde: enviado, masks });
  enviado = log.length;
}

// The viewer (Espectador): its own copy of the game
const copia = new Monedas(99);
const suLog = [];
let simulado = 0;

for (let tick = 0; tick < 60; tick++) {
  const m = tick % 7 < 4 ? BTN.RIGHT : BTN.LEFT;
  log.push(m); jugador.update(m);
  if (log.length % CADA === 0) enviar();

  // whatever is in the channel arrives
  while (canal.length) {
    const p = canal.shift();
    for (let i = 0; i < p.masks.length; i++) suLog[p.desde + i] = p.masks.charCodeAt(i) - 48;
  }
  if (simulado < suLog.length) copia.update(suLog[simulado++]);
}
while (simulado < suLog.length) copia.update(suLog[simulado++]);

console.log("packets of", CADA, "ticks:", log.length / CADA);
console.log("player:", { x: jugador.x, score: jugador.score });
console.log("copy:   ", { x: copia.x, score: copia.score });

Four 15-character packets and the copy ends exactly where the player did: same position, same score. The copy runs half a second behind because the buttons arrive in batches.

Arriving late and catching up

What if you start watching when the other player is already a minute in? The viewer sends a 'quien' ("who's there") message to the channel and the emitter answers by resending its whole log from tick 0.

With 1,800 buttons at once, the copy is far behind. Instead of jumping to the end (and missing the play), the viewer simulates 3 ticks per tick while the lag is over 45, and 1 once it's nearly caught up. You watch at triple speed for a few seconds until it's current.

// Espectador.tick() from ghost.js: how many ticks to simulate given the lag
let recibido = 1800;   // the whole log arrived: one minute
let simulado = 0;

for (let tick = 1; simulado < recibido; tick++) {
  recibido++;            // the other player keeps going: one new button per tick
  const atraso = recibido - simulado;
  const pasos = atraso > 45 ? 3 : atraso > 0 ? 1 : 0;
  simulado += pasos;
  if (tick % 150 === 0 || atraso <= 46) {
    console.log("tick", tick, "lag", atraso, "simulates", pasos);
    if (atraso <= 46) break;
  }
}

Since the other player keeps going, each tick the viewer gains 2 (simulates 3, receives 1). A minute of lag is recovered in about 30 seconds, and from then on it keeps pace, one tick per tick.

What it costs: almost nothing

One byte per tick is 30 bytes per second. A whole three-minute game is 5.4 KB, less than a thumbnail photo. Streaming video of that same screen, even small, would be megabytes. And the server does nothing: Realtime just passes messages from one phone to another.

The same idea gives you the leader's ghost: after your game of the day you can watch the best game, re-simulated on your screen from its log. The server only hands it to people who can no longer score that day, so nobody copies the play before playing.

const TICKS_POR_SEGUNDO = 30;
const segundos = 3 * 60;
const bytes = TICKS_POR_SEGUNDO * segundos;   // one character per tick
console.log("3-minute log:", bytes, "bytes =", (bytes / 1000).toFixed(1), "KB");

// For comparison: the game screen is 160 x 192 pixels
const pixeles = 160 * 192;
console.log("a single uncompressed frame, 1 byte per pixel:", pixeles, "bytes");
console.log("so one frame weighs as much as", Math.round(pixeles / TICKS_POR_SEGUNDO), "seconds of buttons");

A single uncompressed frame weighs as much as over fifteen minutes of buttons. The trick only works because both phones have the game and the seed: what travels is the only thing they can't guess.