Sound from arithmetic
Pebble Merge has a full set of sound effects and ships no audio files at all, and every one of those sounds gets assembled at runtime from an oscillator, a gain envelope and a single formula instead of being read off disk. None of this was for file size. The real reasons were more interesting, and one of them is faintly embarrassing to admit.
Why not just ship audio files
The obvious approach is to record or license a handful of sounds and load them. For a small web game that is genuinely fine. Most people do exactly that, and I have no argument with them. We went the other way, for three reasons, one of which is mostly about our own limitations.
The first is the one you would expect: no files means no network requests, no decode step, no loading state, nothing to preload, and audio that starts the instant the JavaScript has parsed rather than some unknowable number of milliseconds after the player has already clicked. Our whole pitch is “open a tab and play for five minutes”. A spinner before the first sound eats into that. Small cost, but one I could see no reason to pay.
The second is that samples resist being parameterised. Our merge sound changes with the tier being merged and with how deep the chain runs, so a sample version needs either one file per variation or a single file shunted up and down in pitch with artifacts we would then be listening to for the rest of the project. With a synthesiser, “one tone higher” is one multiplication. That sentence is most of the argument.
The third reason is the least flattering, and it decided things. We are not sound designers. Synthesis lets us iterate in numbers rather than in audio software we are slow at, and I can retune a decay constant faster than I can find the right panel in a DAW, which across a week of tweaking stops being a rounding error and becomes the difference between finishing and not.
One function, every sound
Everything goes through a single routine. Give it a frequency, a duration, a waveform and a volume. It creates an oscillator, wires up a gain node, sets the frequency, and shapes the gain with an envelope, so four numbers handed in at one end come out the other as a sound that stops itself cleanly and never needs tidying up afterwards. Nothing else in the audio layer is worth describing.
- Start at a near-silent 0.0001 rather than zero. An exponential ramp to or from exactly zero is undefined, and the specification means it.
- Ramp up to the target volume over about 12 milliseconds. Short enough to feel percussive, long enough to avoid the click you get from jumping straight to full gain.
- Ramp back down to near-silence across the note’s duration instead of cutting at the end, because an abrupt stop reads as a bug even when players cannot say why, and an exponential decay is the thing that makes a synthesised blip sound like an object being struck rather than a switch being flipped.
Individual effects are then trivial. A dropped stone is a 150 Hz sine for 0.12 seconds at low volume, and it lands as a soft thud, while running out of space is a 110 Hz sine for half a second: low, long, final, yet somehow not dramatic. Each is two lines.
A pentatonic scale, on purpose
The merge is the interesting case. It fires constantly, and it has to carry information. Ideally it tells you which tier you just made and stays pleasant however many times it goes off in a row, and I did not expect those two requirements to fight each other until I heard the first version out loud.
Our first attempt mapped tier number straight onto the chromatic scale, one semitone per tier. Technically correct. Also unpleasant, because consecutive merges played as consecutive semitones, which sounds like running a finger down every key of a piano, a gesture that reads as sliding downwards rather than as anything arriving somewhere better. Nothing about it said progress. I listened for about thirty seconds before deleting it.
So we switched to pentatonic, the five-note scale made from intervals of 0, 2, 4, 7 and 9 semitones. Any two notes drawn from that set are consonant together, which in practice means rapid consecutive merges cannot produce a dissonance whatever order they arrive in, removing a whole category of ugliness instead of trying to tune it out afterwards. Exactly what you want when the player decides the note order, not a composer.
The mapping itself is arithmetic. Take the tier and split it into a position within the scale and an octave, where the position is the tier modulo five and the octave is the tier divided by five, then allow twelve semitones per octave. Converting to frequency from a 220 Hz reference uses the standard equal-temperament formula, 220 multiplied by two to the power of the semitone count over twelve. Tier 0 gives you the low A, tier 5 the A an octave up, and everything between lands on a note belonging to the scale instead of somewhere in the cracks.
Each merge also fires a quiet sine an octave above the main tone, at roughly a third of the volume. That shimmer is what keeps the result from reading as a plain beep: the ear hears a struck object with some complexity to it rather than a tone generator doing precisely what it was told. We found it by accident. I was trying to make the merge louder, typed the wrong frequency multiplier, and preferred the mistake.
Cascades that rise
When one drop sets off a chain, the reward is almost entirely in the feedback rather than in the score. Each link past the first lifts the pitch by a whole tone, two semitones, computed by raising two to the power of two-twelfths per extra link, and the note gets slightly louder too, capped so that a long chain does not turn harsh right at the moment it should feel best.
A chain of four therefore plays as a rising arpeggio, so you hear how good the move was before you have looked at the score, which matters more than it sounds because a cascade is over in well under a second and nobody reads a number in that time. It is the same reasoning that made us keep the score bonus for cascades tiny. The feeling belongs in the sound and the motion. Not in the points.
The parts that are awkward
Synthesis has its own annoyances. Two are worth knowing before you commit to it.
Browsers will not start audio without a user gesture. An audio context created on page load comes back suspended, so the first real interaction has to resume it, which is why we unlock on the first input and route every sound through a helper that quietly resumes the context whenever it finds one suspended. Skip this and the game stays silent until the player happens to click something else. Nobody will manage to describe that bug to you.
Exponential ramps to zero are undefined. Starting a ramp from a truly zero value gives you either no sound or a click depending on which browser you happen to be testing in, and the workaround everyone lands on is starting at 0.0001 instead. Learn it now. The failure looks exactly like a bug in your envelope logic. It is not. I lost an evening to that confusion.
Muting lives in the browser’s local storage, so a player who turns sound off is not asked again. Small thing, but games get it wrong often enough that I will keep repeating it: a mute toggle that forgets is a mute toggle that gets tested over and over by somebody who is getting steadily more annoyed every time they reopen the tab.
When to use samples instead
We would not do this for everything. Anything with a specific recorded character, whether that is a human voice, a real instrument, or an ambient bed with actual texture in it, belongs in a file, and no amount of filter fiddling will convince me otherwise, because those sounds carry detail that oscillators simply do not have. Synthesis is at its best on sounds that are short, abstract, repeatable and parameterised: clicks, thuds, blips, confirmation tones. That list happens to be the entire sound vocabulary of a small puzzle game, so how well it fits here is luck as much as judgement, and I would rather say that than dress it up as strategy.
The lesson we would keep is narrower than “synthesise everything”. It is that when a sound has to vary along a dimension you can compute, tier, chain depth, distance, generating it from a formula beats shipping a library of variants. Sometimes by a mile, sometimes only in tidiness, but either way it means less work every later time we decide to change something. That has held up.