This is the final part of our Frogger series. In Part 5, we added level progression with faster traffic and shorter timers. Now we bring the game to life with sound effects. We will use the Web Audio API to generate all sounds programmatically — no audio files needed. Every hop, splash, and level-up gets its own distinct sound.
The Web Audio API requires an AudioContext. Browsers block audio until the user interacts with the page, so we create the context on the first click or keypress. This is the same pattern used in every browser game.
let audioCtx = null;
let muted = false;
function initAudio() {
if (!audioCtx) {
audioCtx = new (window.AudioContext
|| window.webkitAudioContext)();
}
}
// Initialize on first user interaction
document.addEventListener('keydown', initAudio,
{ once: true });
document.addEventListener('click', initAudio,
{ once: true });
document.addEventListener('touchstart', initAudio,
{ once: true });
touchstart in addition to click and keydown. On mobile, the first touch needs to unlock audio. The { once: true } option ensures the listener removes itself after firing — no wasted event checks on every subsequent interaction.
Every time the frog moves one tile, play a short blip. This gives satisfying tactile feedback for each hop. The sound is a quick high-pitched square wave that decays rapidly.
function playHop() {
if (!audioCtx || muted) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.type = 'square';
osc.frequency.value = 480;
const t = audioCtx.currentTime;
gain.gain.setValueAtTime(0.12, t);
gain.gain.exponentialRampToValueAtTime(
0.001, t + 0.06);
osc.start(t);
osc.stop(t + 0.06);
}
// Call in the keydown handler:
document.addEventListener('keydown', (e) => {
if (gameOver || deathAnim || levelTransition)
return;
let moved = false;
switch (e.key) {
case 'ArrowUp':
if (frog.row > 0) { frog.row--; moved = true; }
break;
case 'ArrowDown':
if (frog.row < ROWS - 1) { frog.row++; moved = true; }
break;
case 'ArrowLeft':
if (frog.col > 0) { frog.col--; moved = true; }
break;
case 'ArrowRight':
if (frog.col < COLS - 1) { frog.col++; moved = true; }
break;
}
if (moved) playHop();
});
The hop sound is only 60ms long — barely noticeable on its own, but it makes every movement feel responsive. The square wave gives it a retro arcade character that fits the Frogger aesthetic.
When the frog drowns or gets hit by a car, play a descending tone. The pitch drops from high to low, creating a "falling" feeling that signals failure.
function playSplash() {
if (!audioCtx || muted) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.type = 'sine';
const t = audioCtx.currentTime;
// Descending pitch: 500Hz → 80Hz
osc.frequency.setValueAtTime(500, t);
osc.frequency.exponentialRampToValueAtTime(
80, t + 0.5);
gain.gain.setValueAtTime(0.2, t);
gain.gain.exponentialRampToValueAtTime(
0.001, t + 0.5);
osc.start(t);
osc.stop(t + 0.5);
}
// Add noise burst for car hit variant
function playCarHit() {
if (!audioCtx || muted) return;
// Impact thud
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.type = 'sawtooth';
const t = audioCtx.currentTime;
osc.frequency.setValueAtTime(150, t);
osc.frequency.exponentialRampToValueAtTime(
40, t + 0.3);
gain.gain.setValueAtTime(0.25, t);
gain.gain.exponentialRampToValueAtTime(
0.001, t + 0.3);
osc.start(t);
osc.stop(t + 0.3);
}
When the frog reaches a lily pad, play an ascending sweep that feels rewarding. The pitch rises from low to high, the opposite of the death sound — success feels like going up.
function playGoal() {
if (!audioCtx || muted) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.type = 'square';
const t = audioCtx.currentTime;
// Ascending pitch: 300Hz → 900Hz
osc.frequency.setValueAtTime(300, t);
osc.frequency.exponentialRampToValueAtTime(
900, t + 0.25);
gain.gain.setValueAtTime(0.15, t);
gain.gain.exponentialRampToValueAtTime(
0.001, t + 0.3);
osc.start(t);
osc.stop(t + 0.3);
}
The goal sound is 300ms — long enough to feel celebratory but short enough to not overlap with the next action. The ascending pitch creates a natural "success" feeling that players universally understand.
When all goal slots are filled and the player advances to the next level, play a three-note ascending melody. This is more elaborate than the single-goal sound, marking it as a bigger achievement.
function playLevelUp() {
if (!audioCtx || muted) return;
const notes = [440, 554, 659]; // A4, C#5, E5
const dur = 0.15;
notes.forEach((freq, i) => {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.type = 'square';
osc.frequency.value = freq;
const t = audioCtx.currentTime + i * dur;
gain.gain.setValueAtTime(0.15, t);
gain.gain.exponentialRampToValueAtTime(
0.001, t + dur);
osc.start(t);
osc.stop(t + dur);
});
}
Players need a way to mute sounds. We add a simple toggle with the M key. On mobile, we also need to handle the audio unlock pattern — the first touch must resume the AudioContext if it was suspended.
// Mute toggle with M key
document.addEventListener('keydown', (e) => {
if (e.key === 'm' || e.key === 'M') {
muted = !muted;
}
});
// Mobile audio unlock
function unlockAudio() {
if (audioCtx &&
audioCtx.state === 'suspended') {
audioCtx.resume();
}
}
document.addEventListener('touchstart',
unlockAudio, { once: true });
// Draw mute indicator on HUD
function drawMuteIndicator() {
ctx.fillStyle = '#888';
ctx.font = '12px "Segoe UI", sans-serif';
ctx.textAlign = 'right';
ctx.fillText(
muted ? '🔇 M to unmute' : '🔊 M to mute',
WIDTH - 8, HEIGHT - 8
);
}
The mute indicator sits in the bottom-right corner of the canvas. It shows the current state and reminds the player which key toggles it. On mobile, the touchstart listener calls audioCtx.resume() to unlock audio that was suspended by the browser's autoplay policy.
Here is where each sound gets called in the game code:
// In keydown handler (after movement):
if (moved) playHop();
// In onFrogDeath():
function onFrogDeath(type) {
if (type === 'hit') playCarHit();
else playSplash();
lives--;
// ... rest of death logic
}
// In awardGoal():
function awardGoal() {
playGoal();
// ... scoring logic
if (filledGoals.length >= goalSlots.length) {
playLevelUp();
startNextLevel();
}
}
// In drawHUD() — add at the end:
drawMuteIndicator();
Congratulations — you have built a complete Frogger game from scratch! Over 6 parts we covered:
From here, you can keep adding features: mobile swipe controls, animated sprites, turtles that dive underwater, bonus items on logs, or a two-player mode. The foundation is solid — have fun building on it.