Skip to content

Trigger on the beat ​

Goal ​

Something in the game happens on every beat of the music (a light pulses, an enemy steps), a sound you ask for lands exactly on the next beat, and a key press is judged "perfect", "good" or "miss" by how close to the beat it was.

Files you will edit ​

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

Steps ​

  1. Declare a track and a hit sound in assets.json. Know the track's tempo and where its first beat is (firstBeat, seconds from the file start); the engine does not guess them.

    json
    {
      "version": 1,
      "assets": [
        { "id": "arena", "type": "splat", "src": "arena.spz" },
        { "id": "song", "type": "audio", "src": "music/song.ogg", "tags": ["music"] },
        { "id": "clap", "type": "audio", "src": "sfx/clap.ogg", "tags": ["sfx"] }
      ]
    }
  2. Start the song with its grid, react to beat events, and judge presses with ctx.music.offsetMs(): the signed distance from now to the nearest beat, as the player hears it.

    ts
    import type { GameContext } from 'gameable';
    
    const PERFECT_MS = 45;
    const GOOD_MS = 110;
    
    let started = false;
    export let pulse = 0; // 1 on the beat, decays to 0; drive a light or a scale with it
    export let score = 0;
    
    export function beat(ctx: GameContext): void {
      if (!started) {
        started = true;
        ctx.music.play('song', { bpm: 120, beatsPerBar: 4, firstBeat: 0.05 });
      }
      pulse = Math.max(0, pulse - ctx.dt * 4);
      for (const ev of ctx.events) {
        if (ev.tag !== 'beat') continue;
        pulse = 1;
        // On a bar's last beat, schedule a clap for the next one (the next bar's
        // first): it starts on the audio clock, sample-exact.
        if (ev.val.beatInBar === 3) ctx.audio.playOnBeat('clap', { quantize: 'beat' });
      }
      if (ctx.input.pressed('Space')) {
        const off = Math.abs(ctx.music.offsetMs());
        if (off <= PERFECT_MS) score += 100;
        else if (off <= GOOD_MS) score += 50;
        else score -= 10;
      }
    }

    For a chart of notes (beat numbers), ctx.music.nearestBeat() says which beat a press is nearest to: judge it with nearestBeat() === note and the offset above. examples/rhythm does exactly that.

    Never play a sound when the beat event arrives and expect it on the beat: the event comes up to a frame late. playOnBeat schedules it on the audio clock instead.

Verify ​

sh
npm run dev

The pulse lands with the kick you hear, the clap falls on every bar's first beat, and tapping Space with the music scores 100s. Headlessly, feed beat events to the guest and assert what it did:

sh
npm test
ts
expect(harness.commands('play-sound-on-beat')).toContainEqual(
  expect.objectContaining({ quantize: 'beat' }),
);

See also ​