Skip to content

Tone.js for Game Audio

Tone.js sits on top of the Web Audio API and makes synthesized audio practical. Instead of loading audio files, you generate sounds programmatically. This means zero load time, infinite variations, and sounds that can react to gameplay in real time.

Try it (click buttons to hear synthesized game sounds):

Why Tone.js Instead of Audio Files

Audio files work fine for music and complex sounds. But for game sound effects, synthesis has real advantages. You can vary pitch and timing on every play so sounds don't feel repetitive. You can generate sounds on the fly based on game state. And you skip the network requests entirely.

The downside is that synthesized audio takes more CPU than playing a file. For simple games this isn't a problem. For complex games, you might mix both approaches.

Setup

Load Tone.js from a CDN or npm:

html
<script src="https://unpkg.com/tone@15/build/Tone.js"></script>

Or with npm:

bash
npm install tone
js
import * as Tone from 'tone'

The Autoplay Problem

Browsers block audio until user interaction. Tone.js handles this, but you need to call Tone.start() on a user gesture:

js
document.addEventListener('click', async () => {
  await Tone.start()
  console.log('Audio ready')
}, { once: true })

Without this, your AudioContext stays suspended and nothing plays.

Your First Synth

A synth generates sound. The simplest one is Tone.Synth:

js
const synth = new Tone.Synth().toDestination()

// Play a note
synth.triggerAttackRelease('C4', '8n')

triggerAttackRelease plays a note for a duration. 'C4' is middle C. '8n' is an eighth note at the current tempo.

The synth has an oscillator (the sound source) and an envelope (how the sound fades in and out). You can customize both:

js
const synth = new Tone.Synth({
  oscillator: { type: 'triangle' },
  envelope: {
    attack: 0.02,
    decay: 0.2,
    sustain: 0.3,
    release: 0.4,
  },
}).toDestination()

Oscillator types include sine (pure, mellow), triangle (soft), sawtooth (buzzy), and square (hollow, retro).

Building a Sound Effects System

For a game, you want multiple synths for different sound types. Here's a pattern that works well:

js
let isInitialized = false
let softSynth = null
let subSynth = null
let noiseSynth = null
let sfxGain = null

function initSFX() {
  if (isInitialized) return
  
  // Master volume control
  sfxGain = new Tone.Gain(0.7).toDestination()
  
  // Soft synth for melodic sounds
  softSynth = new Tone.PolySynth(Tone.Synth, {
    maxPolyphony: 6,
    oscillator: { type: 'sine' },
    envelope: {
      attack: 0.02,
      decay: 0.2,
      sustain: 0.3,
      release: 0.4,
    },
    volume: -8,
  }).connect(sfxGain)
  
  // Sub bass for deep impacts
  subSynth = new Tone.Synth({
    oscillator: { type: 'sine' },
    envelope: {
      attack: 0.02,
      decay: 0.3,
      sustain: 0.1,
      release: 0.3,
    },
    volume: -8,
  }).connect(sfxGain)
  
  // Noise for texture
  noiseSynth = new Tone.NoiseSynth({
    noise: { type: 'pink' },
    envelope: {
      attack: 0.02,
      decay: 0.15,
      sustain: 0,
      release: 0.15,
    },
    volume: -20,
  }).connect(sfxGain)
  
  isInitialized = true
}

PolySynth lets you play multiple notes at once. Regular Synth is monophonic.

Game Sound Examples

Here are some common game sounds and how to create them:

Coin collect - ascending notes feel rewarding:

js
function coinCollect() {
  const now = Tone.now()
  softSynth.triggerAttackRelease('C4', '8n', now, 0.3)
  softSynth.triggerAttackRelease('E4', '8n', now + 0.1, 0.35)
  softSynth.triggerAttackRelease('G4', '4n', now + 0.2, 0.4)
}

Jump - quick upward sweep:

js
function jump() {
  const synth = new Tone.Synth({
    oscillator: { type: 'square' },
    envelope: { attack: 0.01, decay: 0.1, sustain: 0, release: 0.1 },
  }).toDestination()
  
  const now = Tone.now()
  synth.frequency.setValueAtTime(150, now)
  synth.frequency.exponentialRampToValueAtTime(400, now + 0.1)
  synth.triggerAttackRelease('C4', '8n', now, 0.2)
}

Hit/damage - descending with noise:

js
function hit() {
  const now = Tone.now()
  subSynth.triggerAttackRelease('E2', '4n', now, 0.5)
  noiseSynth.triggerAttackRelease('4n', now, 0.25)
}

Death - sad descending notes:

js
function death() {
  const now = Tone.now()
  softSynth.triggerAttackRelease('E4', '4n', now, 0.4)
  softSynth.triggerAttackRelease('C4', '4n', now + 0.2, 0.35)
  softSynth.triggerAttackRelease('A3', '4n', now + 0.4, 0.3)
}

Adding Effects

Effects make sounds richer. Chain them between the synth and destination:

js
const filter = new Tone.Filter({
  frequency: 3000,
  type: 'lowpass',
  rolloff: -12,
})

const reverb = new Tone.Reverb({
  decay: 1.2,
  wet: 0.25,
})

const gain = new Tone.Gain(0.7)

// Chain: synth -> filter -> reverb -> gain -> destination
filter.connect(reverb)
reverb.connect(gain)
gain.toDestination()

const synth = new Tone.Synth().connect(filter)

Lowpass filter cuts harsh high frequencies. Reverb adds space. Gain controls volume.

Rate Limiting

If your game triggers many sounds rapidly, they can overlap and create a mess. Add rate limiting:

js
const MIN_INTERVAL_MS = 35
let lastNoteTime = 0

function canPlayNote() {
  const now = performance.now()
  if (now - lastNoteTime < MIN_INTERVAL_MS) {
    return false
  }
  lastNoteTime = now
  return true
}

function playSound() {
  if (!canPlayNote()) return
  synth.triggerAttackRelease('C4', '8n')
}

Dynamic Background Music

For music that reacts to gameplay, use a beat-based system:

js
const BPM = 140
const BEAT_MS = (60 / BPM) * 1000

let bassSynth = null
let kickDrum = null
let beatInterval = null
let beatCount = 0
let energy = 0.3

const PROGRESSION = [
  { root: 'E2', chord: ['E3', 'G3', 'B3'] },
  { root: 'C2', chord: ['C3', 'E3', 'G3'] },
  { root: 'D2', chord: ['D3', 'F#3', 'A3'] },
  { root: 'A1', chord: ['A2', 'C3', 'E3'] },
]

function playBeat() {
  const beatInMeasure = beatCount % 8
  const chordIndex = Math.floor(beatCount / 8) % PROGRESSION.length
  const chord = PROGRESSION[chordIndex]
  const now = Tone.now()
  
  // Bass on beat 1
  if (beatInMeasure === 0) {
    bassSynth.triggerAttackRelease(chord.root, '4n', now, 0.6)
  }
  
  // Kick drum - only when energy is high
  if (beatInMeasure === 0 && energy > 0.3) {
    kickDrum.triggerAttackRelease('C1', '8n', now, 0.4)
  }
  
  beatCount++
}

function startMusic() {
  beatInterval = setInterval(playBeat, BEAT_MS)
}

function stopMusic() {
  clearInterval(beatInterval)
}

// Game events affect energy
function onEnemyKill() {
  energy = Math.min(1, energy + 0.1)
}

The music intensity changes based on what's happening in the game.

Drums with MembraneSynth and NoiseSynth

For percussion, use specialized synths:

js
// Kick drum
const kick = new Tone.MembraneSynth({
  pitchDecay: 0.05,
  octaves: 4,
  envelope: {
    attack: 0.001,
    decay: 0.2,
    sustain: 0,
    release: 0.1,
  },
  volume: -14,
}).toDestination()

kick.triggerAttackRelease('C1', '8n')

// Snare
const snare = new Tone.NoiseSynth({
  noise: { type: 'white' },
  envelope: {
    attack: 0.002,
    decay: 0.15,
    sustain: 0,
    release: 0.1,
  },
  volume: -12,
}).toDestination()

snare.triggerAttackRelease('8n')

// Hi-hat
const hihat = new Tone.NoiseSynth({
  noise: { type: 'white' },
  envelope: {
    attack: 0.001,
    decay: 0.04,
    sustain: 0,
    release: 0.03,
  },
  volume: -22,
}).toDestination()

hihat.triggerAttackRelease('32n')

Cleanup

Dispose of synths when you're done to free resources:

js
function cleanup() {
  if (softSynth) softSynth.dispose()
  if (subSynth) subSynth.dispose()
  if (noiseSynth) noiseSynth.dispose()
  if (sfxGain) sfxGain.dispose()
}

Common Mistakes

Not waiting for Tone.start() - Audio won't play until the context is started on user interaction.

Creating synths on every sound - Create synths once at init, reuse them. Creating new ones every time leaks memory.

Too many overlapping sounds - Use rate limiting and deduplication to prevent audio mush.

Harsh frequencies - Use lowpass filters and softer oscillator types (sine, triangle) for sounds that won't annoy players.

More Resources

Web Audio API for games covers the underlying API and loading audio files.

Tone.js documentation has the full API reference.