Skip to content
Ayotl

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

A sound chip with Web Audio

✕
1/9

A mixing desk in the browser

Daily Challenge doesn't load a single mp3: all its sounds are built on the fly with the Web Audio API, the way an 8-bit console's chip did it. Think of a mixing desk: one AudioContext and nodes wired in a chain.

  • An OscillatorNode makes a tone: a square wave (the Atari and NES sound) or a triangle wave (softer, good for bass).
  • A GainNode controls volume over time.
  • Everything ends in a master and from there goes to the speakers. Music first goes through its own bus, musicBus, so it can be muted without muting the effects.

Phones have a rule: audio only starts from a user's tap. That's why chip.unlock() is called when you press PLAY, not when the page loads.

There are no speakers here (the code runs in a Worker), so the program uses a fake AudioContext that writes down what would have played, with core/audio.js's note() function as is.

leccion.js

$ node leccion.js

Press RUN (or Ctrl+Enter).

What to look at

  • Eating is two square notes in a row, 660 and 990 Hz: a fifth upward, it sounds like "yes!".
  • Crashing is one note that slides from 400 down to 50 Hz in half a second: the classic "peeyoo" (in the game it also gets a burst of noise).
  • Each note stops 20 ms after its end, so the envelope finishes before the cut.

Web Audio API (MDN, Spanish) ↗

The whole lesson on one page

A mixing desk in the browser

Daily Challenge doesn't load a single mp3: all its sounds are built on the fly with the Web Audio API, the way an 8-bit console's chip did it. Think of a mixing desk: one AudioContext and nodes wired in a chain.

  • An OscillatorNode makes a tone: a square wave (the Atari and NES sound) or a triangle wave (softer, good for bass).
  • A GainNode controls volume over time.
  • Everything ends in a master and from there goes to the speakers. Music first goes through its own bus, musicBus, so it can be muted without muting the effects.

Phones have a rule: audio only starts from a user's tap. That's why chip.unlock() is called when you press PLAY, not when the page loads.

There are no speakers here (the code runs in a Worker), so the program uses a fake AudioContext that writes down what would have played, with core/audio.js's note() function as is.

// A fake AudioContext: instead of playing, it writes down what it would do
const ctx = {
  createOscillator() {
    const o = {
      type: "",
      frequency: {
        setValueAtTime: (f, t) => console.log("  oscillator", o.type, f, "Hz from t =", t),
        exponentialRampToValueAtTime: (f, t) => console.log("  slides to", f, "Hz at t =", t.toFixed(2)),
      },
      connect: (n) => n,
      start() {},
      stop: (t) => console.log("  stops at t =", t.toFixed(2)),
    };
    return o;
  },
  createGain() {
    return { gain: { setValueAtTime() {}, exponentialRampToValueAtTime() {} }, connect: (n) => n };
  },
};
const master = {};

// note() de core/audio.js
function note(freq, t, dur, type = "square", vol = 0.15, slideTo = null, bus = master) {
  const osc = ctx.createOscillator();
  const g = ctx.createGain();
  osc.type = type;
  osc.frequency.setValueAtTime(freq, t);
  if (slideTo) osc.frequency.exponentialRampToValueAtTime(slideTo, t + dur);
  g.gain.setValueAtTime(0.0001, t);
  g.gain.exponentialRampToValueAtTime(vol, t + 0.005);
  g.gain.setValueAtTime(vol, t + dur * 0.6);
  g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
  osc.connect(g).connect(bus);
  osc.start(t); osc.stop(t + dur + 0.02);
}

console.log("eat (eating fruit):");
note(660, 0, 0.06, "square", 0.2);
note(990, 0.06, 0.08, "square", 0.2);
console.log("die (crashing):");
note(400, 0, 0.5, "square", 0.25, 50);
  • Eating is two square notes in a row, 660 and 990 Hz: a fifth upward, it sounds like "yes!".
  • Crashing is one note that slides from 400 down to 50 Hz in half a second: the classic "peeyoo" (in the game it also gets a burst of noise).
  • Each note stops 20 ms after its end, so the envelope finishes before the cut.

The envelope: how a note starts and dies

If an oscillator switches on and off abruptly, you hear a "click". So each note gets an envelope on its GainNode: it rises in 5 milliseconds (the attack), holds until 60 % of its length and then falls (the decay).

The ramps are exponential (exponentialRampToValueAtTime) because that's how the ear hears volume: going from 0.2 to 0.1 sounds like as big a drop as 0.02 to 0.01. That's also why they start at 0.0001 rather than zero: an exponential ramp can't leave 0.

The program computes that same curve for a 0.3 s note and draws it with #.

// note()'s envelope: 5 ms attack, holds until 60 %, falls at the end
function volumen(t, dur, vol) {
  const rampa = (v0, v1, t0, t1) => v0 * Math.pow(v1 / v0, (t - t0) / (t1 - t0));
  if (t < 0.005) return rampa(0.0001, vol, 0, 0.005);
  if (t < dur * 0.6) return vol;
  if (t < dur) return rampa(vol, 0.0001, dur * 0.6, dur);
  return 0;
}

for (let i = 0; i <= 12; i++) {
  const t = i * 0.025;
  const v = volumen(t, 0.3, 0.2);
  console.log(t.toFixed(3), "s", v.toFixed(4).padStart(7), "#".repeat(Math.round(v * 150)));
}
  • Up almost from the first instant: a 5 ms attack feels immediate, but without a click.
  • Flat until 0.18 s (60 % of 0.3).
  • Then it falls fast at first and slowly at the end: that's what an exponential ramp looks like.

The game doesn't play sounds: it reports

The Snake class doesn't know audio exists. When it eats, it pushes 'eat' into this.events; when it crashes, 'die'. At the end of each tick, main.js walks that list and plays whatever applies:

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

Why the detour? Because the server runs the same class to check scores, and the server has no speakers and no AudioContext. Separating "what happened" from "how it sounds" keeps the simulation clean, and sfx() handles details that aren't the game's business: not repeating the same effect within 40 ms, and staying quiet if you turned effects off.

// A minimal game that only reports
class Juego {
  constructor() { this.tick = 0; this.events = []; }
  update() {
    this.events.length = 0;          // emptied every tick, as in snake.js
    this.tick++;
    if (this.tick % 3 === 0) this.events.push("eat");
    if (this.tick === 7) this.events.push("die");
  }
}

// What chip.sfx() would do: here it just writes the notes
const SONIDOS = { eat: [660, 990], die: [400] };
let efectosOn = true;
const sfx = (e) => { if (efectosOn) console.log("  ♪", e, SONIDOS[e].join(" → "), "Hz"); };

const game = new Juego();
for (let i = 1; i <= 7; i++) {
  game.update();
  console.log("tick", i, game.events);
  for (const e of game.events) sfx(e);
}

The simulation produces a list of words; the browser side turns them into sound. Set efectosOn = false and run again: the game does exactly the same and only the sound goes quiet.

White noise for the drums

An oscillator can't make a cymbal or a snare: those are noise. When audio is unlocked, the chip fills a one-second buffer with random numbers between −1 and 1 (Math.random() * 2 - 1), and each drum hit plays a slice of it through a high-pass filter: cut at 6000 Hz it sounds like a hi-hat (H), at 1500 Hz like a snare (S). The kick (K) is a triangle oscillator falling from 160 to 40 Hz.

Math.random is fine here: the noise only makes sound and doesn't change the game.

// The chip's noise buffer: one second of random samples between -1 and 1
const sampleRate = 44100;
const ruido = new Float32Array(sampleRate);
for (let i = 0; i < ruido.length; i++) ruido[i] = Math.random() * 2 - 1;

console.log("samples:", ruido.length);
console.log("the first ones:", Array.from(ruido.slice(0, 5), (v) => v.toFixed(3)).join(" "));
const media = ruido.reduce((s, v) => s + v, 0) / ruido.length;
console.log("mean:", media.toFixed(4));

// drum() from core/audio.js, summarized
const BATERIA = {
  K: "triangle 160 → 40 Hz, 0.12 s",
  S: "noise high-passed at 1500 Hz + triangle 200 → 100 Hz",
  H: "noise high-passed at 6000 Hz, 0.035 s",
};
for (const [golpe, como] of Object.entries(BATERIA)) console.log(golpe, "=", como);

44,100 samples: one second at CD quality. The mean comes out near zero because there are as many positives as negatives; it changes on every run, and that's fine.