← How a game is made: Daily Challenge's Snake · Lesson 2/16
How the code is split up
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.jsandmusic.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 anindex.jsthat registers them.main.jsandshell.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.
$ 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.jsandmusic.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 anindex.jsthat registers them.main.jsandshell.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,titleandhint: 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.paramis the number of extra walls).update(mask): advances one tick with that tick's buttons.render(r): draws the state using the rendererr.score,overandevents: 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.