Game Music Systems for the Web
Basic Music Playback
The simplest music implementation loads a track into an AudioBuffer and plays it with looping enabled. Set source.loop = true on the AudioBufferSourceNode and the track repeats indefinitely. Connect it through a dedicated music GainNode so players can control music volume independently from sound effects.
This approach works for tracks under 2-3 minutes, where the memory cost of holding the fully decoded PCM data is acceptable. A 3-minute stereo track at 44.1kHz occupies roughly 30MB of decoded audio data. For a game with a single music track, this is fine. For a game with 10+ tracks, the memory adds up fast.
For longer tracks or larger music libraries, streaming through a MediaElementAudioSourceNode is more memory-efficient. Create an <audio> element, set its source to the music file URL, create a MediaElementAudioSourceNode from it using ctx.createMediaElementSource(audioElement), and connect that node to your music gain chain. The browser handles buffering and decoding progressively, using far less memory than a fully decoded AudioBuffer.
The tradeoff with streaming is slightly less precise timing control. AudioBufferSourceNode gives you sample-accurate start times and loop points. MediaElementAudioSourceNode relies on the media element's playback, which has minor timing imprecision. For background music this is rarely noticeable, but for rhythm-critical applications, buffer-based playback is safer.
Seamless Looping
The most common music complaint in web games is an audible gap or click when the track loops. This happens when the audio file is not trimmed precisely to a zero-crossing point, leaving a discontinuity at the loop boundary.
The first fix is at the source file level. The music track should be authored with looping in mind. The last sample should transition smoothly into the first sample. If you are working with a composer, specify that the track needs a seamless loop. If you are using royalty-free music, test the loop in an audio editor before integrating it.
The Web Audio API provides loopStart and loopEnd properties on AudioBufferSourceNode. These define the region within the buffer that loops. The common pattern is to have an intro section (bars 1-8) that plays once, followed by a loop section (bars 9-24) that repeats. Set loopStart to the start time of bar 9 and loopEnd to the end time of bar 24. The intro plays through, then the loop section repeats until the track is stopped.
Calculating precise loop points requires knowing the tempo and time signature of your music. At 120 BPM in 4/4 time, each beat is 0.5 seconds, each bar is 2 seconds. An 8-bar intro at 120 BPM starts looping at exactly 16.0 seconds. A 16-bar loop section ends at 48.0 seconds. These values go directly into loopStart and loopEnd.
For tracks where you do not control the source material, a short crossfade at the loop point can mask imperfect loops. Schedule a brief volume dip (50ms ramp down, 50ms ramp up) at the loop boundary using the GainNode's automation methods. This is a hack, but it works surprisingly well for tracks that almost loop cleanly.
Crossfading Between Tracks
When the game state changes, the music needs to change with it. Walking from a peaceful village into a dungeon, entering combat, reaching a boss fight, completing a level, each transition calls for different music. Abrupt cuts sound jarring. Crossfading creates smooth transitions.
A basic crossfade uses two music channels with independent GainNodes. The outgoing track ramps its gain to 0 over a crossfade duration (typically 1-3 seconds). Simultaneously, the incoming track starts playing with its gain at 0 and ramps to 1 over the same duration. Both tracks play simultaneously during the crossfade period, creating a blend.
The Web Audio API makes crossfading simple with scheduled gain automation. When a transition triggers, calculate the target time: const fadeEnd = ctx.currentTime + fadeDuration. On the outgoing gain: outGain.gain.setValueAtTime(1, ctx.currentTime) then outGain.gain.linearRampToValueAtTime(0, fadeEnd). On the incoming gain: inGain.gain.setValueAtTime(0, ctx.currentTime) then inGain.gain.linearRampToValueAtTime(1, fadeEnd).
After the crossfade completes, stop the outgoing source node and disconnect it from the graph to free resources. Schedule this cleanup using setTimeout set to the fade duration plus a small buffer. Forgetting this step means old music tracks accumulate in the audio graph, wasting processing power and memory.
For exponential crossfades that sound more natural (because human hearing is logarithmic), use exponentialRampToValueAtTime() instead. Note that exponential ramps cannot target a value of 0, so use a very small value like 0.001 and then set to 0 at the end time: outGain.gain.exponentialRampToValueAtTime(0.001, fadeEnd) followed by outGain.gain.setValueAtTime(0, fadeEnd).
Layer-Based Music
Vertical layering is the technique AAA games use for adaptive music, and it works just as well in web games. Instead of a single music file, the composer creates multiple synchronized stems: a pad layer, a percussion layer, a melody layer, a bass layer, and possibly stinger layers for specific events.
All stems are the same length and start simultaneously. Each has its own GainNode. The music system fades stems in and out based on game state. In a peaceful area, only the pad and light melody play. When enemies appear, the percussion and bass fade in. During a boss fight, all layers play at full volume. The transitions feel organic because the music never stops or restarts, it just changes intensity.
The stems must be perfectly synchronized, which means they need to start from the same buffer source at the same AudioContext time. Load all stems as AudioBuffers, create source nodes for each, and start them all with the same when time: const startTime = ctx.currentTime; stems.forEach(s => s.start(startTime)). Because they share the same start time on the audio clock, they remain sample-locked.
Memory is the main cost. Four stereo stems of a 2-minute track at 44.1kHz consume about 80MB of decoded audio. For web games, consider using mono stems (cutting memory in half), shorter loop lengths (a 30-second loop is plenty for most games), or lower sample rates for layers that lack high-frequency content (the bass layer does not need 44.1kHz).
A simpler variation uses two layers: "calm" and "intense." The calm layer plays during exploration. When combat starts, the intense layer fades in on top. This requires only two stems and delivers 80% of the impact of a full multi-layer system.
Music State Management
A music manager object tracks the current music state and handles transitions. It holds references to the current playing sources, their gain nodes, the active track name, and the game state-to-track mapping. When the game state changes, the manager looks up the appropriate track, determines the transition type (crossfade, hard cut, or layer change), and executes it.
State transitions should be debounced. If the player enters and exits combat rapidly, you do not want the music to crossfade back and forth every second. A simple timer that requires the game to stay in a new state for 2-3 seconds before committing to a music change prevents this thrashing.
Stingers are short musical phrases that play on top of the current music to emphasize specific events: discovering a secret area, defeating a mini-boss, picking up a rare item. They play as one-shot sounds routed through the music gain chain (so they respect the music volume slider) but do not interrupt the current track. Keep stingers under 5 seconds and let them play out regardless of subsequent game state changes.
Save and restore the music state when the game is paused, minimized, or loses focus. On the visibilitychange event, ramp all music to silence over 200ms when the tab becomes hidden, and ramp back to the saved volume when it becomes visible. This prevents music from blaring when a player returns to a tab they forgot was open.
Sourcing Game Music
Free options include Kevin MacLeod's Incompetech library (hundreds of royalty-free tracks across every genre), OpenGameArt.org's music section (community-contributed game music under Creative Commons), and the Free Music Archive. These provide perfectly usable music for jam games, prototypes, and indie releases.
Commercial music libraries like Epidemic Sound, Artlist, and Musicbed offer higher production quality with clear licensing terms for games. Prices typically run $10-30 per track for single-project licenses. For a web game with 5-10 tracks, the total cost is modest compared to commissioning custom music.
AI music generation tools can produce custom tracks tuned to specific moods, tempos, and styles. Tools like Suno and Udio generate full compositions from text prompts. The quality is improving rapidly and is already sufficient for many indie game contexts. The licensing landscape for AI-generated music is still evolving, so review the terms of whichever tool you use.
For the highest quality, commission a composer. Game audio freelancers on platforms like Fiverr, SoundBetter, and the Game Audio Network Guild charge anywhere from $50 to $500+ per minute of music depending on complexity and experience. Specify that you need stems (separate instrument layers) if you plan to implement adaptive music, as this is not included by default.
Use AudioBufferSourceNode with loopStart/loopEnd for seamless music loops, crossfade between tracks using scheduled GainNode automation, and consider layered stems for adaptive music that responds to gameplay. Stream long tracks via MediaElementAudioSourceNode to conserve memory.