Create and modify procedural audio (music, SFX, footsteps) using Web Audio API in 2D&D
All audio in 2D&D is synthesized at runtime via the Web Audio API — no external audio files.
The system lives in src/systems/audio.ts (~1,300 lines).
import { audioEngine } from "../systems/audio";
// Must be called from a user gesture (click/keydown) — browsers block autoplay
audioEngine.init();
AudioContext.destination
└── masterGain (volume * muted)
├── musicGain (music tracks)
├── sfxGain (attack, chest, dungeon, potion SFX)
├── dialogGain (NPC dialogue blips)
└── footstepGain (terrain footsteps, very low volume)
All volume settings are persisted to localStorage under key 2dnd_audio_prefs.
The engine loads saved preferences on construction and saves after every setter call.
audioEngine.setMasterVolume(0.8); // Affects all channels
audioEngine.setMusicVolume(0.6); // Music only
audioEngine.setSFXVolume(0.4); // SFX + footsteps
audioEngine.setDialogVolume(0.5); // Dialog blips
audioEngine.toggleMute(); // All persisted to localStorage
Six scales define the musical character of each location:
| Scale | Mood | Used For |
|---|---|---|
MAJOR_PENTA | Happy, bright | Grasslands, villages, highlands |
MINOR_PENTA | Melancholic | Frozen, ancient, mystical areas |
HARMONIC_MINOR | Exotic, desert | Arid, canyon, title screen |
DIMINISHED | Eerie, unsettling | Swamp, murky areas |
NATURAL_MINOR | Dark, moody | Scorched, volcanic, industrial |
PHRYGIAN_DOM | Tense, intense | Boss fights, battle theme |
Every music track is driven by a BiomeProfile:
interface BiomeProfile {
baseNote: number; // Semitone offset from A4 (440Hz)
scale: Scale; // Array of semitone intervals
bpm: number; // Beats per minute
wave: OscillatorType; // Lead oscillator: "sine" | "square" | "sawtooth" | "triangle"
padWave: OscillatorType; // Bass/pad oscillator type
}
Add to BIOME_PROFILES record. The key must match the first word of chunk names:
export const BIOME_PROFILES: Record<string, BiomeProfile> = {
// ...existing entries...
Mystic: { baseNote: 3, scale: HARMONIC_MINOR, bpm: 74, wave: "triangle", padWave: "sine" },
};
Add to BOSS_OVERRIDES with the boss monster's ID as key:
const BOSS_OVERRIDES: Record<string, Partial<BiomeProfile>> = {
// ...existing entries...
ancientLich: { baseNote: -12, bpm: 130, scale: DIMINISHED, wave: "square", padWave: "sawtooth" },
};
Add to CITY_OVERRIDES with the city name as key:
const CITY_OVERRIDES: Record<string, Partial<BiomeProfile>> = {
// ...existing entries...
Starhaven: { baseNote: 7, bpm: 110, scale: MAJOR_PENTA, wave: "sine", padWave: "triangle" },
};
Every track automatically layers these instruments via playNote():
wave type at the melody frequencypadWave at half frequency, every other beatMajor scales automatically shift to their relative minor at night. Already-minor scales drop the root by 2–3 semitones for a darker feel.
SFX methods follow this pattern using the sfxGain node:
playNewSFX(): void {
if (!this.ctx || !this.sfxGain) return;
const ctx = this.ctx;
const dest = this.sfxGain;
// Create oscillators/noise, connect through gain nodes to dest
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sine";
osc.frequency.value = 440;
gain.gain.setValueAtTime(0.15, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.2);
osc.connect(gain);
gain.connect(dest);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.25);
}
| Method | Sound Design | Trigger |
|---|---|---|
playAttackSFX() | Swoosh + impact thump + metallic clang | Normal melee/spell hit |
playMissSFX() | Airy highpass whoosh + descending pitch | Missed attack |
playCriticalHitSFX() | Deep slam + noise crunch + rising sting + bell | Critical hit (nat 20) |
playChestOpenSFX() | 4-note ascending twinkle + shimmer | Opening a chest |
playDungeonEnterSFX() | Deep boom + eerie tone + stone scrape | Entering a dungeon |
playPotionSFX() | 3 glug bubbles + healing shimmer | Using a consumable |
playFootstepSFX(terrain) | Filtered noise burst, varies by terrain | Every player step |
playDialogueBlip(pitch) | Quick square wave blip | NPC dialogue (future) |
The playFootstepSFX(terrainType) method uses the Terrain enum value to pick filter parameters:
Weather SFX use looping noise buffers routed through sfxGain:
Audio tests verify the API surface and state (no actual audio output in test env):
expect(typeof audioEngine.playAttackSFX).toBe("function");
expect(() => audioEngine.playAttackSFX()).not.toThrow(); // no-op without AudioContext
audioEngine.init() outside a user gesture — browsers will block itplayAllSounds() demo and test file