Skip to content
Ayotl

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

How the code is split up

✕
1/8

The folders

The whole arcade lives in public/arcade/, split by responsibility:

  • core/: what all 36 games share. renderer.js (drawing), input.js (buttons), audio.js and music.js (sound), rng.js (seeded randomness), map.js (reading maps), buttons.js, palette.js (colors).
  • games/: one file per game (snake.js, tetris.js…) and an index.js that registers them.
  • main.js and shell.js: the screens, the menu and the loop that calls the game 30 times a second.
  • net.js: talking to the server.
  • verify.js: replaying a game from its buttons.

And separately, public/content/: the maps and the music, as text files.

leccion.js

$ node leccion.js

Press RUN (or Ctrl+Enter).

What to look at

The line that splits the project: on one side what needs the browser (screen, keyboard, sound); on the other, pure computation that therefore also runs in Node. The game sits on the pure side.

The whole lesson on one page

The folders

The whole arcade lives in public/arcade/, split by responsibility:

  • core/: what all 36 games share. renderer.js (drawing), input.js (buttons), audio.js and music.js (sound), rng.js (seeded randomness), map.js (reading maps), buttons.js, palette.js (colors).
  • games/: one file per game (snake.js, tetris.js…) and an index.js that registers them.
  • main.js and shell.js: the screens, the menu and the loop that calls the game 30 times a second.
  • net.js: talking to the server.
  • verify.js: replaying a game from its buttons.

And separately, public/content/: the maps and the music, as text files.

const archivos = {
  "core/renderer.js": { dom: true,  hace: "draw on the canvas" },
  "core/input.js":    { dom: true,  hace: "keyboard, D-pad and swipes" },
  "core/audio.js":    { dom: true,  hace: "the sound chip" },
  "core/rng.js":      { dom: false, hace: "seeded randomness" },
  "core/map.js":      { dom: false, hace: "text maps to a grid" },
  "games/snake.js":   { dom: false, hace: "Snake's rules" },
  "verify.js":        { dom: false, hace: "replay a game" },
  "main.js":          { dom: true,  hace: "screens and loop" },
};

const puros = Object.keys(archivos).filter(f => !archivos[f].dom);
console.log("can run on the server:");
for (const f of puros) console.log("  " + f, "->", archivos[f].hace);

The line that splits the project: on one side what needs the browser (screen, keyboard, sound); on the other, pure computation that therefore also runs in Node. The game sits on the pure side.

A game's contract

All 36 games follow the same shape, which is why main.js can run any of them without knowing which one it is:

  • static id, title and hint: the name and the controls hint (in Spanish, English and Japanese).
  • constructor(seed, mapText, opts): sets up the game with the day's seed, the map text and options (in Snake, opts.param is the number of extra walls).
  • update(mask): advances one tick with that tick's buttons.
  • render(r): draws the state using the renderer r.
  • score, over and events: the score, whether it's finished, and what happened this tick ('eat', 'die').

The program builds a toy game with that contract and a "main" that knows nothing about it.

class Monedas {
  static id = "monedas";
  static title = "COINS";
  constructor(seed, mapText, opts = {}) {
    this.meta = opts.param ?? 3;
    this.score = 0; this.over = false; this.events = [];
  }
  update(mask) {
    this.events.length = 0;
    if (this.over) return;
    if (mask & 16) { this.score += 5; this.events.push("eat"); }
    if (this.score >= this.meta * 5) { this.over = true; this.events.push("die"); }
  }
  render(r) { r.text("SCORE " + this.score); }
}

// The "main": doesn't know which game it is, it only uses the contract
function correr(Juego, log) {
  const g = new Juego(1234, "", { param: 2 });
  const r = { text: s => console.log("  screen:", s) };
  for (const mask of log) {
    g.update(mask);
    for (const e of g.events) console.log("  sound:", e);
    if (g.over) break;
  }
  g.render(r);
  return g.score;
}

console.log(Monedas.title, "->", correr(Monedas, [0, 16, 0, 16, 16]));

With param: 2 the game ends on the second A: you get eat, eat and die, and the last 16 in the log never gets played. Change param to 3 and run it again.

The logic never touches the screen

The header of snake.js puts it like this: "Deterministic Snake with no DOM dependencies: update() only receives the tick's bitmask, render() only receives the renderer."

update doesn't draw, doesn't play sounds and doesn't read the keyboard. If it wants something to make a sound, it writes it down in events, and main.js decides what to do:

for (const e of game.events) chip.sfx(e);

Thanks to that, the same game can run on the phone (with sound), on the server (with nothing), as a ghost you watch playing, or in a video produced by a tool. None of those places needs a real screen.

function verifyRun(Game, log) {
  const g = new Game();
  let ticks = 0;
  for (const mask of log) {
    if (g.over) break;
    g.update(mask);
    ticks++;
  }
  return { score: g.score, over: g.over, ticks };
}

class Contador {
  constructor() { this.score = 0; this.over = false; this.events = []; }
  update(mask) {
    this.events.length = 0;
    if (mask & 16) { this.score++; this.events.push("eat"); }
    if (this.score === 3) this.over = true;
  }
}

// Just like verify.js: no sound, no screen, no keyboard
console.log(verifyRun(Contador, [16, 0, 16, 16, 16, 16]));

This is almost literally the verifyRun function from verify.js. It stops as soon as over is true (4 ticks), even if the log has more: whatever someone types after losing doesn't count.