In Part 5, we added mobile swipe controls. Now for the finishing touch — sound. A short rising tone when eating food, a descending buzz on death, a cheerful jingle when leveling up — these tiny audio cues make the game feel alive. In this final part of the Snake tutorial series, we use the Web Audio API to generate all sounds from code. No audio files needed.
The Web Audio API starts with an AudioContext. This is the central hub that creates and connects audio nodes. We create one context and reuse it for all sounds.
let audioCtx = null;
let isMuted = false;
function initAudio() {
if (!audioCtx) {
audioCtx = new (window.AudioContext
|| window.webkitAudioContext)();
}
// Resume if suspended (mobile browsers)
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
}
function playTone(freq, duration, type, ramp) {
if (!audioCtx || isMuted) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type || 'square';
osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
if (ramp) {
osc.frequency.linearRampToValueAtTime(
ramp, audioCtx.currentTime + duration
);
}
gain.gain.setValueAtTime(0.15, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(
0.001, audioCtx.currentTime + duration
);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + duration);
}
The playTone() helper creates a single oscillator with optional frequency ramping. The gain node fades the sound out so it does not click when it stops. This one function powers all our sound effects.
exponentialRampToValueAtTime for the gain fade-out. Stopping an oscillator abruptly causes an audible click. The ramp smooths the ending.
The eat sound is a short rising tone — it starts at a low frequency and ramps up. This creates a satisfying "boop" that feels rewarding.
function playEatSound() {
playTone(300, 0.1, 'square', 600);
}
// Call in checkFood() when snake eats:
function checkFood() {
const head = snake[0];
if (head.x === food.x && head.y === food.y) {
snake.push({ ...snake[snake.length - 1] });
food = spawnFood();
score += 10;
addFloatingText(food.x, food.y, '+10');
playEatSound();
}
}
The tone starts at 300Hz and ramps to 600Hz over 0.1 seconds. The square wave gives it a retro, 8-bit character that fits the pixel-style Snake game. Experiment with the frequencies — 200→500 sounds deeper, 400→800 sounds brighter.
The death sound is the opposite of the eat sound — a descending tone that feels like failure. We use a longer duration and a lower target frequency.
function playDeathSound() {
playTone(400, 0.4, 'sawtooth', 80);
}
// Call in the game loop when collision is detected:
if (checkCollision()) {
gameOver = true;
playDeathSound();
drawGameOver();
return;
}
The sawtooth wave sounds harsher than square, which fits the "you died" mood. Starting at 400Hz and dropping to 80Hz over 0.4 seconds creates a descending buzz. The longer duration (0.4s vs 0.1s for eat) gives it more weight — death should feel significant.
When the snake reaches a new speed tier (from Part 3), play a quick ascending jingle — three notes that go up in pitch. This signals progress and feels rewarding.
function playLevelUpSound() {
if (!audioCtx || isMuted) return;
const notes = [440, 554, 659]; // A4, C#5, E5
const noteDuration = 0.12;
notes.forEach((freq, i) => {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(
freq, audioCtx.currentTime + i * noteDuration
);
const startTime = audioCtx.currentTime + i * noteDuration;
gain.gain.setValueAtTime(0.15, startTime);
gain.gain.exponentialRampToValueAtTime(
0.001, startTime + noteDuration
);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(startTime);
osc.stop(startTime + noteDuration);
});
}
// Call when speed tier changes:
function checkFood() {
const head = snake[0];
if (head.x === food.x && head.y === food.y) {
const oldTier = getSpeedTier();
snake.push({ ...snake[snake.length - 1] });
food = spawnFood();
score += 10;
playEatSound();
const newTier = getSpeedTier();
if (newTier !== oldTier) {
playLevelUpSound();
}
}
}
The three notes (A4, C#5, E5) form a major triad — a universally "happy" chord. Each note plays for 120ms, so the entire jingle is 360ms. It is short enough to not interrupt gameplay but distinct enough to notice.
Optional background music adds atmosphere. We create a simple looping pattern using oscillators — a bass note that alternates between two pitches.
let bgmOsc = null;
let bgmGain = null;
function startBGM() {
if (!audioCtx || isMuted || bgmOsc) return;
bgmOsc = audioCtx.createOscillator();
bgmGain = audioCtx.createGain();
bgmOsc.type = 'triangle';
bgmOsc.frequency.setValueAtTime(110, audioCtx.currentTime);
bgmGain.gain.setValueAtTime(0.04, audioCtx.currentTime);
bgmOsc.connect(bgmGain);
bgmGain.connect(audioCtx.destination);
bgmOsc.start();
// Alternate between two notes
scheduleBGMPattern();
}
function scheduleBGMPattern() {
if (!bgmOsc) return;
const now = audioCtx.currentTime;
const beatLen = 0.5;
for (let i = 0; i < 16; i++) {
const freq = i % 2 === 0 ? 110 : 82.41; // A2, E2
bgmOsc.frequency.setValueAtTime(
freq, now + i * beatLen
);
}
// Schedule next batch
setTimeout(scheduleBGMPattern, 16 * beatLen * 1000);
}
function stopBGM() {
if (bgmOsc) {
bgmOsc.stop();
bgmOsc = null;
bgmGain = null;
}
}
Always give players a way to mute. Some people play at work, on public transport, or just prefer silence. A simple button in the corner toggles all audio.
// HTML button
// <button id="mute-btn" class="mute-btn">🔊</button>
const muteBtn = document.getElementById('mute-btn');
muteBtn.addEventListener('click', () => {
isMuted = !isMuted;
muteBtn.textContent = isMuted ? '🔇' : '🔊';
if (isMuted) {
stopBGM();
} else {
startBGM();
}
});
// Also draw mute state on canvas as fallback
function drawMuteIndicator() {
ctx.fillStyle = '#666';
ctx.font = '12px "Segoe UI", sans-serif';
ctx.textAlign = 'right';
ctx.fillText(
isMuted ? '🔇 Muted' : '🔊 Sound On',
WIDTH - 8, HEIGHT - 6
);
}
The button uses emoji for the icon — no image assets needed. When muted, all playTone() calls return early (we check isMuted at the top), and the background music stops. Unmuting restarts the BGM.
Mobile browsers block audio until the user interacts with the page (tap, click, or keypress). This is a security feature to prevent auto-playing ads. We handle it by initializing audio on the first user interaction.
let audioUnlocked = false;
function unlockAudio() {
if (audioUnlocked) return;
initAudio();
// Play a silent buffer to unlock
const buffer = audioCtx.createBuffer(1, 1, 22050);
const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.connect(audioCtx.destination);
source.start(0);
audioUnlocked = true;
}
// Unlock on first touch or click
document.addEventListener('touchstart', unlockAudio,
{ once: true });
document.addEventListener('click', unlockAudio,
{ once: true });
document.addEventListener('keydown', unlockAudio,
{ once: true });
The trick is playing a silent buffer on the first interaction. This "unlocks" the AudioContext, and all subsequent sounds play normally. The { once: true } option removes the listener after it fires, so we do not waste resources on future events.
Congratulations — you have built a complete Snake game from scratch across six tutorials:
Every concept you learned here — game loops, input handling, collision detection, state persistence, responsive design, audio — applies to any browser game you build next. The Snake game is simple, but the skills are universal.