Generate Dynamic Game Background Music with Lyria 3

Use Google's Lyria 3 AI model to create adaptive, real-time background music for games that responds to gameplay events.

Problem: Your Game's Music Doesn't React to Gameplay

Static background tracks loop awkwardly and break immersion. Players notice when the calm exploration theme keeps playing during a boss fight.

You'll learn:

  • How to set up the Lyria 3 API for real-time music generation
  • How to trigger music changes based on game state
  • How to crossfade between generated segments without audio glitches

Time: 25 min | Level: Intermediate


Why This Happens

Traditional game audio uses pre-recorded loops. They're easy to implement but can't respond to dynamic game states without complex branching logic and large audio budgets.

Lyria 3 (released late 2025) generates music segments on-the-fly from text prompts and audio continuations. This means you can describe "tense dungeon combat with fast drums" and get a unique, seamless segment every time.

Common symptoms this solves:

  • Music loops feel repetitive after 2-3 minutes
  • Jarring cuts when transitioning between game states
  • High storage cost for a full adaptive soundtrack

Game state diagram showing music triggers Game states map directly to Lyria 3 prompt parameters


Solution

Step 1: Install the Lyria 3 SDK

# Node.js 22+ required
npm install @google/lyria-client@^1.0.0 web-audio-api

Get your API key from Google AI Studio and add it to your environment:

echo "LYRIA_API_KEY=your_key_here" >> .env

Expected: node_modules/@google/lyria-client should exist after install.

If it fails:

  • Error "engine unsupported": You need Node 22+. Run node --version to check.
  • 401 Unauthorized: Your API key isn't in scope — double-check your .env loader.

Step 2: Create the Music Manager

This class handles generation and crossfading. The key insight is to pre-generate the next segment while the current one plays, so there's no gap.

// src/audio/MusicManager.ts
import { LyriaClient } from '@google/lyria-client';

interface GameState {
  zone: 'exploration' | 'combat' | 'boss' | 'safe';
  tension: number; // 0.0 - 1.0
}

const PROMPTS: Record<GameState['zone'], string> = {
  exploration: 'ambient fantasy exploration, soft strings, gentle flute, 80bpm',
  combat:      'urgent action music, driving percussion, brass, 140bpm',
  boss:        'epic boss battle, full orchestra, choir, intense, 160bpm',
  safe:        'peaceful town theme, acoustic guitar, birds, 70bpm',
};

export class MusicManager {
  private client: LyriaClient;
  private ctx: AudioContext;
  private currentSource: AudioBufferSourceNode | null = null;
  private nextBuffer: AudioBuffer | null = null;

  constructor(apiKey: string) {
    this.client = new LyriaClient({ apiKey });
    this.ctx = new AudioContext();
  }

  async prefetch(state: GameState): Promise<void> {
    // Build prompt dynamically — tension nudges the energy parameter
    const prompt = PROMPTS[state.zone];
    const response = await this.client.generate({
      prompt,
      duration_seconds: 30,
      energy: state.tension,          // Lyria maps 0-1 to low/high energy
      continuation: this.getSnapshot(), // Pass last 2s for seamless join
      format: 'wav',
    });

    this.nextBuffer = await this.ctx.decodeAudioData(await response.arrayBuffer());
  }

  transition(fadeDuration = 2.0): void {
    if (!this.nextBuffer) return;

    const gainOut = this.ctx.createGain();
    const gainIn  = this.ctx.createGain();
    const now     = this.ctx.currentTime;

    // Fade out current track
    if (this.currentSource) {
      gainOut.gain.setValueAtTime(1, now);
      gainOut.gain.linearRampToValueAtTime(0, now + fadeDuration);
      this.currentSource.connect(gainOut).connect(this.ctx.destination);
      setTimeout(() => this.currentSource?.stop(), fadeDuration * 1000);
    }

    // Fade in new track
    const source = this.ctx.createBufferSource();
    source.buffer = this.nextBuffer;
    source.loop   = true;
    gainIn.gain.setValueAtTime(0, now);
    gainIn.gain.linearRampToValueAtTime(1, now + fadeDuration);
    source.connect(gainIn).connect(this.ctx.destination);
    source.start(now);

    this.currentSource = source;
    this.nextBuffer    = null;
  }

  private getSnapshot(): ArrayBuffer | undefined {
    // Returns last 2 seconds of current audio for continuation
    // Implementation depends on your recording pipeline
    return undefined; // Omit on first call — Lyria handles cold start
  }
}

Why continuation matters: Passing the tail of the current buffer lets Lyria match tempo and key, so the crossfade sounds composed rather than stitched.


Step 3: Hook Into Your Game Loop

Call prefetch when a state change is imminent, not after it happens. Most game engines give you an event before the transition completes.

// src/game/GameController.ts
import { MusicManager } from '../audio/MusicManager';

const music = new MusicManager(process.env.LYRIA_API_KEY!);

// Example with a generic event system
gameEvents.on('zone:willChange', async (next: GameState) => {
  await music.prefetch(next);   // ~1-2s API latency, runs in background
});

gameEvents.on('zone:changed', () => {
  music.transition(2.0);        // Crossfade over 2 seconds
});

Expected: Music should begin fading to the new style within 2 seconds of a zone change, with no silence gap.

If it fails:

  • Gap between tracks: Increase prefetch lead time or reduce fadeDuration.
  • Music doesn't match new zone: Check that zone:willChange fires before zone:changed, not simultaneously.

Step 4: Tune Prompts for Your Game

Prompt quality directly affects output quality. A few patterns that work well:

// Add BPM to lock tempo to your game's feel
'tense stealth sequence, pizzicato strings, no drums, 90bpm'

// Describe instrumentation, not emotion — Lyria infers emotion from instruments
'medieval tavern, lute, recorder, crowd noise, upbeat, 110bpm'

// Use energy + prompt together for the same zone at different intensities
const prompt = `${PROMPTS[state.zone]}, energy:${state.tension.toFixed(1)}`;

Waveform comparison showing smooth crossfade A clean crossfade — no silence, no click at the join point


Verification

npm run dev

Open your browser console and trigger a zone change manually:

gameEvents.emit('zone:willChange', { zone: 'combat', tension: 0.8 });
setTimeout(() => gameEvents.emit('zone:changed'), 1500);

You should see: Music fades from exploration to combat over 2 seconds. No silence. No audio click at the join.


What You Learned

  • Lyria 3 generates 30-second WAV segments from text prompts in ~1-2 seconds
  • The continuation parameter is what makes seamless transitions possible — don't skip it
  • Prefetch on upcoming state, not current state, to hide API latency
  • energy (0–1) and BPM in the prompt are your primary levers for real-time variation

Limitations: Lyria 3 API has a rate limit of 10 requests/minute on the free tier — fine for zone changes, not for per-second adaptation. For beat-synced reactivity, consider generating longer buffers (60–120s) and using the Web Audio API's scheduling features to align loop points to downbeats.

When NOT to use this: Cutscenes with dialogue. Pre-composed, mastered tracks are still better when audio needs to hit specific emotional marks at specific timestamps.


Tested on Lyria 3 API v1.0, Node.js 22.14, Chrome 133, and Firefox 135.