Skip to content
Ayotl

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

Snake versus: one simulation, several players

✕
1/8

An array of buttons instead of one

In single-player Snake, update(mask) takes one number per tick. In Snake VS (games/snakevs.js, 2 to 5 players) it takes an array: masks[0] holds player 1's buttons, masks[1] player 2's, and so on. The rest is the same idea: a single deterministic simulation with every snake inside it.

That's what makes it playable online: if every phone gets the same arrays in the same order, they all compute the same board. The next lesson tells how they're made to arrive the same.

The program uses a reduced SnakeVs on a 10×5 board: A and B are the heads, a and b the bodies.

leccion.js

$ node leccion.js

Press RUN (or Ctrl+Enter).

What to look at

Each row of jugada is one tick with two masks, one per player. Both snakes moved on the same ticks, each with its own buttons: A started right, went down two and turned again; B started left and turned up.

Snake VS on Daily Challenge ↗

The whole lesson on one page

An array of buttons instead of one

In single-player Snake, update(mask) takes one number per tick. In Snake VS (games/snakevs.js, 2 to 5 players) it takes an array: masks[0] holds player 1's buttons, masks[1] player 2's, and so on. The rest is the same idea: a single deterministic simulation with every snake inside it.

That's what makes it playable online: if every phone gets the same arrays in the same order, they all compute the same board. The next lesson tells how they're made to arrive the same.

The program uses a reduced SnakeVs on a 10×5 board: A and B are the heads, a and b the bodies.

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 }];   // up, down, left, right
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;   // one input per player
      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) every new head, before anyone moves
    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) the crashes, with the whole board still frozen
    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) move the survivors (they always grow, so the trail shows)
    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],          // player 1 turns down; player 2 carries on
  [0, BTN.UP],            // player 2 turns up
  [BTN.RIGHT, 0],
];
for (const masks of jugada) g.update(masks);
console.log(g.dibujar());
console.log("tick", g.tick, "| alive:", g.players.filter((p) => p.vivo).length);

Each row of jugada is one tick with two masks, one per player. Both snakes moved on the same ticks, each with its own buttons: A started right, went down two and turned again; B started left and turned up.

All at once: heads first, then crashes

If the code moved snake 1 first and then snake 2, snake 1 would have an edge: in a head-on crash it would reach the cell first. So SnakeVs.update works in three steps:

  • compute every new head without moving anyone;
  • check crashes with the board still frozen: wall, any body, two heads on the same cell, or two heads that swap (each goes where the other was);
  • move only the survivors.

That way the result doesn't depend on the array's order. If two fall on the same tick, they tie for the place.

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 }];   // up, down, left, right
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;   // one input per player
      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) every new head, before anyone moves
    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) the crashes, with the whole board still frozen
    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) move the survivors (they always grow, so the trail shows)
    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");
  }
}

// Case 1: head-on, one cell apart: both heads go to the same cell
const g1 = new MiniVs([{ x: 3, y: 2, dir: 3 }, { x: 5, y: 2, dir: 2 }]);
g1.update([0, 0]);
console.log("same cell:");
console.log(g1.dibujar());

// Case 2: touching head-on: each goes where the other is (they swap)
const g2 = new MiniVs([{ x: 4, y: 2, dir: 3 }, { x: 5, y: 2, dir: 2 }]);
g2.update([0, 0]);
console.log("they swap:");
console.log(g2.dibujar());

console.log("alive:", g1.players.filter((p) => p.vivo).length, "and", g2.players.filter((p) => p.vivo).length);

Both die in both cases (x). Without the "swap" rule, in the second case the snakes would pass through each other like ghosts, because neither new head lands on the other new head.

Points for fruit and for place

Each fruit is worth 10 and makes the snake grow. When you fall you get a place: the last one alive is 1st. The game ends when only one is left or after 2 minutes; if time runs out, the longest wins. And every 15 seconds they all speed up (the interval drops from 7 down to 4 ticks per step), so nobody wins by hiding.

Place points come from 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 },   // fell on the same tick: a tie
];
for (const p of final) console.log(p.nombre, "place", p.puesto, "->", puntosDe(p), "points");

// How long each step takes over the game (speeds up every 15 s, minimum 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 ate a lot more, but Rosa survived: 320 to 290. Surviving outweighs eating.
  • Caro and Dani tie for 3rd and get the same.
  • From second 45 on, the snakes are already at top speed.