Every 8-bit sound effect is, at its core, a short program. A coin is two oscillators with an envelope. A jump is one oscillator with a pitch slide. A laser is the same oscillator with the slide reversed and the duration cut in half. None of them require a microphone, a sample library, or an audio file. They require a few lines of math and a basic understanding of what each parameter does to the waveform.
Generating sounds in code rather than recording or downloading them has practical advantages beyond the novelty. The compiled project carries no audio assets, which matters for download size and storage. Every sound can be varied at runtime by adjusting a number rather than loading a different file. And the sounds can be regenerated at any point from the same parameters, which makes them easy to tune without re-recording.
The design principles behind 8-bit sound, including why square waves and short envelopes define the style, are covered in How to Make 8-Bit Sound Effects. This article is about the code.
The Three Building Blocks of Every 8-Bit Sound
A code-generated 8-bit sound is built from three elements that are combined in the same way every time.
The oscillator produces a continuous waveform at a specified frequency. For 8-bit sounds, the waveform is almost always a square wave, though triangle and noise are also available. The oscillator is the raw material; on its own it produces a steady tone that does not change.
The amplitude envelope shapes the volume of that tone over time. It has four stages: attack (how fast the sound rises to full volume), decay (how fast it drops), sustain (the level it holds at), and release (how it fades at the end). Most 8-bit effects use a near-zero attack, a short decay, and no sustain. The envelope is what turns a continuous tone into a short blip.
The pitch envelope shapes the frequency of the tone over time. This is what makes a jump sound like upward motion and a laser sound like downward energy. The pitch envelope is often the single most important parameter for identifying a sound's character.
Every 8-bit sound effect in a game is some combination of these three elements with different parameter values. A coin is a short tone with a small upward pitch slide. A jump is a longer tone with a large upward slide. A laser is a very short tone with a fast downward slide.
Generating a Square Wave in JavaScript
The most direct way to generate an 8-bit sound in a browser is with the Web Audio API. An oscillator node produces the square wave, a gain node shapes the envelope, and the two are connected to the output.
The following function generates and plays a coin sound using only these two nodes. It is a complete, working implementation.
function playCoin() {
const ctx = new AudioContext();
const now = ctx.currentTime;
// First note
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.type = 'square';
osc1.frequency.setValueAtTime(988, now);
gain1.gain.setValueAtTime(0, now);
gain1.gain.linearRampToValueAtTime(0.3, now + 0.005);
gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
osc1.connect(gain1);
gain1.connect(ctx.destination);
osc1.start(now);
osc1.stop(now + 0.08);
// Second note
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.type = 'square';
osc2.frequency.setValueAtTime(1319, now + 0.11);
gain2.gain.setValueAtTime(0, now + 0.11);
gain2.gain.linearRampToValueAtTime(0.3, now + 0.115);
gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.23);
osc2.connect(gain2);
gain2.connect(ctx.destination);
osc2.start(now + 0.11);
osc2.stop(now + 0.25);
}
The two frequencies are a fourth apart, which is the interval that makes a coin sound read as a reward. The first note is shorter than the second, and the gap between them is what separates them so the ear hears a phrase rather than a single rough tone. The design logic for that interval and timing is covered in more detail in How to Create a Coin Sound Effect.
Three details in the code are worth noting. First, the gain starts at zero and ramps up over 5 milliseconds rather than starting at full volume, which prevents the click that occurs when an oscillator begins at full amplitude. Second, the decay uses an exponential ramp rather than a linear one, because that matches the way physical sounds fall off. Third, the exponential ramp targets 0.001 rather than 0 because an exponential curve cannot reach zero.
Adding a Pitch Slide
The coin uses a fixed frequency for each note. A jump or laser needs the frequency to change during the note. This is done with the same scheduling methods used for the gain envelope, applied to the oscillator's frequency parameter.
A jump sound uses a fast upward slide. The frequency starts low and ramps to a higher value over the duration of the note.
function playJump() {
const ctx = new AudioContext();
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(220, now);
osc.frequency.exponentialRampToValueAtTime(660, now + 0.15);
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.3, now + 0.005);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.18);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.2);
}
The laser is the same structure with the slide reversed and the duration shortened.
function playLaser() {
const ctx = new AudioContext();
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(1200, now);
osc.frequency.exponentialRampToValueAtTime(200, now + 0.08);
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.25, now + 0.003);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.1);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.12);
}
The three sounds share the same node structure. The differences are the frequency values, the direction of the slide, and the durations. This is the pattern that makes code-generated 8-bit sounds efficient: one oscillator and one gain node, with different numbers passed in.
The Same Sounds in C# for Unity
Unity does not have a built-in oscillator node in the same
way the Web Audio API does, but it does provide
AudioClip.SetData, which fills an audio clip
with sample data from an array. This is the hook for
procedural audio.
The approach is to generate an array of float values between -1.0 and 1.0, then pass that array to a new AudioClip. The math is the same as the JavaScript version; only the API for creating the clip and playing it is different.
using UnityEngine;
public class RetroSfx : MonoBehaviour
{
public AudioSource source;
public void PlayCoin()
{
source.PlayOneShot(
GenerateTone(988, 0.06f, 0.3f)
);
Invoke(nameof(PlayCoinSecond), 0.11f);
}
private void PlayCoinSecond()
{
source.PlayOneShot(
GenerateTone(1319, 0.12f, 0.3f)
);
}
private AudioClip GenerateTone(
float frequency,
float duration,
float volume
)
{
int sampleRate = 44100;
int sampleCount = (int)(sampleRate * duration);
float[] samples = new float[sampleCount];
for (int i = 0; i < sampleCount; i++)
{
float t = (float)i / sampleRate;
float envelope = Mathf.Exp(-t * 30f);
float value = Mathf.Sign(
Mathf.Sin(2f * Mathf.PI * frequency * t)
) * envelope * volume;
samples[i] = value;
}
AudioClip clip = AudioClip.Create(
"sfx",
sampleCount,
1,
sampleRate,
false
);
clip.SetData(samples, 0);
return clip;
}
}
The square wave is produced by taking the sign of the sine value, which returns +1 or -1 depending on whether the sine is positive or negative. The envelope is an exponential decay applied to the amplitude. The result is an array of samples that produces a short blip when played.
The full procedural audio workflow in Unity, including how to cache generated clips and avoid regenerating them on every play, is a larger topic. The C# port of the sfxr generator known as usfxr handles much of this infrastructure and is worth looking at if the project needs a broader set of effects.
Using a Library Instead of Writing From Scratch
Writing the oscillator and envelope math manually is useful for understanding how the sounds work, but it is not the only option. Several libraries implement the same logic with a higher-level API.
jsfxr is a JavaScript port of the original sfxr generator. It provides preset algorithms for coin, laser, explosion, power-up, hit, jump and other common effects, and accepts a parameter object or a compressed string that defines the sound. The presets are a starting point; the parameter object can be adjusted to tune the result.
usfxr is the C# port for Unity. It generates audio in real time on a non-blocking thread, caches the result, and provides an in-editor interface for testing effects before using them in code. It supports the same parameter set as the original sfxr, plus additional waveform types and filters from BFXR.
8bit-sound-engine is a lightweight Web Audio library that generates both sound effects and background music from a JSON definition. It exposes square, triangle, sawtooth and noise channels, and includes built-in presets for jump, coin, damage, powerup, laser and other effects.
The tradeoff between writing from scratch and using a library is control versus speed. A library gives you a working sound in a few lines of code. Writing from scratch gives you direct control over every parameter and no dependency. For a project that needs a handful of sounds, either approach works. For a project that needs a broad set with runtime variation, the library is usually the faster path.
Tuning the Parameters by Ear
The code produces a sound from a set of numbers. The difference between a good coin and a bad one is in the numbers, and the only way to find the right values is to listen and adjust.
A few parameter relationships are consistent across implementations.
- Attack time. Keep it under 5 milliseconds for almost every 8-bit effect. A slow attack softens the sound and makes it feel less immediate.
- Decay time. 40 to 200 milliseconds for most effects. Longer decays start to sound like sustained tones rather than short effects.
- Pitch slide direction. Upward for reward and movement. Downward for attack and energy discharge. Reversing the direction changes the meaning of the sound.
- Pitch slide speed. Faster slides sound more aggressive. A slide that completes in under 50 milliseconds is sharp; one that takes 150 milliseconds is smoother and more melodic.
- Waveform. Square for most effects. Sawtooth for a harsher, more aggressive character. Triangle for a softer, more rounded sound. Noise for impacts and explosions.
The most efficient way to tune a sound is to change one parameter at a time and listen to the result. Changing multiple parameters at once makes it impossible to know which change produced the result.
Caching and Performance
Generating a sound in code is not free. Each sample involves a sine calculation, an envelope calculation and a write to a buffer. At 44100 samples per second, a one-second sound requires 44100 calculations. Generating that sound on every play is wasteful when the parameters have not changed.
The standard solution is to generate the sample array once, cache it, and reuse the cached buffer on subsequent plays. The sound is only regenerated when its parameters change, which in most games is never.
In the Web Audio API, the pattern is to generate the
AudioBuffer once and reuse it across multiple
AudioBufferSourceNode instances. In Unity,
AudioClip.SetData is called once when the clip
is created, and the same clip is played repeatedly.
The performance cost of a code-generated sound is higher than playing a pre-recorded WAV file, because the audio is computed rather than read from memory. On desktop hardware the difference is negligible for a handful of effects. On mobile, it is worth measuring, especially if many sounds are playing simultaneously. The general approach to runtime audio performance on mobile is covered in How to Optimize Game Sound Effects for Mobile Devices.
Exporting Code-Generated Sounds as WAV Files
A code-generated sound does not have to stay in code. The sample array that produces the sound can be written to a WAV file, which makes the sound portable to engines and tools that cannot run the generation code.
In JavaScript, an OfflineAudioContext can render
the sound faster than real time and produce an
AudioBuffer that can be converted to a WAV
blob. In C#, the same float array passed to
AudioClip.SetData can be written to a WAV file
with a small amount of additional code for the file header.
The reason to export is compatibility. A browser game can generate and play sounds directly. A Unity or Unreal project needs a file. Generating the sound in code during development, then exporting it as a WAV for the final build, combines the speed of code generation with the compatibility of a standard audio format. The cleanup and export steps for the resulting file are covered in How to Use Audacity to Make Game Sound Effects.
Common Mistakes
- Starting the oscillator at full amplitude. Ramp the gain up over a few milliseconds to avoid a click at the start of the sound.
- Using a linear decay instead of exponential. A linear fade sounds like a chopped-off tone. The exponential curve matches physical decay.
- Setting an exponential ramp target to zero. Exponential ramps cannot reach zero. Use a small value like 0.001 instead.
- Regenerating the sound on every play. Cache the sample buffer and reuse it unless the parameters change.
- Making the decay too long. An 8-bit effect that lasts more than about 250 milliseconds stops feeling like a retro sound.
- Using the same waveform for every effect. Square is the default, but noise is better for impacts and sawtooth is better for aggressive sounds.
What to Check Before Shipping
Play the generated sounds in the game, not just in the development console. A coin that sounds correct in isolation can be too bright against music, and a laser that sounds impactful in a preview can become fatiguing when fired repeatedly.
Check the CPU cost on the target platform. Code-generated sounds use more CPU than pre-recorded files, and the difference is larger on mobile. If the game is dropping frames when several effects play at once, consider caching the generated sounds as WAV files and playing them as regular audio clips.
Test with the actual gameplay parameters. If the sound depends on game state, such as a pickup that varies with combo count, test the full range of values to make sure the result is usable at every point. A parameter that produces a good sound at one value can produce something unusable at another.
Generating 8-bit sound effects in code is not the only way to make them, and it is not always the fastest. But it is the most direct: the sound is a set of numbers, the numbers are under your control, and the result is exactly what the code produces. For effects that need to vary at runtime, or for projects where every kilobyte of audio assets matters, that directness is worth the additional setup.