2026-06-11·Sanchez

Zero Sound Files — Synthesizing SFX with Web Audio

I kept external assets to a minimum throughout the rebuild.
Only the background music (BGM) uses mp3s; the sound effects (SFX) use no files at all — every effect (collect, jump, hit, combo) is synthesized on the spot with the Web Audio API.

There are only two basic tools: an oscillator (which generates a waveform) and gain (volume).
Add a starting frequency, a duration, a waveform type, and an envelope that decays the volume toward zero over time, and you've got one short sound effect.
I reuse this single function for everything.

function blip(freq, dur, type, gain = 0.15, slide = 0) {
  const c = ac(); // lazily-created AudioContext (browser/SSR guard)
  const o = c.createOscillator();
  const g = c.createGain();
  o.type = type;
  o.frequency.setValueAtTime(freq, c.currentTime);
  if (slide !== 0) // glide the pitch over the duration
    o.frequency.exponentialRampToValueAtTime(freq + slide, c.currentTime + dur);
  g.gain.setValueAtTime(gain, c.currentTime);
  g.gain.exponentialRampToValueAtTime(0.0001, c.currentTime + dur); // envelope
  o.connect(g).connect(c.destination);
  o.start();
  o.stop(c.currentTime + dur);
}

I split the timbres: a low sine wave for jumps, a gritty sawtooth for taking a hit.
My favorite detail is the collect sound — the pitch rises as your combo climbs.

export const playCollect = (combo: number) =>
  blip(520 + Math.min(combo, 24) * 40, 0.18, "triangle", 0.18, 120);

520 + min(combo,24)*40Hz.
Grab one and it's 520Hz; as the combo builds it steps up a notch at a time and caps out at the ceiling.
When you collect a streak in a row, the pitch trickles upward — and that rising feel was more thrilling than the combo itself.
There's no way I'd have gotten that satisfaction from replaying the same canned sound.

I also covered the browser environment check.
If window or AudioContext is missing, every function quietly falls back to a no-op.
That keeps things from blowing up under SSR or unsupported environments.

That wraps up the rebuild dev log.
The takeaway is simple — even with zero sound-effect files, code alone was enough to make sounds that feel cute and satisfying to play.

A sixth argument makes chords

One blip produces one tone.
But the game has an unlock fanfare and a rising arpeggio.
Rather than write new functions, one more argument covered it — delay.

const t0 = c.currentTime + delay; // everything schedules against this

Web Audio lets you schedule an oscillator's start and stop ahead of time.
So calling blip several times with different delay values plays the notes in order on its own.
No setTimeout required.
A JavaScript timer slips whenever the main thread is busy; audio scheduling is kept sample-accurate on the audio thread.
Frames can drop and the fanfare still doesn't wobble.

The unlock fanfare looks like this.

export const playUnlock = () => {
  blip(523, 0.1, "triangle", 0.14);            // C
  blip(659, 0.1, "triangle", 0.14, 0, 0.08);   // E, 0.08s later
  blip(784, 0.1, "triangle", 0.14, 0, 0.16);   // G
  blip(1047, 0.25, "triangle", 0.16, 0, 0.24); // high C, held longer
};

523, 659, 784, 1047 Hz is C–E–G–C: a plain C major arpeggio.
Only the last note stretches to 0.25s to close it out.
It's closer to writing sheet music than writing code.

Fourteen sounds, one function

The game currently has fourteen effects and every one is the same blip with different arguments.

EffectFrequencyWaveformCharacter
Jump220Hzsineshort and low, slides up
Collect520Hz+triangleclimbs with the combo
Near miss880Hzsine0.08s, barely there
Hit140Hzsawtoothslides down
Trampoline180Hzsine+340Hz — a "boing"
Booster330Hzsawtooth+480Hz, steep climb
Gem880Hztrianglelayered with 1320Hz, bright
Game over330Hztriangle−180Hz, falling

A rule shows up. Good things go up, bad things go down. Collect, trampoline, booster, and gem all slide upward; hits and game over fall.
The waveforms split too — sine and triangle for soft events, sawtooth and square for harsh ones.

That isn't music theory, it's a convention people already know.
Nobody has to be told that a rising tone means it went well.
Synthesizing sound turns that rule into a number rather than a filename, which turned out to help the design: deciding on a new effect starts and mostly ends with "is this a good thing or a bad thing," and that fixes the sign of slide.

An exponential ramp can't reach zero

There's a reason the envelope ramps to 0.0001 rather than 0.

An exponential ramp cannot reach zero by definition — passing zero throws.
So it converges to something too quiet to hear instead.
Using a linear ramp avoids that but makes notes end with an audible click: human hearing is logarithmic, so a linear fade sounds like it vanishes all at once at the end.
Exponential decay is what sounds natural.

slide needs the same kind of guard.

o.frequency.exponentialRampToValueAtTime(Math.max(40, freq + slide), t0 + dur);

Without Math.max(40, ...), a negative slide like the hit sound can drive the target frequency to zero or below and throw for the same reason.
The guard wasn't there at first, and low notes with a big downward slide would cut off mid-sound and log an error.

An AudioContext is born locked

Browsers block audio until the user has interacted with the page, so a freshly constructed AudioContext usually starts out suspended.

if (ctx.state === "suspended") void ctx.resume();

That's why ac() checks and wakes it on every call.
By the time the first effect plays, the user has already tapped to start the game, so the resume() goes through.
Creating the context lazily — on the first sound rather than at module load — is part of the same fix: build it early and it sits locked, swallowing the first sound.

What skipping files cost, and bought

The upside was clear.
Fourteen sound effects weigh zero bytes.
Nothing downloads, so nothing delays the first sound.
And most of all, a dynamic sound like a pitch that climbs with the combo simply isn't possible from files — you're not going to record 24 separate collect sounds.

There's a cost too.
Textured sounds — footsteps, soil crumbling — don't come out of an oscillator.
That's the domain of noise and filters, and once you're there you may as well ship files.
So every effect in this game is in the "beep" family.
It suited a casual tone, but for a game that needed realistic sound this would have been the wrong call.

Background music being the one exception, shipped as mp3, follows from the same limit.
You don't synthesize a twelve-track soundtrack.

Try it yourself →