← Así se hace un juego: Snake de Daily Challenge · Lección 14/16
El fantasma: ver jugar a otro sin vídeo
Mandar botones, no imágenes
En el modo party, mientras esperas tu turno, ves en vivo la partida de otro: la cámara fantasma. No viaja ni un fotograma. El que juega manda sus botones por un canal de Supabase Realtime (mensajes *broadcast*, sin base de datos) y tu teléfono corre la misma simulación con la misma semilla y el mismo mapa. Lo que ves es tu propia copia de su partida.
El Emisor de ghost.js junta los botones y cada 15 ticks (medio segundo) manda los nuevos como texto, con desde: la posición del primero. El Espectador los acomoda en su copia del registro y avanza.
$ node leccion.js
Pulsa CORRER (o Ctrl+Enter).
Qué mirar
Cuatro paquetes de 15 caracteres y la copia termina exactamente donde el jugador: misma posición, mismo puntaje. La copia va medio segundo atrás porque los botones llegan por tandas.
Toda la lección en una página
Mandar botones, no imágenes
En el modo party, mientras esperas tu turno, ves en vivo la partida de otro: la cámara fantasma. No viaja ni un fotograma. El que juega manda sus botones por un canal de Supabase Realtime (mensajes *broadcast*, sin base de datos) y tu teléfono corre la misma simulación con la misma semilla y el mismo mapa. Lo que ves es tu propia copia de su partida.
El Emisor de ghost.js junta los botones y cada 15 ticks (medio segundo) manda los nuevos como texto, con desde: la posición del primero. El Espectador los acomoda en su copia del registro y avanza.
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;
}
}
const canal = []; // un canal de mentira: lo que se manda queda aquí
const CADA = 15;
// El que juega (Emisor de ghost.js, resumido)
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;
}
// El que mira (Espectador): su propia copia del juego
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();
// llega lo que haya en el canal
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("paquetes de", CADA, "ticks:", log.length / CADA);
console.log("jugador:", { x: jugador.x, score: jugador.score });
console.log("copia: ", { x: copia.x, score: copia.score });
Cuatro paquetes de 15 caracteres y la copia termina exactamente donde el jugador: misma posición, mismo puntaje. La copia va medio segundo atrás porque los botones llegan por tandas.
Llegar tarde y alcanzar
¿Y si entras a mirar cuando el otro ya lleva un minuto? El espectador manda un aviso 'quien' al canal y el emisor contesta reenviando todo su registro desde el tick 0.
Con 1800 botones de golpe, la copia va muy atrás. En vez de saltar al final (y perderte la jugada), el espectador simula 3 ticks por cada tick mientras el atraso pase de 45, y 1 cuando ya casi alcanzó. Ves la partida a triple velocidad unos segundos, hasta quedar al día.
// Espectador.tick() de ghost.js: cuántos ticks simular según el atraso
let recibido = 1800; // llegó el registro entero: un minuto
let simulado = 0;
for (let tick = 1; simulado < recibido; tick++) {
recibido++; // el otro sigue jugando: un botón nuevo por 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, "atraso", atraso, "simula", pasos);
if (atraso <= 46) break;
}
}
Como el otro sigue jugando, cada tick el espectador gana 2 (simula 3, llega 1). Un minuto de atraso se recupera en unos 30 segundos, y a partir de ahí va al paso, un tick por tick.
Lo que cuesta: casi nada
Un byte por tick son 30 bytes por segundo. Una partida entera de tres minutos son 5.4 KB, menos que una foto miniatura. Transmitir vídeo de esa misma pantalla, aun pequeño, serían megas. Y el servidor no hace nada: Realtime sólo pasa los mensajes de un teléfono a otro.
La misma idea da el fantasma del líder: al acabar tu partida del día puedes ver la mejor partida, re-simulada en tu pantalla desde su registro. El servidor sólo la entrega a quien ya no puede puntuar ese día, para que nadie copie la jugada antes de jugar.
const TICKS_POR_SEGUNDO = 30;
const segundos = 3 * 60;
const bytes = TICKS_POR_SEGUNDO * segundos; // un carácter por tick
console.log("registro de 3 minutos:", bytes, "bytes =", (bytes / 1000).toFixed(1), "KB");
// Para comparar: la pantalla del juego es de 160 x 192 píxeles
const pixeles = 160 * 192;
console.log("un solo fotograma sin comprimir, 1 byte por píxel:", pixeles, "bytes");
console.log("o sea, un fotograma pesa como", Math.round(pixeles / TICKS_POR_SEGUNDO), "segundos de botones");
Un solo fotograma sin comprimir pesa lo que más de quince minutos de botones. El truco sólo funciona porque los dos teléfonos tienen el juego y la semilla: lo que viaja es lo único que no pueden adivinar.