Advertisement
UI & Interface Sounds

Programmatically Generating Blip Sounds in JavaScript for Browser Games

An architecture-focused guide to generating a full blip set at runtime in the browser, covering the data structure that describes the set, the factory function that produces every blip from a single generator, the memory and CPU tradeoffs of runtime generation, and how to let designers tune the set without editing JavaScript.

Making one blip in JavaScript is a solved problem. The beginner tutorial covers it in six lines of code. The problem that most browser game projects actually run into is not making one blip; it is making twelve that sound like they belong together, that vary between playbacks, and that can be adjusted when the design changes. That is an architecture problem, and it is the one this article is about.

The architecture that works is a generator rather than a set of sounds. Instead of a folder of WAV files or a list of hard-coded oscillator calls, the project contains a small data structure that describes the blip set, and a function that reads the data and produces every blip. The data structure is what the designer edits; the function is what the developer maintains. The two are decoupled, which means the blip set can be tuned without touching code, and the code can be changed without retuning the set.

The Web Audio game guide covers the core API patterns — the AudioContext, the voice pool, the PannerNode. This article is about what happens when those patterns are applied to a programmable blip set rather than a single sound, and the specific decisions that make the difference between a set that scales and one that becomes unmaintainable after the third addition.

Why generate the blip set at runtime instead of shipping WAV files?

The instinct for a browser game is to pre-render the blips as WAV files and ship them alongside the game's other assets. The game loads the files at startup and plays them through the Web Audio API's buffer system. This works, and for a small game with four or five fixed blips it is the right approach.

The runtime generation approach becomes the right one when three conditions are true at once. The first is that the blip set is large enough that the file size matters. The second is that the blips need to vary between playbacks in a way that cannot be pre-baked into a fixed number of files. The third is that the project wants the blip set to be tunable at runtime based on game state, such as a blip whose pitch rises with a combo counter.

Approach File size Variation Runtime cost
Pre-baked WAV (5 blips) ~30 KB None per playback Decode once, play many
Pre-baked WAV (5 sets × 4 variations) ~250 KB 4 per blip Decode once, play many
Runtime oscillator 0 KB Unlimited ~0.05 ms per play
Runtime oscillator + variation 0 KB Unlimited ~0.08 ms per play

The runtime oscillator approach produces no files, costs essentially nothing per playback, and supports unlimited variation. The tradeoff is that it can only produce waveforms an oscillator can generate. If the blip needs to contain recorded material — a metallic click, a wooden tap — the runtime approach has to use a source buffer, which brings back the file size question in a different form.

For most browser games, the answer is a hybrid: runtime oscillators for the blips that are genuinely tonal, and pre-baked files for the blips that need material character. The generator architecture described below supports both, because it treats the source as a parameter rather than as a hard-coded assumption.

How do you describe a blip set as a data structure?

The data structure is the heart of the architecture. It describes what each blip in the set should sound like, without describing how the sound is produced. The generator reads the data and produces the sound. If the generator is rewritten, the data does not change; if the data is retuned, the generator does not change.

The fields that a blip description needs are:

Field Type Purpose
id string Unique name the game code calls
frequency number (Hz) Base pitch of the blip
duration number (seconds) Total blip length
waveform string sine, square, triangle, or sawtooth
volume number (0-1) Peak amplitude
pitchShift number (ratio) End frequency as a ratio of start
variation object Per-playback pitch and volume ranges

The structure is deliberately flat. A nested structure with sub-objects for every parameter would be more expressive, but it would also be harder to edit and harder to validate. The flat structure with a single variation object for the per-playback ranges is the right level of complexity for a UI set that has between five and twenty entries.

const blipSet = {
    ui_click: {
        frequency: 1400,
        duration: 0.04,
        waveform: 'square',
        volume: 0.5,
        pitchShift: 1.0,
        variation: { pitch: 0.03, volume: 0.1 }
    },
    ui_confirm: {
        frequency: 1100,
        duration: 0.06,
        waveform: 'sine',
        volume: 0.6,
        pitchShift: 1.0,
        variation: { pitch: 0.02, volume: 0.05 }
    },
    ui_cancel: {
        frequency: 900,
        duration: 0.07,
        waveform: 'sine',
        volume: 0.6,
        pitchShift: 0.85,
        variation: { pitch: 0.02, volume: 0.05 }
    },
    ui_error: {
        frequency: 700,
        duration: 0.12,
        waveform: 'triangle',
        volume: 0.7,
        pitchShift: 0.90,
        variation: { pitch: 0.01, volume: 0.03 }
    },
    ui_pickup: {
        frequency: 1500,
        duration: 0.05,
        waveform: 'square',
        volume: 0.5,
        pitchShift: 1.15,
        variation: { pitch: 0.04, volume: 0.08 }
    }
};

The pitchShift field is a ratio rather than an end frequency, because a ratio expresses the intention ("this blip slides up by a sixth") more directly than an absolute frequency does. A pitchShift of 1.15 plays the blip at a frequency 15 percent higher than its start, which is roughly a minor third. A value of 0.85 plays it 15 percent lower. A value of 1.0 produces a static pitch with no slide.

The variation object expresses the per-playback ranges as fractions of the base values. A pitch variation of 0.03 means each playback is randomly shifted by up to 3 percent of the base pitch, either up or down. The variation system guide covers the perceptual thresholds that determine how much variation is enough.

How do you generate every blip from a single function?

The generator is one function that takes a blip description and produces a sound. Every blip in the set goes through the same function, which is what makes the set feel consistent. The differences between blips come from the data, not from the code.

function playBlipFromDescription(desc) {
    const ctx = getAudioContext();
    const now = ctx.currentTime;

    // Apply per-playback variation
    const pitchJitter = 1 + (Math.random() * 2 - 1) * desc.variation.pitch;
    const volumeJitter = 1 + (Math.random() * 2 - 1) * desc.variation.volume;

    const startFreq = desc.frequency * pitchJitter;
    const endFreq = startFreq * desc.pitchShift;
    const volume = desc.volume * volumeJitter;

    const osc = ctx.createOscillator();
    const gain = ctx.createGain();

    osc.type = desc.waveform;
    osc.frequency.setValueAtTime(startFreq, now);

    if (desc.pitchShift !== 1.0) {
        osc.frequency.exponentialRampToValueAtTime(
            endFreq, now + desc.duration
        );
    }

    // Envelope: fast attack, exponential decay
    gain.gain.setValueAtTime(0, now);
    gain.gain.linearRampToValueAtTime(volume, now + 0.003);
    gain.gain.exponentialRampToValueAtTime(0.001, now + desc.duration);

    osc.connect(gain);
    gain.connect(ctx.destination);

    osc.start(now);
    osc.stop(now + desc.duration + 0.01);

    // Cleanup
    osc.onended = () => {
        osc.disconnect();
        gain.disconnect();
    };
}

The function is about thirty lines long and handles every blip in the set. The variation is applied before the oscillator is created, which means the jitter is baked into the blip rather than applied afterward. The cleanup in onended is what prevents the audio graph from accumulating disconnected nodes over a long play session.

The function's shape is determined by what is common across the set. Every blip has a start frequency, a duration, a waveform, and a volume. The pitch shift is optional, which is why it is checked before the ramp is scheduled. If a blip in the set does not need a pitch shift, the field is set to 1.0 and the ramp is skipped.

Adding a new blip to the set is now a data change rather than a code change. The designer adds an entry to the blipSet object, and the function handles it without modification. Adding a blip that needs a feature the generator does not support — a second oscillator for a two-note blip, a noise layer for a hit — requires extending the generator, which is the point at which the architecture has to be revisited.

What does the game code actually look like?

The game code calls the generator with a blip ID. The generator looks up the description in the set and produces the sound. The call site is one line, and it does not need to know anything about how the blip is produced.

// In a button's click handler
playBlipFromDescription(blipSet.ui_click);

// In a confirmation handler
playBlipFromDescription(blipSet.ui_confirm);

// In an error handler
playBlipFromDescription(blipSet.ui_error);

The call sites are readable because the field names in the blipSet object are descriptive. A developer reading the call site does not need to understand how the blip is generated; they need to understand which blip is playing, and the field name tells them.

If the game has many buttons that all play the same blip, the call can be wrapped in a small helper function that the UI code calls:

function attachClickBlip(element, blipId) {
    element.addEventListener('click', () => {
        const desc = blipSet[blipId];
        if (desc) playBlipFromDescription(desc);
    });
}

// In a setup function
attachClickBlip(document.getElementById('start'), 'ui_click');
attachClickBlip(document.getElementById('cancel'), 'ui_cancel');

The helper function is optional, but it is useful when the game has many buttons that need the same blip behavior. The important architectural point is that the blip description is looked up by ID at the call site, not hard-coded in the UI code. This means the blip set can be retuned without touching the UI code, which is the entire point of the data-driven approach.

How do you keep a generated blip set feeling consistent?

A generated set feels consistent when every blip shares a set of underlying characteristics, and the differences between blips come from a small number of parameters that move within a defined range. The most useful characteristics to share are the waveform family and the envelope shape; the most useful to vary are the frequency and the duration.

The set that feels like a family usually has two to three waveform types, not one and not six. A set that uses only sine waves sounds monotonous; a set that uses sine, square, triangle, and sawtooth across five blips sounds like five unrelated sounds. The middle ground is two to three waveforms, with related blips sharing the same waveform.

Blip family Waveform Frequency range Duration range
Navigation (move, select) square 1200 to 1600 Hz 30 to 50 ms
Confirmation (accept, success) sine 900 to 1200 Hz 50 to 80 ms
Negative (cancel, error) triangle 600 to 900 Hz 70 to 120 ms
Reward (pickup, achievement) square 1400 to 1800 Hz 40 to 60 ms

The four families cover the four emotional registers that a UI blip set needs. Navigation is neutral and quick; confirmation is warm and slightly slower; negative is low and longer; reward is bright and energetic. Each family has its own waveform and its own frequency and duration ranges, and the blips within a family share those ranges.

The sharing is what makes the set feel consistent. A player who hears the navigation blip and the reward blip will identify them as different, because they are different waveforms at different frequencies. A player who hears two different navigation blips will identify them as related, because they share the waveform and the frequency range.

The envelope shape is the other shared characteristic. Every blip in the set can use the same attack time (around 3 milliseconds) and the same exponential decay shape, and the only difference is the decay rate, which is set by the duration. This is what makes the blips feel like they were produced by the same system even when their frequencies are different. The game UI sound design guide covers the broader approach to building a coherent UI sound set.

How do you let designers tune the set without touching JavaScript?

The data structure described above can live in a JavaScript object, but it does not have to. The most flexible architecture keeps the data in a separate file that a designer can edit without touching the generator code, and the game loads the file at startup.

The two formats that work for this are JSON and a small in-house format. JSON is the safer choice for a team that is not sure who will maintain the set, because it is universally readable and every editor supports it. The in-house format is the better choice for a solo developer who wants to comment the values and use shorthand.

// blip-set.json
{
    "ui_click": {
        "frequency": 1400,
        "duration": 0.04,
        "waveform": "square",
        "volume": 0.5,
        "pitchShift": 1.0,
        "variation": { "pitch": 0.03, "volume": 0.1 }
    },
    "ui_confirm": {
        "frequency": 1100,
        "duration": 0.06,
        "waveform": "sine",
        "volume": 0.6,
        "pitchShift": 1.0,
        "variation": { "pitch": 0.02, "volume": 0.05 }
    }
}

The game loads the JSON at startup and assigns it to the blipSet variable. The generator function does not change; it still reads a description and produces a sound. The designer edits the JSON and reloads the game; the changes take effect without a rebuild.

The one caution with this approach is that the JSON is a shipped asset, and a malformed JSON file will break the game at startup. The loader should validate the JSON against a schema, or at minimum check that each entry has the required fields and that the values are in the expected ranges. The validation is a few lines of code, and it prevents the situation where a mistyped value in the blip set takes down the whole game instead of producing a slightly wrong sound.

The file size reduction guide covers the broader question of what ships with a browser game. A JSON file of a few hundred bytes is negligible compared to the game's other assets, and it gives the design team a tuning interface that costs nothing to maintain.

When does the generator architecture stop being worth it?

The generator architecture is designed for a blip set that has between five and twenty entries, all produced by the same oscillator, all following the same envelope shape. Beyond that scope, the architecture starts to become its own maintenance burden, and a different approach is better.

The generator stops being worth it when:

Blips need layered sources. A blip that combines an oscillator and a noise burst, or two oscillators at different frequencies, does not fit the single-source model. The generator would need to be extended to support multiple sources, which brings back the complexity the architecture was designed to avoid. The granular blip guide covers the approach for blips that need material character rather than pure oscillator output.

The set grows past about twenty entries. A data structure with twenty entries is manageable; one with a hundred is not. The problem is not the file size, it is the cognitive load of maintaining a large set with a small number of variations. At a hundred entries, the set is really several sets, and each set should have its own data file and its own generator configuration.

The blips need precise synchronization with animation. The generator approach schedules sounds on the audio clock, which is sample-accurate but independent of the frame loop. A blip that must land on a specific animation frame is better served by a pre-rendered buffer scheduled with the precise timing pattern than by a runtime generator.

The game targets low-end devices. The runtime oscillator approach costs more CPU than playing a pre-baked buffer, and on a device with very little headroom, the difference matters. The mobile optimization guide covers the tradeoffs for browser games that need to run on constrained hardware.

The architecture is a tool, not a rule. It works for the specific kind of blip set it was designed for, and it should be replaced with a different approach when the project's needs exceed that scope. The mistake is to treat it as a universal solution and add complexity to it that it was designed to avoid.

What does a finished blip generator project look like?

A finished project using this architecture has three parts: the data file, the generator function, and the game code that calls the generator. The three are separate, and each can be changed independently of the others.

The data file is what the designer maintains. It describes every blip in the set and is the single source of truth for how the set sounds. The designer edits the file to retune the set, and the changes take effect on reload.

The generator function is what the developer maintains. It reads a description and produces a sound. It is about thirty lines long, and its scope is deliberately narrow. The developer edits it only when a new feature is needed, which is rare.

The game code is what the gameplay programmer maintains. It calls the generator with a blip ID from the data file. It does not know how the blip is produced, and it does not need to. The call sites are one line each, and they read as clearly as the event they are attached to.

The separation is the architecture. The generator and the set are separate because the developer and the designer have different concerns and different expertise. The generator is code; the set is data. Keeping them separate means the designer can retune the set without waiting for the developer, and the developer can change the generator without retuning the set. The two-way independence is what makes the architecture scale.

The set that a project ends up with after a year of using this architecture usually has between ten and thirty entries, and the entries have evolved to fill the exact gaps the game actually had rather than the generic UI sound categories that a generic set would have included. A project that started with five default blips ends with twenty-five tuned ones, and each one exists because a specific interaction needed it. The architecture is what makes this evolution possible without the set becoming unmaintainable, because adding a blip is a data change rather than a code change.

The blip sound design guide covers the parameters for a single blip, and the Web Audio blip tutorial covers the single-blip implementation. This article is about what happens when there are twelve, and the architecture that keeps twelve blips feeling like a family rather than twelve unrelated sounds.

Generate Reference Blips for the Data File

Open the SfxMaker blip generator and create the four reference blips that the data structure describes — a navigation blip, a confirmation, a negative, and a reward. Use the generated files as a reference for tuning the data file's values, then delete the files once the generated set sounds correct. The generator produces the same sounds at runtime that the reference files contain, without the file size cost.

Open Blip Sound Generator →
Advertisement