AudioContext Resume Gesture Handling

Browser autoplay policies suspend any AudioContext created before a user gesture. The AudioBus module in engine/audio/audio-context.ts handles this with a combination of lazy initialization and an explicit resume() entry point wired to the first user input.

Why it matters

Every modern browser ships an autoplay policy: a fresh AudioContext starts in the suspended state and produces no sound until a user gesture (tap, click, keypress) un-suspends it. Calling ctx.resume() outside a gesture handler does nothing on iOS Safari and is no-ops in Chrome/Firefox until the user has interacted with the page at least once.

Starship Survivors is a mobile-web game with audio-driven feedback (weapon chimes, UI clicks, music). If audio is silent for the first few seconds of play, the game feels broken.

Lazy init

AudioBus.getCtx() defers construction until the first caller actually needs the context:

  • Module load creates no AudioContext — only singleton slots (_ctx, _masterGain, bus inputs) are declared as null.
  • The first call to getCtx() (or any bus getter like getGameSfxBus(), getUiSfxBus(), getMusicBus()) runs _init(), which constructs the context and wires the full master chain.
  • _init() catches construction failures and returns null so the caller can no-op gracefully on environments without Web Audio.

This means the context is created on the same call stack as the gesture that triggers the first sound, maximizing the chance the browser accepts it as user-initiated.

resume() entry point

AudioBus.resume() is the canonical un-suspend hook:

resume(): void {
  if (_ctx && _ctx.state === 'suspended') {
    _ctx.resume().catch(() => {});
  }
}

Callers wire it to the first tap or keypress in the app. The check is idempotent — if the context is already running (or hasn’t been constructed yet) the call is a no-op. The .catch(() => {}) swallows rejections so the gesture handler never throws.

Persisted unlock state

Once the user has interacted with the page and resume() succeeds, the AudioContext state moves to running and stays there for the lifetime of the tab:

  • No re-locking on subsequent navigations within the SPA.
  • No re-locking when the tab loses focus and regains it.
  • The context only goes away when the tab is closed or AudioBus.dispose() is called explicitly.

Subsequent calls to resume() are cheap no-ops because _ctx.state is no longer 'suspended'.

Failure modes

  • SSR / no window.AudioContext_init() returns null; all bus getters return null; consumers must null-check (and do).
  • resume() called before _init() — guarded by if (_ctx && ...); harmless no-op.
  • resume() called without a real gesture — promise rejects silently; user can tap again to retry.
  • engine/audio/audio-context.ts — implementation
  • engine/audio/weapon-chimes.ts, micro-sfx.ts, music-player.ts — consumers that pull bus inputs through AudioBus.getCtx()