Music That Cannot Drift
Game music has one job description in two words: never wrong. Never playing over the pause screen, never continuing after death, never drifting against the action it is supposed to score. Mar gives you three ways to run music, and all three are declarative: you never call start or stop. You declare what should be sounding given the current world, and the runtime makes it so.
Loops: the jukebox is a subscription
Sound.loop and its siblings are subscriptions, not commands. Music belongs in subscriptions because music is a state, not an event:
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch
[ gameplaySubs model
, case model.mode of
TitleScreen -> Sound.loop titleTheme
WorldMap -> Sound.loop mapTheme
Battle -> Sound.loop battleTheme
Paused -> Sub.none
]
The reconciliation rules are exactly what a jukebox should do: return the same Sound and it keeps looping untouched across updates; return a different one and the old song stops and the new one starts; return Sub.none and there is silence. Changing tracks is changing a case result. There is no music manager, and there is nothing to forget to stop.
Two siblings complete the family:
Sound.once s: plays s a single time while subscribed (a victory jingle on the win screen); unsubscribing early cancels it cleanly.Sound.ambient s: holds s as a continuous bed: an engine hum, wind, a crowd. Beds have a special power: returning the same bed at a new volume swells the live sound smoothly, and at a new pitch retunes it with a glide, never a restart click. The soccer example runs its crowd this way, cheering louder as the score tightens; the racer pitches its engine with your speed.
Writing a tune
A song is a chord of parallel voices, each voice a long sequence:
titleTheme : Sound
titleTheme =
Sound.chord
[ Sound.sequence melodyBars -- Square, duty 25
, Sound.sequence bassBars -- Triangle
, Sound.sequence drumBars -- Noise
]
Work on a beat grid: at 120 BPM a beat is 500 ms, a bar of four is 2000 ms. Write helpers that make notes in beat units (n 1 (Sound.e 4) for a one-beat E) and the sheet music becomes legible code. Melody on a duty-25 Square, bass roots on the Triangle an octave or two down, percussion as short Noise hits with rests between: that trio is the entire classic chip ensemble, and one extra Square playing off-beat chords (or a single arp voice) is the luxury version.
The loop invariant
Here is the rule that will save you an afternoon: in a looped chord, every voice must sum to exactly the same total duration. The runtime loops each voice seamlessly; if the melody adds up to 8000 ms and the drums to 7995, the drums arrive 5 ms early on every pass, and two minutes in your song has smeared into soup. It fails slowly, which is what makes it mystifying if you have never been warned.
The discipline: pad every voice’s tail with Sound.rest to the bar total, and keep a per-voice sum comment you update when you edit:
-- each voice: 8 bars * 2000 ms = 16000 ms exactly
melodyBars = [ ... ] -- 16000
bassBars = [ ... ] -- 16000
drumBars = [ ... ] -- 16000
Arithmetic on ms is integer arithmetic; the sums are checkable by eye, in a comment, or by a five-line test that folds durations. The repository keeps a checker script for its tunes for exactly this reason. When a looped song sounds subtly wrong after a while, check the sums first.
World-driven music: the flagship pattern
Loops are for menus and moods. For action, the examples do something better: the game world plays its own soundtrack. No Sound.loop at all; instead, the beat accumulator from chapter 4 fires inside step, and on each beat the simulation emits sound cues (the same cue-list pattern as chapter 9) chosen from the world’s state:
onBeat : Model -> Model
onBeat model =
{ model
| beat = model.beat + 1
, cues = List.concat [ model.cues, grooveStep model.beat model ]
}
grooveStep looks at the beat number and the world and answers “what does the music do now”: bass note on every beat, kick on 1 and 3, melody phrase chosen by the current loop, a stinger when a wave spawns. The rhythm runner drives its entire soundtrack this way, nine loops of escalating arrangement, and the shmup scores each world with beat and melody cue streams.
What you buy for the extra structure:
- Perfect sync, permanently. Obstacles spawn on the same beat counter the music plays from. They cannot drift; they are the same integer.
- The pause bug is unrepresentable. Hidden tab, pause phase, game over: the tick stops, so the beat stops, so the music stops. Nothing to remember.
- Dynamic arrangement is a
case. More intense at low health, drums drop out in the shop, boss theme layers in at the boss: all justgrooveStepconsulting the model. - Time travel scrubs the score. Rewind the debugger and the soundtrack rewinds with the gameplay, which is as delightful as it sounds.
The trade: you are now the composer and the sequencer. Start with loops (they are two lines); graduate to world-driven when the game’s rhythm and the music’s rhythm deserve to be the same thing.
Original tunes only
A craft note as important as any invariant: compose your own. The chip idiom (square leads, triangle bass, noise drums) is a style, free for everyone; a specific melody is someone’s work. The example soundtracks are originals written in the style of the eras they honor, and the difference between “evokes” and “copies” is the difference between a soundtrack and a takedown notice. Eight notes you own beat thirty-two you borrowed.