← Así se hace un juego: Snake de Daily Challenge · Lección 16/16
Lockstep: todos ven la misma partida
Lo ingenuo: cada quien aplica lo que le llega
Imagina dos teléfonos. Cada uno aplica sus botones al instante y los del otro cuando llegan. El problema es que llegan tarde, y no igual de tarde: el teléfono A aplica un botón de B dos ticks después de que B lo apretó, y B aplica el de A tres ticks después. Cada uno simula una partida ligeramente distinta y, como en Snake un tick de diferencia es chocar o no chocar, al rato son dos partidas distintas.
Corre el programa: el mismo juego, los mismos botones, sólo cambia cuándo se aplican.
$ node leccion.js
Pulsa CORRER (o Ctrl+Enter).
Qué mirar
La huella es tick | posiciones | puntos. Las dos salen distintas: cada teléfono cree en una partida diferente, y ninguno está mal según lo que vio.
Toda la lección en una página
Lo ingenuo: cada quien aplica lo que le llega
Imagina dos teléfonos. Cada uno aplica sus botones al instante y los del otro cuando llegan. El problema es que llegan tarde, y no igual de tarde: el teléfono A aplica un botón de B dos ticks después de que B lo apretó, y B aplica el de A tres ticks después. Cada uno simula una partida ligeramente distinta y, como en Snake un tick de diferencia es chocar o no chocar, al rato son dos partidas distintas.
Corre el programa: el mismo juego, los mismos botones, sólo cambia cuándo se aplican.
class Juego {
constructor(n) { this.pos = Array(n).fill(0); this.puntos = Array(n).fill(0); this.tick = 0; }
update(masks) {
this.tick++;
masks.forEach((m, i) => {
if (m & 8) this.pos[i]++; // derecha
if (m & 4) this.pos[i]--; // izquierda
if (m & 16 && this.pos[i] % 3 === 0) this.puntos[i] += 10; // A sobre una moneda
});
}
huella() { return this.tick + "|" + this.pos.join(",") + "|" + this.puntos.join(","); }
}
// Lo que aprieta cada jugador en cada tick (una regla fija, para poder repetirlo)
const aprieta = (jugador, tick) => [8, 8, 16, 4, 0, 24][(tick * (jugador + 2) + jugador) % 6];
// Cada teléfono ve su botón al momento y el del otro con retraso
function teléfono(yo, retrasoDelOtro) {
const g = new Juego(2);
for (let t = 0; t < 30; t++) {
const masks = [0, 0];
masks[yo] = aprieta(yo, t);
const otro = 1 - yo;
if (t - retrasoDelOtro >= 0) masks[otro] = aprieta(otro, t - retrasoDelOtro);
g.update(masks);
}
return g.huella();
}
console.log("teléfono A:", teléfono(0, 2));
console.log("teléfono B:", teléfono(1, 3));
La huella es tick | posiciones | puntos. Las dos salen distintas: cada teléfono cree en una partida diferente, y ninguno está mal según lo que vio.
Turnos del anfitrión
La solución de versus.js se llama lockstep (paso al unísono). Uno de los teléfonos es el anfitrión. Los demás le mandan sus botones; el anfitrión, cada turno de 6 ticks (200 ms), arma un paquete { k, masks } con un input por jugador y lo manda a todos.
La regla de oro: nadie simula con otra cosa, ni siquiera el anfitrión con sus propios botones. Todos esperan el turno k, lo aplican durante 6 ticks y pasan al k + 1. Si los paquetes llegan a destiempo, da igual: se aplican en orden. Mismos paquetes, mismo orden, mismo juego.
El tubo es un canal de Supabase Realtime por ronda (party:CODIGO:r0), con mensajes *broadcast*: pasan de teléfono a teléfono sin tocar la base de datos.
class Juego {
constructor(n) { this.pos = Array(n).fill(0); this.puntos = Array(n).fill(0); this.tick = 0; }
update(masks) {
this.tick++;
masks.forEach((m, i) => {
if (m & 8) this.pos[i]++; // derecha
if (m & 4) this.pos[i]--; // izquierda
if (m & 16 && this.pos[i] % 3 === 0) this.puntos[i] += 10; // A sobre una moneda
});
}
huella() { return this.tick + "|" + this.pos.join(",") + "|" + this.puntos.join(","); }
}
// Lo que aprieta cada jugador en cada tick (una regla fija, para poder repetirlo)
const aprieta = (jugador, tick) => [8, 8, 16, 4, 0, 24][(tick * (jugador + 2) + jugador) % 6];
const TURN = 6;
// Un canal de mentira con retraso: cada mensaje llega a cada teléfono en otro momento
function mulberry32(a) { return () => { 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; }; }
const azar = mulberry32(3);
const telefonos = [0, 1, 2].map((i) => ({ i, juego: new Juego(3), turnos: [], siguiente: 0, buzon: [] }));
for (let k = 0; k < 10; k++) {
// el anfitrión (teléfono 0) compone el turno k con lo último de cada jugador
const turno = { k, masks: telefonos.map((t) => aprieta(t.i, k)) };
// lo manda a todos, él incluido; cada copia llega con 0 a 3 turnos de retraso
for (const t of telefonos) t.buzon.push({ llega: k + Math.floor(azar() * 4), turno });
for (const t of telefonos) {
for (const m of t.buzon.filter((m) => m.llega <= k)) t.turnos[m.turno.k] = m.turno;
t.buzon = t.buzon.filter((m) => m.llega > k);
// sólo se avanza con el turno que sigue, en orden
while (t.turnos[t.siguiente]) {
const { masks } = t.turnos[t.siguiente++];
for (let i = 0; i < TURN; i++) t.juego.update(masks);
}
}
}
// al final llega todo lo que faltaba
for (const t of telefonos) {
for (const m of t.buzon) t.turnos[m.turno.k] = m.turno;
while (t.turnos[t.siguiente]) { const { masks } = t.turnos[t.siguiente++]; for (let i = 0; i < TURN; i++) t.juego.update(masks); }
console.log("teléfono", t.i, t.juego.huella());
}
Los tres teléfonos recibieron los turnos en momentos distintos y aun así terminan con la misma huella: 60 ticks, mismas posiciones, mismos puntos. Ninguno simuló un tick sin tener el turno que le tocaba.
Paquetes perdidos y gente que se va
En una red de verdad, a veces un mensaje no llega. Si a un teléfono le llega el turno 4 sin haber tenido el 3, sabe que le falta uno: manda 'need' con from: 3 y el anfitrión reenvía desde su historial todos los turnos desde ahí. Mientras tanto ese teléfono espera: prefiere ir atrasado a ir equivocado.
Otros casos que versus.js cubre:
- Quien lleva más de 1.5 s sin mandar botones (cerró la app) se queda quieto: el anfitrión pone
0en su lugar. - Si el creador de la party no aparece en 6 s, el anfitrión es el primer presente de la lista, y el elegido queda fijo al arrancar (
hostFijo). - Si los turnos dejan de llegar por 6 s, la ronda se da por terminada con lo que haya.
class Juego {
constructor(n) { this.pos = Array(n).fill(0); this.puntos = Array(n).fill(0); this.tick = 0; }
update(masks) {
this.tick++;
masks.forEach((m, i) => {
if (m & 8) this.pos[i]++; // derecha
if (m & 4) this.pos[i]--; // izquierda
if (m & 16 && this.pos[i] % 3 === 0) this.puntos[i] += 10; // A sobre una moneda
});
}
huella() { return this.tick + "|" + this.pos.join(",") + "|" + this.puntos.join(","); }
}
// Lo que aprieta cada jugador en cada tick (una regla fija, para poder repetirlo)
const aprieta = (jugador, tick) => [8, 8, 16, 4, 0, 24][(tick * (jugador + 2) + jugador) % 6];
const TURN = 6;
const historial = []; // lo que guarda el anfitrión
const turnos = []; // lo que tiene el invitado
let siguiente = 0;
const juego = new Juego(2);
function recibirTurno(t) { // recibirTurno() de versus.js, resumido
turnos[t.k] = t;
if (t.k > siguiente && !turnos[siguiente]) {
console.log(" falta el", siguiente, "-> need from", siguiente);
for (const v of historial.slice(siguiente)) turnos[v.k] = v; // el anfitrión reenvía
}
}
for (let k = 0; k < 6; k++) {
const t = { k, masks: [aprieta(0, k), aprieta(1, k)] };
historial[k] = t;
if (k === 3) { console.log("turno 3: se perdió en el camino"); continue; }
console.log("turno", k, "llega");
recibirTurno(t);
while (turnos[siguiente]) { const { masks } = turnos[siguiente++]; for (let i = 0; i < TURN; i++) juego.update(masks); }
}
// El anfitrión simuló todo sin perder nada
const anfitrion = new Juego(2);
for (const { masks } of historial) for (let i = 0; i < TURN; i++) anfitrion.update(masks);
console.log("invitado: ", juego.huella());
console.log("anfitrión:", anfitrion.huella());
Al llegar el 4 sin el 3, el invitado pide lo que le falta y se pone al día. Sus huellas y las del anfitrión coinciden: el turno perdido no dejó rastro en la partida, sólo un poco de retraso.
El precio, y el registro para el servidor
Lockstep tiene un costo que se siente: entre que aprietas y que tu serpiente gira pasan 200 a 400 ms, lo que tarda tu botón en llegar al anfitrión, en entrar al siguiente turno y en volver. A cambio, nadie puede ver una partida distinta, y Realtime sólo transporta unos cuantos bytes por turno.
Al terminar, el anfitrión tiene todos los turnos. log() los despliega en un arreglo de máscaras por tick (cada turno vale 6 ticks) y eso es lo que se manda al servidor, que lo re-simula con verifyVersus para repartir los puntos: la misma idea de la unidad 7.
Kazan, el juego de estrategia de la casa, lleva esto mismo a 50 jugadores en una isla, con órdenes en vez de botones.
const TURN = 6;
const turnos = [
{ k: 0, masks: [8, 0] },
{ k: 1, masks: [8, 1] },
{ k: 2, masks: [2, 1] },
];
// log() de versus.js: un input por jugador por tick
function log(turnos, turnoFin) {
const out = [];
for (let k = 0; k < turnoFin && turnos[k]; k++)
for (let i = 0; i < TURN; i++) out.push(turnos[k].masks);
return out;
}
const registro = log(turnos, turnos.length);
console.log("ticks:", registro.length);
console.log("primeros 8:", JSON.stringify(registro.slice(0, 8)));
console.log("retraso de un turno:", (TURN * 1000 / 30).toFixed(0), "ms");
Tres turnos son 18 ticks: cada paquete se repite seis veces seguidas. El turno dura 200 ms; con lo que tarda la red en ir y volver, el retraso que se siente queda entre 200 y 400 ms.