Skip to content

Play music that changes in combat ​

Goal ​

Exploration music plays from the start; when an enemy notices the player, drums and a lead come in on the next bar without the music restarting, and they fade back out a few seconds after the fight ends.

Files you will edit ​

  • assets.json
  • src/systems/music.ts

Steps ​

  1. Declare the layers in assets.json. They are stems: loops of the same length and tempo, exported from one session, so they stay in phase. Keep them short (one to four bars, mono is fine): stems are decoded whole, and seconds x 48000 x channels x 4 bytes each is what a phone holds.

    json
    {
      "version": 1,
      "assets": [
        { "id": "arena", "type": "splat", "src": "arena.spz" },
        { "id": "music-pad", "type": "audio", "src": "music/pad.ogg", "tags": ["music"] },
        { "id": "music-drums", "type": "audio", "src": "music/drums.ogg", "tags": ["music"] },
        { "id": "music-lead", "type": "audio", "src": "music/lead.ogg", "tags": ["music"] }
      ]
    }
  2. Start the stems once, with only the pad audible. bpm gives the music a beat grid, so later changes can land on a bar.

    ts
    import type { GameContext } from 'gameable';
    
    const DRUMS = 1;
    const LEAD = 2;
    const CALM_AFTER = 4; // seconds without an enemy in sight before the drums go
    
    let started = false;
    let inCombat = false;
    let calmFor = 0;
    
    export function music(ctx: GameContext): void {
      if (!started) {
        started = true;
        ctx.music.stems(['music-pad', 'music-drums', 'music-lead'], {
          bpm: 96,
          gains: [1, 0, 0],
          loop: true,
          fadeMs: 1500,
        });
      }
      const threatened = enemiesAlerted(ctx) > 0;
      if (threatened) calmFor = 0;
      else calmFor += ctx.dt;
    
      if (threatened && !inCombat) {
        inCombat = true;
        ctx.music.layer(DRUMS, 1, 400);
        ctx.music.layer(LEAD, 0.8, 1200);
      } else if (inCombat && calmFor > CALM_AFTER) {
        inCombat = false;
        ctx.music.layer(DRUMS, 0, 3000);
        ctx.music.layer(LEAD, 0, 3000);
      }
    }

    enemiesAlerted is your game's own query (the enemy recipe's AI state).

  3. For a different track rather than a layer — a boss theme — crossfade to it on the next bar. A single track streams, so it can be minutes long:

    ts
    ctx.music.play('music-boss', { bpm: 140, fadeMs: 2000, start: 'bar', loop: true });

Verify ​

sh
npm run dev

Walk toward an enemy: the drums enter on a bar line, not mid-beat, and the pad never restarts. Walk away and they fade out after four seconds. Headlessly:

sh
npm test
ts
expect(harness.commands('set-music-layer')).toContainEqual(
  expect.objectContaining({ layer: 1, gain: 1 }),
);

See also ​