← Así se hace un juego: Snake de Daily Challenge · Lección 15/16
Snake versus: una simulación, varios jugadores
Un arreglo de botones en vez de uno
En el Snake de un jugador, update(mask) recibe un número por tick. En Snake VS (games/snakevs.js, de 2 a 5 jugadores) recibe un arreglo: masks[0] son los botones del jugador 1, masks[1] los del 2, y así. El resto es la misma idea: una sola simulación, determinista, con todas las serpientes adentro.
Eso es lo que la hace jugable en línea: si cada teléfono recibe los mismos arreglos en el mismo orden, todos calculan el mismo tablero. La siguiente lección cuenta cómo se logra que lleguen iguales.
El programa usa una versión reducida de SnakeVs en un tablero de 10×5: A y B son las cabezas, a y b los cuerpos.
$ node leccion.js
Pulsa CORRER (o Ctrl+Enter).
Qué mirar
Cada renglón de jugada es un tick con dos máscaras, una por jugador. Las dos serpientes se movieron en los mismos ticks, cada una con sus botones: la A arrancó a la derecha, bajó dos y volvió a girar; la B arrancó a la izquierda y subió.
Toda la lección en una página
Un arreglo de botones en vez de uno
En el Snake de un jugador, update(mask) recibe un número por tick. En Snake VS (games/snakevs.js, de 2 a 5 jugadores) recibe un arreglo: masks[0] son los botones del jugador 1, masks[1] los del 2, y así. El resto es la misma idea: una sola simulación, determinista, con todas las serpientes adentro.
Eso es lo que la hace jugable en línea: si cada teléfono recibe los mismos arreglos en el mismo orden, todos calculan el mismo tablero. La siguiente lección cuenta cómo se logra que lleguen iguales.
El programa usa una versión reducida de SnakeVs en un tablero de 10×5: A y B son las cabezas, a y b los cuerpos.
const BTN = { UP: 1, DOWN: 2, LEFT: 4, RIGHT: 8, A: 16 };
const DIRS = [{ x: 0, y: -1 }, { x: 0, y: 1 }, { x: -1, y: 0 }, { x: 1, y: 0 }]; // arriba, abajo, izq., der.
const COLS = 10, ROWS = 5;
class MiniVs {
constructor(salidas) {
this.players = salidas.map((s, i) => ({ i, body: [{ x: s.x, y: s.y }], dir: s.dir, want: s.dir, vivo: true }));
this.tick = 0; this.over = false;
}
update(masks) {
if (this.over) return;
this.tick++;
for (const p of this.players) {
const m = masks[p.i] ?? 0; // un input por jugador
if (m & BTN.UP && p.dir !== 1) p.want = 0; else if (m & BTN.DOWN && p.dir !== 0) p.want = 1;
else if (m & BTN.LEFT && p.dir !== 3) p.want = 2; else if (m & BTN.RIGHT && p.dir !== 2) p.want = 3;
}
const vivos = this.players.filter((p) => p.vivo);
// 1) todas las cabezas nuevas, antes de mover a nadie
const nuevas = vivos.map((p) => { p.dir = p.want; const h = p.body[0]; return { p, x: h.x + DIRS[p.dir].x, y: h.y + DIRS[p.dir].y }; });
// 2) los choques, con todo el tablero todavía quieto
const muere = new Set();
for (const n of nuevas) {
const { p, x, y } = n;
if (x < 0 || y < 0 || x >= COLS || y >= ROWS) muere.add(p);
for (const q of vivos) if (q.body.some((b) => b.x === x && b.y === y)) muere.add(p);
for (const o of nuevas) if (o !== n && ((o.x === x && o.y === y) ||
(o.x === p.body[0].x && o.y === p.body[0].y && x === o.p.body[0].x && y === o.p.body[0].y))) muere.add(p);
}
// 3) mover a las que sobreviven (crecen siempre, para que se vea el rastro)
for (const n of nuevas) if (!muere.has(n.p)) n.p.body.unshift({ x: n.x, y: n.y });
for (const p of muere) p.vivo = false;
if (this.players.filter((p) => p.vivo).length <= 1) this.over = true;
}
dibujar() {
const filas = Array.from({ length: ROWS }, () => Array(COLS).fill("."));
for (const p of this.players) p.body.forEach((b, k) => { filas[b.y][b.x] = k === 0 ? (p.vivo ? "AB"[p.i] : "x") : "ab"[p.i]; });
return filas.map((f) => f.join("")).join("\n");
}
}
const g = new MiniVs([{ x: 0, y: 0, dir: 3 }, { x: 9, y: 4, dir: 2 }]);
const jugada = [
[0, 0],
[BTN.DOWN, 0], // el 1 baja; el 2 sigue
[0, BTN.UP], // el 2 sube
[BTN.RIGHT, 0],
];
for (const masks of jugada) g.update(masks);
console.log(g.dibujar());
console.log("tick", g.tick, "| vivos:", g.players.filter((p) => p.vivo).length);
Cada renglón de jugada es un tick con dos máscaras, una por jugador. Las dos serpientes se movieron en los mismos ticks, cada una con sus botones: la A arrancó a la derecha, bajó dos y volvió a girar; la B arrancó a la izquierda y subió.
Todas a la vez: primero las cabezas, luego los choques
Si el código moviera primero a la serpiente 1 y luego a la 2, la 1 tendría ventaja: en un choque de frente llegaría antes a la casilla. Por eso SnakeVs.update va en tres pasos:
- calcula todas las cabezas nuevas sin mover a nadie;
- revisa los choques con el tablero todavía quieto: muro, cualquier cuerpo, dos cabezas en la misma casilla, o dos cabezas que se cruzan (cada una va a donde estaba la otra);
- mueve sólo a las que sobrevivieron.
Así el resultado no depende del orden del arreglo. Si dos caen en el mismo tick, empatan en el puesto.
const BTN = { UP: 1, DOWN: 2, LEFT: 4, RIGHT: 8, A: 16 };
const DIRS = [{ x: 0, y: -1 }, { x: 0, y: 1 }, { x: -1, y: 0 }, { x: 1, y: 0 }]; // arriba, abajo, izq., der.
const COLS = 10, ROWS = 5;
class MiniVs {
constructor(salidas) {
this.players = salidas.map((s, i) => ({ i, body: [{ x: s.x, y: s.y }], dir: s.dir, want: s.dir, vivo: true }));
this.tick = 0; this.over = false;
}
update(masks) {
if (this.over) return;
this.tick++;
for (const p of this.players) {
const m = masks[p.i] ?? 0; // un input por jugador
if (m & BTN.UP && p.dir !== 1) p.want = 0; else if (m & BTN.DOWN && p.dir !== 0) p.want = 1;
else if (m & BTN.LEFT && p.dir !== 3) p.want = 2; else if (m & BTN.RIGHT && p.dir !== 2) p.want = 3;
}
const vivos = this.players.filter((p) => p.vivo);
// 1) todas las cabezas nuevas, antes de mover a nadie
const nuevas = vivos.map((p) => { p.dir = p.want; const h = p.body[0]; return { p, x: h.x + DIRS[p.dir].x, y: h.y + DIRS[p.dir].y }; });
// 2) los choques, con todo el tablero todavía quieto
const muere = new Set();
for (const n of nuevas) {
const { p, x, y } = n;
if (x < 0 || y < 0 || x >= COLS || y >= ROWS) muere.add(p);
for (const q of vivos) if (q.body.some((b) => b.x === x && b.y === y)) muere.add(p);
for (const o of nuevas) if (o !== n && ((o.x === x && o.y === y) ||
(o.x === p.body[0].x && o.y === p.body[0].y && x === o.p.body[0].x && y === o.p.body[0].y))) muere.add(p);
}
// 3) mover a las que sobreviven (crecen siempre, para que se vea el rastro)
for (const n of nuevas) if (!muere.has(n.p)) n.p.body.unshift({ x: n.x, y: n.y });
for (const p of muere) p.vivo = false;
if (this.players.filter((p) => p.vivo).length <= 1) this.over = true;
}
dibujar() {
const filas = Array.from({ length: ROWS }, () => Array(COLS).fill("."));
for (const p of this.players) p.body.forEach((b, k) => { filas[b.y][b.x] = k === 0 ? (p.vivo ? "AB"[p.i] : "x") : "ab"[p.i]; });
return filas.map((f) => f.join("")).join("\n");
}
}
// Caso 1: de frente, con una casilla en medio: las dos cabezas van a la misma
const g1 = new MiniVs([{ x: 3, y: 2, dir: 3 }, { x: 5, y: 2, dir: 2 }]);
g1.update([0, 0]);
console.log("misma casilla:");
console.log(g1.dibujar());
// Caso 2: pegadas de frente: cada una va a donde está la otra (se cruzan)
const g2 = new MiniVs([{ x: 4, y: 2, dir: 3 }, { x: 5, y: 2, dir: 2 }]);
g2.update([0, 0]);
console.log("se cruzan:");
console.log(g2.dibujar());
console.log("vivos:", g1.players.filter((p) => p.vivo).length, "y", g2.players.filter((p) => p.vivo).length);
En los dos casos mueren las dos (x). Sin la regla de «se cruzan», en el segundo caso las serpientes se atravesarían como fantasmas, porque ninguna cabeza nueva cae sobre la otra cabeza nueva.
Puntos por fruta y por puesto
Cada fruta vale 10 y hace crecer la serpiente. Al caer se te asigna un puesto: el último vivo es el 1.º. La partida acaba cuando queda una sola o a los 2 minutos; si se acaba el tiempo, gana la más larga. Y cada 15 segundos todas aceleran (el intervalo baja de 7 hasta 4 ticks por paso), para que nadie gane escondiéndose.
Los puntos del puesto salen de PUNTOS_PUESTO = [300, 200, 100, 50, 0]:
puntosDe(p) { return p.frutas * 10 + PUNTOS_PUESTO[Math.min(4, p.puesto - 1)]; }
const PUNTOS_PUESTO = [300, 200, 100, 50, 0];
const puntosDe = (p) => p.frutas * 10 + PUNTOS_PUESTO[Math.min(4, p.puesto - 1)];
const final = [
{ nombre: "Rosa", frutas: 2, puesto: 1 },
{ nombre: "Beto", frutas: 9, puesto: 2 },
{ nombre: "Caro", frutas: 4, puesto: 3 },
{ nombre: "Dani", frutas: 4, puesto: 3 }, // cayeron en el mismo tick: empate
];
for (const p of final) console.log(p.nombre, "puesto", p.puesto, "->", puntosDe(p), "puntos");
// Cuánto tarda cada paso a lo largo de la partida (acelera cada 15 s, mínimo 4)
for (let s = 0; s <= 120; s += 15) console.log(s + " s: un paso cada", Math.max(4, 7 - Math.floor(s / 15)), "ticks");
- Beto comió mucho más, pero Rosa sobrevivió: 320 contra 290. Sobrevivir pesa más que comer.
- Caro y Dani empatan en el 3.er puesto y se llevan lo mismo.
- Desde el segundo 45 las serpientes ya van a su velocidad máxima.