
Game Audio
- 717 installs
- 305 repo stars
- Updated May 25, 2026
- opusgamelabs/game-creator
Game Audio is a game-creator agent skill that generates low-intensity looping Strudel BGM patterns with cycle alternation and phasing so background music stays subtle during gameplay prototypes.
About
Game Audio is an opusgamelabs/game-creator skill for genre-specific background music using Strudel live-coding patterns. Each pattern layers instruments with stack(), starts loops via .play(), and applies .slow(2-4), .room(), and .delay() so music breathes instead of overpowering gameplay. The skill enforces background-appropriate mixing—rests with ~, sine and triangle waves over square, and gain ranges like 0.10-0.18 for lead melodies and 0.08-0.15 for pads. Developers reach for Game Audio when prototyping indie or jam games that need ambient loops without hiring a composer or loading heavy audio middleware. Outputs are copy-paste Strudel pattern strings tuned for cycle alternation and phasing, ready to drop into web-based game prototypes.
- BGM mix rules: low gains (lead ~0.10–0.18), liberal rests, sine/triangle bias, `.slow(2-4)`, reverb/delay instead of den
- Anti-repetition via `<[phrase1] [phrase2] …>` cycle alternation on every pattern
- Layer phasing with different `.slow()` per stack to desync loops
- Genre-specific background music templates using `stack()` and `.play()`
- Explicit guidance to avoid loud drums except when needed (gain under 0.3)
Game Audio by the numbers
- 717 all-time installs (skills.sh)
- +29 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #33 of 247 Game Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/opusgamelabs/game-creator --skill game-audioAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 717 |
|---|---|
| repo stars | ★ 305 |
| Security audit | 2 / 3 scanners passed |
| Last updated | May 25, 2026 |
| Repository | opusgamelabs/game-creator ↗ |
How do you write subtle looping game BGM?
Generate looping, low-intensity Strudel BGM patterns for game prototypes with cycle alternation and phasing so music stays background-appropriate.
Who is it for?
Game developers prototyping web or Strudel-based games who need quiet, loopable background tracks without a DAW pipeline.
Skip if: AAA projects requiring FMOD/Wwise middleware, licensed stems, or non-looping cinematic scores.
When should I use this skill?
A developer asks for game background music, Strudel BGM patterns, or low-intensity looping audio for a prototype.
What you get
Strudel BGM pattern code with layered instruments, gain levels, and phasing-ready loop structures.
- Strudel BGM pattern strings
- Layered loop configurations
By the numbers
- Recommends lead melody gain between 0.10 and 0.18
- Recommends pad gain between 0.08 and 0.15
- Uses .slow(2-4) to stretch Strudel pattern cycles
Files
Game Audio Engineer (Web Audio API)
You are an expert game audio engineer. You use the Web Audio API for both background music (looping sequencer) and one-shot sound effects. Zero dependencies — everything is built into the browser.
Performance Notes
- Take your time with each step. Quality is more important than speed.
- Do not skip validation steps — they catch issues early.
- Read the full context of each file before making changes.
- Test every sound in the browser. Web Audio timing is different from what you expect.
Reference Files
For detailed reference, see companion files in this directory:
sequencer-pattern.md— BGM sequencer function,parsePattern(), example patterns, anti-repetition techniquessfx-engine.md—playTone(),playNotes(),playNoise(), all SFX presetsmute-button.md— Mute state management,drawMuteIcon(), UIScene button, localStorage persistencebgm-patterns.md— Strudel BGM pattern examplesstrudel-reference.md— Strudel.cc API referencemixing-guide.md— Volume levels table and style guidelines per genre
Tech Stack
| Purpose | Engine | Package |
|---|---|---|
| Background music | Web Audio API sequencer | Built into browsers |
| Sound effects | Web Audio API one-shot | Built into browsers |
| Synths | OscillatorNode (square, triangle, sawtooth, sine) | — |
| Effects | GainNode, BiquadFilterNode, ConvolverNode, DelayNode | — |
No external audio files or npm packages needed — all sounds are procedural.
File Structure
src/
├── audio/
│ ├── AudioManager.js # AudioContext init, BGM sequencer, play/stop
│ ├── AudioBridge.js # Wires EventBus → audio playback
│ ├── music.js # BGM patterns (sequencer note arrays)
│ └── sfx.js # SFX (one-shot oscillator + gain + filter)AudioManager (BGM Sequencer + AudioContext)
The AudioManager owns the AudioContext (created on first user interaction for autoplay policy) and runs a simple step sequencer for BGM loops.
// AudioManager.js — Web Audio API BGM sequencer + SFX context
class AudioManager {
constructor() {
this.ctx = null;
this.currentBgm = null; // { stop() }
this.masterGain = null;
}
init() {
if (this.ctx) return;
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
this.masterGain = this.ctx.createGain();
this.masterGain.connect(this.ctx.destination);
}
getCtx() {
if (!this.ctx) this.init();
return this.ctx;
}
getMaster() {
if (!this.masterGain) this.init();
return this.masterGain;
}
playMusic(patternFn) {
this.stopMusic();
try {
this.currentBgm = patternFn(this.getCtx(), this.getMaster());
} catch (e) {
console.warn('[Audio] BGM error:', e);
}
}
stopMusic() {
if (this.currentBgm) {
try { this.currentBgm.stop(); } catch (_) {}
this.currentBgm = null;
}
}
setMuted(muted) {
if (this.masterGain) {
this.masterGain.gain.value = muted ? 0 : 1;
}
}
}
export const audioManager = new AudioManager();BGM Sequencer Pattern
See sequencer-pattern.md for the full sequencer function, parsePattern(), example BGM patterns, and anti-repetition techniques.
SFX Engine (Web Audio API -- one-shot)
See sfx-engine.md for playTone(), playNotes(), playNoise(), and all common game SFX presets (score, jump, death, click, powerUp, hit, whoosh, select).
AudioBridge (wiring EventBus -> audio)
import { eventBus, Events } from '../core/EventBus.js';
import { audioManager } from './AudioManager.js';
import { gameplayBGM, gameOverTheme } from './music.js';
import { scoreSfx, deathSfx, clickSfx } from './sfx.js';
export function initAudioBridge() {
// Init AudioContext on first user interaction (browser autoplay policy)
eventBus.on(Events.AUDIO_INIT, () => audioManager.init());
// BGM transitions
eventBus.on(Events.MUSIC_GAMEPLAY, () => audioManager.playMusic(gameplayBGM));
eventBus.on(Events.MUSIC_GAMEOVER, () => audioManager.playMusic(gameOverTheme));
eventBus.on(Events.MUSIC_STOP, () => audioManager.stopMusic());
// SFX (one-shot)
eventBus.on(Events.SCORE_CHANGED, () => scoreSfx());
eventBus.on(Events.PLAYER_DIED, () => deathSfx());
}Mute State Management
See mute-button.md for mute toggle event handling, drawMuteIcon() Phaser Graphics implementation, UIScene button creation, and localStorage persistence.
Integration Checklist
1. Create src/audio/AudioManager.js — AudioContext + sequencer + master gain 2. Create src/audio/music.js — BGM patterns as note arrays + sequencer calls 3. Create src/audio/sfx.js — SFX using Web Audio API (oscillator + gain + filter) 4. Create src/audio/AudioBridge.js — wire EventBus events to audio 5. Wire initAudioBridge() in main.js 6. Emit AUDIO_INIT on first user click (browser autoplay policy) 7. Emit MUSIC_GAMEPLAY, MUSIC_GAMEOVER, MUSIC_STOP at scene transitions 8. Add mute toggle — AUDIO_TOGGLE_MUTE event, UI button, M key shortcut 9. Test: BGM loops seamlessly, SFX fire once and stop, mute silences everything
Important Notes
- Zero dependencies: Everything uses the built-in Web Audio API. No npm packages needed for audio.
- Browser autoplay: AudioContext MUST be created/resumed from a user click/tap. The
AUDIO_INITevent handles this. - Master gain for mute: Route everything through a single GainNode. Setting
gain.value = 0mutes all audio instantly. - Sequencer timing: The look-ahead scheduler (schedules 100ms ahead, checks every 25ms) gives sample-accurate timing with no drift. This is the standard Web Audio scheduling pattern.
- No external audio files needed: Everything is synthesized with oscillators.
- SFX are instant: Web Audio API fires immediately with zero scheduler latency.
Optional: Strudel.cc Upgrade
For richer procedural BGM with pattern language support, you can optionally install @strudel/web:
npm install @strudel/webNote: Strudel is AGPL-3.0 — projects using it must be open source. See strudel-reference.md and bgm-patterns.md in this directory for Strudel-specific patterns.
The Strudel upgrade replaces the Web Audio sequencer for BGM only. SFX always use Web Audio API directly.
BGM Patterns for Games
Genre-specific background music patterns using Strudel. Each pattern uses stack() to layer instruments and .play() to start the loop.
Background music should FEEL like background
The #1 mistake is making BGM too loud, dense, or aggressive. Players need to focus on gameplay, not the soundtrack. Follow these principles:
- Use rests (`~`) liberally — silence is part of the music
- Keep gains low — lead melody at 0.10-0.18, pads at 0.08-0.15
- Prefer sine and triangle over square for calmer feel
- Use `.slow(2-4)` to stretch patterns and create breathing room
- Add `.room()` and `.delay()` — reverb/delay fill space without density
- Avoid drums for most game types — if drums are needed, keep gain under 0.3
Anti-Repetition (CRITICAL)
The #2 mistake is writing short patterns that sound identical every cycle. A 16-step pattern at 120 cpm loops every ~8 seconds — players hear the same thing 7+ times per minute. Use these techniques on EVERY BGM pattern:
Cycle alternation — <[phrase1] [phrase2] [phrase3]>
Write 3-4 melodic variations that rotate each cycle. This multiplies your effective loop length:
// 4 alternating melodies = 4x longer before repeating
note('<[e3 ~ g3 a3 ~ ~ g3 ~] [g3 ~ a3 b3 ~ ~ a3 ~] [a3 ~ g3 e3 ~ ~ d3 ~] [b3 ~ a3 g3 ~ ~ e3 ~]>')Layer phasing — different .slow() per layer
When layers cycle at different speeds, they combine differently each pass:
// Melody: 1 cycle, Counter: 1.5 cycles, Pad: 4 cycles, Texture: 3 cycles
// = ~12 cycles before exact realignment
melody, // default speed
counterMelody.slow(1.5), // phased
padChords.slow(4), // very slow
atmosphericTexture.slow(3), // different phaseProbabilistic notes — ? suffix
Notes with ? play 50% of the time, creating organic variation each loop:
note('b4 ~ ~ ~ e5? ~ ~ ~ g4? ~ ~ ~ a4? ~ ~ ~')Filter cycling — <value1 value2 ...>
Change timbre across cycles:
.lpf('<1200 800 1600 1000>') // brightness shifts each cycleRule of thumb: Effective loop length should be 30+ seconds before exact repetition. Apply ALL of these techniques, not just one.
Ambient / Atmospheric BGM (flight sims, exploration, puzzle)
export function gameplayBGM() {
return stack(
// Melody — 3 alternating phrases, gentle sine, lots of rests
note('<[e4 ~ g4 ~ a4 ~ ~ ~ b4 ~ a4 ~ g4 ~ e4 ~] [g4 ~ a4 ~ b4 ~ ~ ~ a4 ~ g4 ~ e4 ~ ~ ~] [b4 ~ a4 ~ g4 ~ ~ ~ e4 ~ g4 ~ a4 ~ g4 ~]>')
.s('sine')
.gain(0.14)
.lpf(2200)
.attack(0.1)
.decay(0.5)
.sustain(0.3)
.release(0.8)
.room(0.4)
.delay(0.2)
.delaytime(0.5)
.delayfeedback(0.3),
// Pad — 4-chord progression on slow cycle (phases against melody)
note('<e3,g3,b3> <e3,g3,b3> <a2,c3,e3> <a2,c3,e3> <d3,f3,a3> <d3,f3,a3> <g2,b2,d3> <g2,b2,d3>')
.s('sine')
.attack(0.6)
.release(1.5)
.gain(0.1)
.room(0.5)
.roomsize(4)
.lpf(1600)
.slow(4),
// Bass — 2 alternating root progressions
note('<[e2 ~ ~ ~ a2 ~ ~ ~ d2 ~ ~ ~ g2 ~ ~ ~] [a2 ~ ~ ~ d2 ~ ~ ~ g2 ~ ~ ~ c2 ~ ~ ~]>')
.s('triangle')
.gain(0.16)
.lpf(500)
.slow(2),
// Texture — probabilistic notes with delay, on its own slow cycle
note('e4? g4 b4? e5')
.s('triangle')
.fast(2)
.gain(0.04)
.lpf('<1200 900 1500 1100>')
.decay(0.15)
.sustain(0)
.room(0.6)
.delay(0.3)
.delaytime(0.375)
.delayfeedback(0.4)
.slow(3)
).cpm(75).play();
}Chiptune BGM (platformers, arcade — keep it moderate)
export function gameplayBGM() {
return stack(
// Lead — 4 alternating phrases for variety
note('<[c4 e4 g4 e4 c4 d4 e4 c4] [e4 g4 c5 g4 e4 f4 g4 e4] [g4 e4 c4 d4 e4 c4 g3 c4] [c4 d4 e4 g4 e4 d4 c4 d4]>')
.s("square")
.gain(0.18)
.lpf(2200)
.decay(0.12)
.sustain(0.25),
// Counter melody — 2 alternating phrases, offset timing
note('<[~ c5 ~ ~ ~ e5 ~ ~] [~ ~ e5 ~ ~ ~ c5 ~]>')
.s("square")
.gain(0.08)
.lpf(3000)
.decay(0.15)
.sustain(0)
.slow(1.5),
// Bass — 3 root progressions
note('<[c2 c2 g2 g2 f2 f2 c2 c2] [a1 a1 e2 e2 f2 f2 g2 g2] [f2 f2 c2 c2 g2 g2 c2 c2]>')
.s("triangle")
.gain(0.22)
.lpf(500),
// Synth drums — 2 alternating kick patterns
note('<[c1 ~ c1 ~ c1 c1 ~ ~ c1 ~ c1 ~ c1 ~ c1 ~] [c1 c1 ~ ~ c1 ~ c1 ~ ~ c1 ~ c1 c1 ~ ~ c1]>')
.s("sine")
.gain(0.28)
.decay(0.12)
.sustain(0)
.lpf(200),
// Arp accent — filter cycles for timbral shift
note("c3 e3 g3 c4")
.s("square")
.fast(4)
.gain(0.05)
.lpf('<1000 700 1400 900>')
.decay(0.06)
.sustain(0)
).cpm(130).play();
}Menu Theme (ambient, gentle — only add if the game has a title screen)
export function menuTheme() {
return stack(
// Pad — wide chords, slow attack
note('<c3,g3,b3> <a2,e3,a3> <f2,c3,f3> <g2,d3,g3>')
.s('sine')
.attack(1.0)
.release(2.0)
.gain(0.15)
.room(0.7)
.roomsize(6)
.lpf(1800)
.slow(2),
// Shimmer — sparse delayed notes
note('~ g5 ~ ~ ~ e5 ~ ~')
.s('triangle')
.slow(4)
.gain(0.06)
.delay(0.5)
.delaytime(0.6)
.delayfeedback(0.55)
.room(0.5)
.lpf(2500),
// Sub bass — grounding
note('c2 ~ ~ ~ ~ ~ g1 ~')
.s('sine')
.gain(0.12)
.slow(4)
.lpf(300)
).slow(2).cpm(60).play();
}Game Over Theme (somber)
export function gameOverTheme() {
return stack(
// Descending melody — 3 variations
note('<[b4 ~ a4 ~ g4 ~ e4 ~ d4 ~ c4 ~ ~ ~ ~ ~] [e4 ~ d4 ~ c4 ~ b3 ~ a3 ~ g3 ~ ~ ~ ~ ~] [g4 ~ e4 ~ d4 ~ c4 ~ e4 ~ d4 ~ b3 ~ ~ ~]>')
.s('triangle')
.gain(0.18)
.decay(0.6)
.sustain(0.1)
.release(1.0)
.room(0.6)
.roomsize(5)
.lpf(1800),
// Dark pad — alternating chords on slow cycle
note('<[a2,c3,e3] [d2,f2,a2] [e2,g2,b2]>')
.s('sine')
.attack(0.5)
.release(2.5)
.gain(0.12)
.room(0.7)
.roomsize(6)
.lpf(1200)
.slow(2),
// Ghostly high texture — probabilistic, phased
note('~ ~ ~ ~ ~ e5? ~ ~ ~ ~ ~ ~ ~ b4? ~ ~')
.s('sine')
.gain(0.03)
.delay(0.5)
.delaytime(0.6)
.delayfeedback(0.5)
.room(0.7)
.lpf(2000)
.slow(3)
).slow(3).cpm(50).play();
}Intense / Boss Theme
export function bossTheme() {
return stack(
// Aggressive lead — 3 alternating riffs
note('<[e3 e3 g3 a3 e3 e3 b3 a3] [e3 g3 a3 b3 a3 g3 e3 g3] [b3 a3 g3 e3 g3 a3 b3 a3]>')
.s("sawtooth")
.gain(0.2)
.lpf(1800)
.decay(0.1)
.sustain(0.4),
// Heavy bass — 2 alternating lines
note('<[e1 e1 e1 g1 a1 a1 e1 e1] [a1 a1 g1 e1 e1 g1 a1 a1]>')
.s("sawtooth")
.gain(0.25)
.lpf(400)
.distort(1.5),
// Synth drums — 2 alternating patterns
note('<[c1 c1 ~ c1 c1 ~ c1 ~] [c1 ~ c1 c1 ~ c1 ~ c1]>')
.s("sine")
.gain(0.35)
.decay(0.12)
.sustain(0)
.lpf(200),
// Tension arp — filter cycling for movement
note("e4 g4 b4 e5")
.s("square")
.fast(8)
.gain(0.08)
.lpf("<800 1600 2400 1200>")
.decay(0.05)
.sustain(0)
).cpm(160).play();
}Volume Mixing Guide
Game audio should never overpower gameplay. BGM gains are lower than you think.
Volume Levels
| Element | Gain | Notes |
|---|---|---|
| BGM Lead melody | 0.10-0.18 | Must not distract from gameplay |
| BGM Pad / chords | 0.08-0.15 | Background wash |
| BGM Bass | 0.15-0.22 | Foundation, felt not heard |
| BGM Drums | 0.20-0.30 | Only if game style demands it |
| BGM Arp/Texture | 0.03-0.08 | Barely audible movement |
| SFX (score, jump) | 0.2-0.3 | Should cut through BGM |
| SFX (death, hit) | 0.2-0.3 | Impactful but not ear-piercing |
| SFX (button, UI) | 0.15-0.25 | Subtle confirmation |
Style Guidelines
Retro / Chiptune (platformers, arcade)
- Use
squareandtriangleoscillators - Short
.decay(),.sustain(0)for percussive feel .crush(8-12)for lo-fi crunch.lpf(1000-3000)to tame harshness- Simple melodies: pentatonic or major scale
- Tempo: 100-140 cpm (not 160+ — that's frenetic)
Ambient / Atmospheric (flight sims, puzzle, exploration)
- Use
sineandtriangleoscillators - Long
.attack(0.3-1.0)and.release(1.0-2.5) - Heavy
.room(0.4-0.7)and.delay(0.2-0.5) - Stacked chords with
.slow(2-4) - Lots of rests (
~) — silence is part of the music - Tempo: 50-80 cpm
Minimal / Casual (mobile games)
- Light percussion only:
s("hh*4, ~ sd") - Sparse melody: mostly rests
.gain(0.10-0.20)— keep it very quiet- Heavy
.room()for space - Tempo: 70-100 cpm
Mute State Management
Store isMuted in GameState and respect it via the master gain node:
// AudioBridge — handle mute toggle event
eventBus.on(Events.AUDIO_TOGGLE_MUTE, () => {
gameState.isMuted = !gameState.isMuted;
try { localStorage.setItem('muted', gameState.isMuted); } catch (_) {}
audioManager.setMuted(gameState.isMuted);
if (gameState.isMuted) audioManager.stopMusic();
});Muting via masterGain.gain.value = 0 silences both BGM and SFX through a single control point. No need to check mute state in every SFX function.
Mute Button
Reference implementation for drawing a speaker icon with the Phaser Graphics API:
function drawMuteIcon(gfx, muted, size) {
gfx.clear();
const s = size;
// Speaker body — rectangle + triangle cone
gfx.fillStyle(0xffffff);
gfx.fillRect(-s * 0.15, -s * 0.15, s * 0.15, s * 0.3);
gfx.fillTriangle(-s * 0.15, -s * 0.3, -s * 0.15, s * 0.3, -s * 0.45, 0);
if (!muted) {
// Sound waves — two arcs
gfx.lineStyle(2, 0xffffff);
gfx.beginPath();
gfx.arc(0, 0, s * 0.2, -Math.PI / 4, Math.PI / 4);
gfx.strokePath();
gfx.beginPath();
gfx.arc(0, 0, s * 0.35, -Math.PI / 4, Math.PI / 4);
gfx.strokePath();
} else {
// X mark
gfx.lineStyle(3, 0xff4444);
gfx.lineBetween(s * 0.05, -s * 0.25, s * 0.35, s * 0.25);
gfx.lineBetween(s * 0.05, s * 0.25, s * 0.35, -s * 0.25);
}
}Create the button in UIScene (runs as a parallel scene, visible on all screens):
// In UIScene.create():
_createMuteButton() {
const ICON_SIZE = 16;
const MARGIN = 12;
const x = this.cameras.main.width - MARGIN - ICON_SIZE;
const y = this.cameras.main.height - MARGIN - ICON_SIZE;
this.muteBg = this.add.circle(x, y, ICON_SIZE + 4, 0x000000, 0.3)
.setInteractive({ useHandCursor: true })
.setDepth(100);
this.muteIcon = this.add.graphics().setDepth(100);
this.muteIcon.setPosition(x, y);
drawMuteIcon(this.muteIcon, gameState.isMuted, ICON_SIZE);
this.muteBg.on('pointerdown', () => {
eventBus.emit(Events.AUDIO_TOGGLE_MUTE);
drawMuteIcon(this.muteIcon, gameState.isMuted, ICON_SIZE);
});
this.input.keyboard.on('keydown-M', () => {
eventBus.emit(Events.AUDIO_TOGGLE_MUTE);
drawMuteIcon(this.muteIcon, gameState.isMuted, ICON_SIZE);
});
}localStorage Persistence
Persist preference via localStorage:
// GameState — read on construct
constructor() {
this.isMuted = localStorage.getItem('muted') === 'true';
}BGM Sequencer Pattern
BGM uses a step sequencer that schedules oscillator notes ahead of time in a recurring loop. This gives sample-accurate timing with zero drift.
// music.js — BGM patterns using Web Audio API sequencer
const NOTES = {
C3: 130.81, D3: 146.83, E3: 164.81, F3: 174.61, G3: 196.00, A3: 220.00, B3: 246.94,
C4: 261.63, D4: 293.66, E4: 329.63, F4: 349.23, G4: 392.00, A4: 440.00, B4: 493.88,
C5: 523.25, D5: 587.33, E5: 659.25, G5: 783.99, R: 0, // R = rest
};
/**
* Simple step sequencer — schedules notes in a loop using Web Audio API.
* Returns { stop() } to cancel the loop.
*
* @param {AudioContext} ctx
* @param {GainNode} dest - destination node (master gain)
* @param {Array<Array<{freq, type, gain, duration}>>} layers - parallel note sequences
* @param {number} bpm - beats per minute
* @param {number} stepsPerBeat - subdivisions per beat (default 2 = eighth notes)
*/
function sequencer(ctx, dest, layers, bpm, stepsPerBeat = 2) {
const stepDuration = 60 / bpm / stepsPerBeat;
let nextStepTime = ctx.currentTime + 0.05; // small initial buffer
let stepIndex = 0;
let stopped = false;
let timerId = null;
function scheduleStep() {
if (stopped) return;
// Schedule notes while we're ahead of the playback cursor
while (nextStepTime < ctx.currentTime + 0.1) {
for (const layer of layers) {
const note = layer[stepIndex % layer.length];
if (note && note.freq > 0) {
const osc = ctx.createOscillator();
osc.type = note.type || 'square';
osc.frequency.setValueAtTime(note.freq, nextStepTime);
if (note.freqEnd) {
osc.frequency.exponentialRampToValueAtTime(note.freqEnd, nextStepTime + (note.duration || stepDuration));
}
const g = ctx.createGain();
const noteGain = note.gain ?? 0.15;
g.gain.setValueAtTime(noteGain, nextStepTime);
g.gain.exponentialRampToValueAtTime(0.001, nextStepTime + (note.duration || stepDuration * 0.9));
const f = ctx.createBiquadFilter();
f.type = 'lowpass';
f.frequency.setValueAtTime(note.lpf || 3000, nextStepTime);
osc.connect(f).connect(g).connect(dest);
osc.start(nextStepTime);
osc.stop(nextStepTime + (note.duration || stepDuration));
}
}
stepIndex++;
nextStepTime += stepDuration;
}
timerId = setTimeout(scheduleStep, 25); // check every 25ms
}
scheduleStep();
return { stop() { stopped = true; clearTimeout(timerId); } };
}
// Helper: convert a string pattern like "C4 R E4 G4" into note objects
function parsePattern(str, type = 'square', gain = 0.15, lpf = 3000) {
return str.split(' ').map(n => {
if (n === 'R' || n === '~') return { freq: 0 };
return { freq: NOTES[n] || 0, type, gain, lpf };
});
}
// --- Example BGM patterns ---
export function gameplayBGM(ctx, dest) {
return sequencer(ctx, dest, [
// Melody — square wave
parsePattern('C4 E4 G4 E4 C4 D4 E4 C4 D4 F4 A4 F4 D4 E4 F4 D4', 'square', 0.14, 2200),
// Bass — triangle wave
parsePattern('C3 R C3 R G3 R G3 R F3 R F3 R C3 R C3 R', 'triangle', 0.18, 500),
// Arpeggio texture — quiet square
parsePattern('C5 E5 G5 E5 C5 E5 G5 E5 D5 F4 A4 F4 D5 F4 A4 F4', 'square', 0.04, 1000),
// Kick drum — low sine
parsePattern('C3 R R R C3 R R R C3 R R R C3 R R R', 'sine', 0.25, 200),
], 130, 2);
}
export function gameOverTheme(ctx, dest) {
return sequencer(ctx, dest, [
// Slow descending melody
parsePattern('B4 R A4 R G4 R E4 R D4 R C4 R R R R R', 'triangle', 0.18, 1800),
// Pad chord
parsePattern('A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3', 'sine', 0.10, 1200),
], 60, 2);
}Anti-Repetition Techniques
The #1 complaint about procedural game music is repetitiveness. Use these techniques:
1. Multiple phrase variations — Write 2-4 melody arrays and cycle through them:
const melodies = [
parsePattern('C4 E4 G4 E4 C4 D4 E4 C4'),
parsePattern('G4 A4 B4 A4 G4 E4 D4 E4'),
parsePattern('E4 G4 A4 G4 E4 D4 C4 D4'),
];
// In sequencer, index melody layers by Math.floor(stepIndex / stepsPerPhrase) % melodies.length2. Different layer lengths — Make bass 12 steps while melody is 16. They realign after LCM(12,16)=48 steps.
3. Random note omission — In the sequencer loop, skip notes with Math.random() > 0.85 for organic variation.
4. Filter sweep — Gradually change lpf values over time for timbral movement.
Rule of thumb: Effective loop length should be 30+ seconds before exact repetition.
SFX Engine (Web Audio API -- one-shot)
// sfx.js — Web Audio API one-shot sounds
import { audioManager } from './AudioManager.js';
function playTone(freq, type, duration, gain = 0.3, filterFreq = 4000) {
const ctx = audioManager.getCtx();
const now = ctx.currentTime;
const osc = ctx.createOscillator();
osc.type = type;
osc.frequency.setValueAtTime(freq, now);
const gainNode = ctx.createGain();
gainNode.gain.setValueAtTime(gain, now);
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration);
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(filterFreq, now);
osc.connect(filter).connect(gainNode).connect(audioManager.getMaster());
osc.start(now);
osc.stop(now + duration);
}
function playNotes(notes, type, noteDuration, gap, gain = 0.3, filterFreq = 4000) {
const ctx = audioManager.getCtx();
const now = ctx.currentTime;
notes.forEach((freq, i) => {
const start = now + i * gap;
const osc = ctx.createOscillator();
osc.type = type;
osc.frequency.setValueAtTime(freq, start);
const gainNode = ctx.createGain();
gainNode.gain.setValueAtTime(gain, start);
gainNode.gain.exponentialRampToValueAtTime(0.001, start + noteDuration);
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(filterFreq, start);
osc.connect(filter).connect(gainNode).connect(audioManager.getMaster());
osc.start(start);
osc.stop(start + noteDuration);
});
}
function playNoise(duration, gain = 0.2, lpfFreq = 4000, hpfFreq = 0) {
const ctx = audioManager.getCtx();
const now = ctx.currentTime;
const bufferSize = ctx.sampleRate * duration;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
const source = ctx.createBufferSource();
source.buffer = buffer;
const gainNode = ctx.createGain();
gainNode.gain.setValueAtTime(gain, now);
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration);
const lpf = ctx.createBiquadFilter();
lpf.type = 'lowpass';
lpf.frequency.setValueAtTime(lpfFreq, now);
let chain = source.connect(lpf).connect(gainNode);
if (hpfFreq > 0) {
const hpf = ctx.createBiquadFilter();
hpf.type = 'highpass';
hpf.frequency.setValueAtTime(hpfFreq, now);
source.disconnect();
chain = source.connect(hpf).connect(lpf).connect(gainNode);
}
chain.connect(audioManager.getMaster());
source.start(now);
source.stop(now + duration);
}
// --- Common Game SFX ---
// Note frequencies: C4=261.63, D4=293.66, E4=329.63, F4=349.23,
// G4=392.00, A4=440.00, B4=493.88, C5=523.25, E5=659.25, B5=987.77
export function scoreSfx() {
playNotes([659.25, 987.77], 'square', 0.12, 0.07, 0.3, 5000);
}
export function jumpSfx() {
const ctx = audioManager.getCtx();
const now = ctx.currentTime;
const osc = ctx.createOscillator();
osc.type = 'square';
osc.frequency.setValueAtTime(261.63, now);
osc.frequency.exponentialRampToValueAtTime(1046.5, now + 0.1);
const g = ctx.createGain();
g.gain.setValueAtTime(0.2, now);
g.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
const f = ctx.createBiquadFilter();
f.type = 'lowpass';
f.frequency.setValueAtTime(3000, now);
osc.connect(f).connect(g).connect(audioManager.getMaster());
osc.start(now);
osc.stop(now + 0.12);
}
export function deathSfx() {
playNotes([392, 329.63, 261.63, 220, 174.61], 'square', 0.2, 0.1, 0.25, 2000);
}
export function clickSfx() {
playTone(523.25, 'sine', 0.08, 0.2, 5000);
}
export function powerUpSfx() {
playNotes([261.63, 329.63, 392, 523.25, 659.25], 'square', 0.1, 0.06, 0.3, 5000);
}
export function hitSfx() {
playTone(65.41, 'square', 0.15, 0.3, 800);
}
export function whooshSfx() {
playNoise(0.25, 0.15, 6000, 800);
}
export function selectSfx() {
playTone(523.25, 'sine', 0.2, 0.25, 6000);
}Strudel Quick Reference (for BGM only)
Reference for Strudel mini-notation syntax, synth oscillators, effects, and advanced patterns. Used when composing background music with @strudel/web.
Core Pattern Syntax
// Sequence sounds across one cycle
s("bd sd hh hh")
// Layer sounds simultaneously
stack(
s("bd sd"),
s("hh*8"),
note("c3 e3 g3").s("square")
)
// Alternate across cycles
note("<c3 e3> <g3 a3>")
// Euclidean rhythm: 3 hits spread across 8 slots
s("bd(3,8)")
// Subdivide within a beat
s("bd [hh hh] sd [hh hh hh]")Mini-Notation Cheat Sheet
| Symbol | Meaning | Example |
|---|---|---|
| Sequence | "bd sd hh" |
~ | Rest | "bd ~ sd ~" |
*N | Speed up | "hh*8" |
/N | Slow down | "bd/2" |
[..] | Subdivide | "bd [sd sd]" |
<..> | Alternate cycles | "<bd sd>" |
, | Layer | "bd, hh*4" |
(k,n) | Euclidean | "bd(3,8)" |
? | 50% chance | "hh?" |
:N | Sample variant | "hh:0 hh:3" |
Synth Oscillators
| Name | Sound | Game Use |
|---|---|---|
square | Classic 8-bit / chiptune | Melodies, leads |
triangle | Soft, muted | Bass lines, subtle pads |
sawtooth | Bright, buzzy | Aggressive leads, stabs |
sine | Pure tone | Sub-bass, gentle melodies, pads |
Key Effects
.gain(0.5) // Volume (0-1+)
.lpf(800) // Low-pass filter cutoff Hz
.hpf(200) // High-pass filter cutoff Hz
.room(0.3) // Reverb send (0-1)
.roomsize(4) // Reverb size (higher = larger room)
.delay(0.2) // Delay send (0-1)
.delaytime(0.375) // Delay time in seconds
.delayfeedback(0.5) // Delay feedback (0-1)
.crush(8) // Bit crush (1-16, lower = crunchier)
.distort(2) // Distortion amount
.pan(0.3) // Stereo pan (0=L, 0.5=C, 1=R)
.attack(0.01) // ADSR attack time
.decay(0.2) // ADSR decay time
.sustain(0) // ADSR sustain level
.release(0.1) // ADSR release time
.fast(2) // Double speed
.slow(2) // Half speed
.cpm(120) // Cycles per minute (tempo)FM Synthesis (for metallic/bell sounds)
note("c4").s("sine")
.fm(4) // Modulation index (brightness)
.fmh(2) // Harmonicity (whole = natural, fractional = metallic)
.fmdecay(0.5) // FM envelope decayFilter Envelopes
// Autopilot filter sweep — opens/closes filter over time
note("g1 bb1 <c2 eb2> d2").s("sawtooth")
.lpf(400).lpenv(4)
// With resonance peak
note("g1 bb1 <c2 eb2> d2").s("sawtooth")
.lpq(8).lpf(400).lpa(.1).lpd(.1).lpenv(4)Chorus / Detune (for fatter sounds)
// Layer a detuned copy — instant width
note("<g1 bb1 d2 f1>").add(note("0,.1")).s("sawtooth")Reverb Variations
.room(0.5) // Standard reverb send
.room(0.5).roomsize(4) // Large room
.room(0.5).rlp(5000) // Reverb with lowpass
.room(0.5).rlp(5000).rfade(4) // Reverb with lowpass fadeRelated skills
How it compares
Use Game Audio for quick Strudel loops in prototypes; use a DAW pipeline when shipping polished multi-stem soundtracks.
FAQ
What gain levels does Game Audio recommend?
Game Audio recommends lead melody gain between 0.10 and 0.18 and pad layers between 0.08 and 0.15. Keeping gains low prevents BGM from masking gameplay audio and UI feedback.
Which Strudel functions does Game Audio use?
Game Audio patterns use stack() to layer instruments, .play() to start loops, and effects like .slow(2-4), .room(), and .delay(). Rests (~) and calmer waveforms keep loops background-appropriate.
Is Game Audio safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.