Game sound with no audio files
Inkloop, Meme Brawl and Runekeep ship no audio files. Every effect and song is synthesized live with the Web Audio API. Here is how, and what it costs.
Inkloop, Meme Brawl and Runekeep don't ship a single mp3 or wav. Every bell, punch, key click and song is computed by the browser with the Web Audio API as it plays, and each game keeps all of it in one audio.js of roughly 5, 15 and 5.5 KB of source. Here is how it works, and what it costs.
Waiting for the first input
Under the browser autoplay policy, a page can't make sound until the person interacts with it. So each game creates its AudioContext, the hub all Web Audio sound runs through, on the first input, falling back to window.webkitAudioContext for older Safari, and calls resume() if the context is suspended.
Inkloop does this on the first pointerdown, at the start of a round and on the mute toggle; Runekeep on typing, menu clicks and starting a run; Meme Brawl on every key press and touch. Until then, Meme Brawl's title music waits in a pending variable while the title screen blinks "Press any key to turn on sound".
Then comes a small mixer: one 0.8 master gain in Inkloop; effects and music buses into a 0.55 output in Runekeep; and in Meme Brawl, effects (0.75) and music (0.3) buses into a 0.6 master and a dynamics compressor (threshold −14, ratio 4).
The building blocks
- Oscillator: a source repeating one waveform at a set frequency: sine, triangle, square or sawtooth, from round to buzzy. Meme Brawl adds a 25% pulse wave via
createPeriodicWave, "for that NES lead sound". - Envelope: a sound's volume over time. All three games' oscillators rise exponentially from 0.0001 to full volume in 4 to 6 ms by default, then decay back to 0.0001. Not 0, because an exponential ramp can't start from or reach zero.
- Pitch sweep: the same kind of ramp on frequency. A sine sliding from 170 to 60 Hz turns a beep into a thump.
- White noise: random samples, like an untuned radio. Each game fills a one-second buffer with
Math.random() * 2 - 1once and reuses it; Inkloop and Meme Brawl start each noise at a random point in its first half second, so repeated hits differ. - Filter: passes only some frequencies. Lowpass turns noise into a dull thud, highpass into hiss or a click, bandpass into something like wind. Its frequency can sweep too.
- Gain: a volume knob; the buses above are gain nodes.
Apart from one looping noise in Inkloop, every sound is built fresh, scheduled with start() and stop(), and forgotten. Here is the envelope from runekeep/js/audio.js, much like the other two:
function env(g, t0, dur, vol) {
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(vol, t0 + 0.006);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
}
Inkloop: paper, bells and a seal
Inkloop is a brush drawing loops on rice paper, and it sounds quiet to match: mostly sine and triangle waves, no drums, no music.
Picking up ink plays a 0.07-second triangle tick at 700 Hz × 2^(combo ÷ 12): keep going within 0.6 seconds and each tick rises a semitone, up to 14. Ticks under 0.035 seconds apart are skipped, so dense dots don't become a buzz.
Closing a loop rings a small temple bell. From inkloop/js/audio.js:
loop(value) {
// A small temple bell; louder and lower for a bigger haul.
const base = PENTA[Math.floor(Math.random() * 3)] / (value > 40 ? 2 : 1);
const vol = clamp(0.07 + Math.log2(1 + value) * 0.025, 0.07, 0.24);
tone(base, 1.6, { vol });
tone(base * 2.01, 0.9, { vol: vol * 0.35 });
tone(base * 3.02, 0.5, { vol: vol * 0.18 });
tone(base * 4.17, 0.3, { vol: vol * 0.1 });
},
Four sines at 1, 2.01, 3.02 and 4.17 times the base, the higher ones quieter and shorter; the not-quite-whole ratios are what make it a bell. The base is a random pick from the first three notes of the pentatonic PENTA table (523.25, 587.33, 659.25 Hz), "so overlapping chimes never clash". Bigger hauls ring louder (log scale, capped at 0.24), and above 40 an octave lower.
Boosting fades looping bandpass noise around 1100 Hz up to 0.08: the brush rushing across paper, per the comment. Sealing a rival is lowpass noise at 420 Hz and a sine falling from 90 to 45 Hz for the stamp, then, 0.05 seconds later, a 110 Hz gong ringing for up to 2.4 seconds.
Meme Brawl: a chiptune band
Meme Brawl is a pixel beat 'em up that sounds like an old console. Its SFX table holds 50 effects, mostly one line each. Three of them, from meme-brawl/js/audio.js:
punch(t) { noise(t, 0.07, 0.5, 'lowpass', 1400); osc('sine', 170, 60, t, 0.09, 0.6); },
clack(t) { for (let i = 0; i < 4; i++) noise(t + i * 0.025, 0.02, 0.35, 'highpass', 4000); },
coin(t) { osc('pulse', midi(83), 0, t, 0.06, 0.16); osc('pulse', midi(88), 0, t + 0.06, 0.16, 0.16); },
A punch is lowpass noise for the smack plus a sine falling from 170 to 60 Hz for weight. The Mech Keyboard weapon's clack is four 20 ms highpass noise bursts, 25 ms apart. A score pickup's coin is two pulse notes, MIDI 83 then 88 (B5 to E6).
A step sequencer written as text
Songs are strings of 16 steps per bar: d5 or f#3 is a note, - holds, . rests, and drum steps combine k, s, h, o and c (kick, snare, hi-hat, open hat, crash). Eight songs (title, five stages, boss, ending) run 112 to 156 BPM in about 130 lines, played by a pulse lead, a triangle bass and drums made of noise and sines.
Timing is the hard part. JavaScript timers drift, so notes fired straight from setInterval wobble. Instead, a timer wakes every 25 ms and schedules everything due in the next 0.12 seconds on the audio clock, ac.currentTime:
function tick() {
if (!ac || !song) return;
const spb = 60 / song.bpm / 4;
while (nextT < ac.currentTime + 0.12) {
const l = song.L[step % song.L.length];
if (l) osc('pulse', midi(l.m), 0, nextT, l.len * spb * 0.92, 0.11, musBus, 0.006);
const b = song.B[step % song.B.length];
if (b) osc('triangle', midi(b.m), 0, nextT, b.len * spb * 0.9, 0.34, musBus, 0.005);
const d = song.D[step % song.D.length];
if (d && d !== '.') for (const ch of d) drum(ch, nextT);
nextT += spb;
step++;
}
}
spb is one sixteenth note. On pause, the music bus drops from 0.3 to 0.1.
Runekeep: key clicks and generated music
Runekeep is a typing game, so the sound you hear most is key(combo), played on every correct key: a 45 ms square chirp sliding from 620 to 880 Hz, at a volume of just 0.045. Every 6 hits in a row it climbs one step of the SCALE array, a natural minor scale in semitones (0, 2, 3, 5, 7…), up to 19 semitones. A typo resets the combo and plays a low sawtooth buzz from 150 to 92 Hz.
The meteor you cast with Space is bandpass noise sweeping from 3200 down to 140 Hz over 0.9 seconds, above a sawtooth falling from 320 to 60 Hz: high to low, like something dropping from the sky. The dragon arrives on a 1.5-second sawtooth from 58 to 38 Hz plus low noise.
The music has no score, only a tempo and four chords per song: calm is Am, F, C, G at 88 BPM; boss is Dm, B♭, C, A at 126. One chord per bar, and on every eighth note the code plays root, third and fifth, then again an octave up, as square notes far quieter than the key click (0.016, or 0.022 for the boss). A triangle bass plays the root an octave down every 8 steps (4 for the boss).
Scheduling works like Meme Brawl's (50 ms timer, 0.25-second look-ahead), plus a guard: if a stall such as a background tab leaves it over 0.1 seconds behind, it skips ahead instead of firing a burst of late notes.
How mute is stored
All three use localStorage inside try/catch, since storage can be missing or throw in private windows or when blocked.
- Inkloop: one toggle (button or M) in
inkloop.muted, a JSONtrueorfalse. Muting glides the master gain to 0 (time constant 0.02 seconds) instead of cutting it. - Meme Brawl: M or a menu item, in
memebrawl.muteas'1'or'0'. While muted,play()creates no effects. - Runekeep: separate music and effects toggles saved together in
runekeep.sound, such as{"sfx":true,"music":true}, each setting its bus gain to 0 or 1.
Three art styles, three palettes
Each game's sound follows its art. Inkloop's ink and paper get sine bells, a gong, a pentatonic scale and paper hiss; its one square wave, in cut, plays at 0.03. Meme Brawl's pixel brawling gets pulse, square and sawtooth waves, drums and joke effects like bruh, sus and laugh. Runekeep's pixel fantasy puts short square clicks over a quiet arpeggio, and brings out deep sawtooth sweeps with noise for the dragon and the meteor.
What we gain and what it costs
The gains are direct. No audio files means nothing to download or wait for, and the audio code for all three games is about 26 KB of source, ten songs included. Changing a sound means changing a number, and sound can follow game state: combo sets pitch, haul size sets volume and octave, and random notes and noise offsets make each play a little different.
The costs are real too. Every sound is a row of numbers tuned by ear, with no sample library to fall back on. Timbre has a ceiling: blips, thumps, hiss and bells are easy, voices and real instruments are not. Meme Brawl's laugh is five falling square-wave notes and its roar a sawtooth from 110 to 60 Hz plus lowpass noise; they hint rather than reproduce. Plus there is engineering a file never needs: unlocking on input, look-ahead scheduling, background-tab stalls, and rate limits like Inkloop's tick spacing.
If you want to try it, start with one oscillator, one gain node and the three lines of envelope above.