So far, game music in this course has meant one thing: press play on an AudioClip, set loop to true, and let it run underneath the game. That is fine for a title screen. It falls apart the moment the game itself is unpredictable — a fight that could last five seconds or five minutes, a boss that could appear the instant the player walks through a door, or a player who runs from combat and wanders back three times before actually fighting. No composer can write music timed to events that have not happened yet.
This chapter covers adaptive music (also called interactive music): music built, from the start, to change in response to what is happening in the game, instead of just playing through one fixed arrangement. Two techniques do almost all of the real work — horizontal re-sequencing and vertical layering — plus two supporting ideas, stingers and quantized (beat-aligned) transitions, that make both techniques sound intentional instead of glitchy. By the end you will have built a beat-quantized segment switcher and a layered-intensity mixer in C#, and you will understand what audio middleware like Wwise or FMOD is doing for you when a studio has the budget to use one.
A movie composer knows, to the frame, when the hero draws their sword. They write sixty seconds of music timed exactly to that moment. A game composer has no such luxury — the player decides when to draw the sword, and might do it during dialogue, mid-jump, or not for another ten minutes. Any system that just plays a fixed track and swaps to another fixed track the instant something happens will, almost always, swap in the middle of a musical phrase.
using UnityEngine;
public class NaiveMusicSwitcher : MonoBehaviour
{
public AudioSource musicSource;
public AudioClip exploreClip;
public AudioClip combatClip;
public void EnterCombat()
{
musicSource.clip = combatClip;
musicSource.Play(); // starts immediately, wherever exploreClip happened to be
}
}
Here musicSource is one AudioSource looping exploreClip. The moment EnterCombat() runs, whatever exploreClip happens to be doing gets cut off — mid-note, mid-drum-fill, wherever the player's timing landed — and combatClip starts fresh from its own beat one, with a completely different rhythm underneath it while the ear catches up.
This is the entire problem this chapter solves. Everything from here on is really one question asked in different forms: how do you change what the player hears without it sounding like a mistake?
Two techniques, used by nearly every game with dynamic combat (including HoYoverse titles like Genshin Impact and Honkai: Star Rail), solve this in different ways:
Neither technique is "better" — they solve different problems. Horizontal re-sequencing is for moments where the music itself needs to be a different piece (a village theme has nothing in common with a dungeon theme). Vertical layering is for moments where the music is still fundamentally the same idea, just more or less intense (the same boss theme, thin while its health is high, full once it drops low). Most real game scores use both at once: horizontal re-sequencing to move between big states like explore/combat/boss, and vertical layering inside each of those states to react to smaller changes, like how many enemies are left.
Both techniques depend on one idea: knowing exactly where you are in the music, measured in musical time instead of plain seconds. Three terms, used constantly in this chapter:
Converting BPM into real seconds is arithmetic used in every remaining section of this chapter:
using UnityEngine;
public class TempoMath : MonoBehaviour
{
public float bpm = 120f;
public int beatsPerBar = 4;
void Start()
{
double secondsPerBeat = 60.0 / bpm;
double secondsPerBar = secondsPerBeat * beatsPerBar;
Debug.Log("One beat = " + secondsPerBeat + "s, one bar = " + secondsPerBar + "s");
}
}
One beat = 0.5s, one bar = 2s
At 120 BPM, one beat is exactly half a second, and one bar (4 beats) is exactly 2 seconds. This is part of why 120 BPM is such a common tempo for game composers to pick — it makes the seconds-per-bar math land on a clean, easy number.
One more piece: Unity's frame-based Time.time is not accurate enough for this. Time.time only updates once per rendered frame, and frame timing jitters — one frame might take 16ms, the next 19ms. Audio hardware runs on its own, much steadier clock, exposed in Unity as AudioSettings.dspTime (a double giving the current time of the audio system in seconds, unrelated to the frame rate). Every scheduling calculation in this chapter uses dspTime, never Time.time, because even a fraction of a frame's worth of drift is audible as a transition landing slightly off the beat.
Horizontal re-sequencing starts by splitting the score into segments — separate audio clips, one per game state, all written at the same BPM and the same bar length so any one of them can follow any other. A typical setup has several interchangeable segments per state (so the player is not stuck hearing the exact one Explore clip on a loop — more on that in section 11), tied to a small state graph.
public enum MusicState { Explore, Combat, Boss }
[System.Serializable]
public class MusicStateSegments
{
public MusicState state;
public AudioClip[] variants; // several clips per state, same tempo and bar length
}
Two things matter about that graph. First, states are usually mutually exclusive — the game is in exactly one of Explore, Combat, or Boss at a time, never a blend of two (that is what vertical layering is for, inside one state). Second, and this is the point of the rest of this section: every arrow is quantized. A request to move from Explore to Combat does not cut the instant it is requested — it gets queued, and only actually swaps on the next bar line. That single rule is what keeps horizontal re-sequencing from sounding like the naive switcher in section 1.
Quantization, in the musical sense, means snapping a moment to the nearest point on a musical grid — usually the nearest beat or the nearest bar — instead of letting it happen at the exact, arbitrary instant it was requested. A quantized transition is a segment swap that has been snapped to a bar line this way.
The reasoning is straightforward once you see the diagram: every segment sharing the same BPM and the same bar length means beat one of any bar in Combat lines up rhythmically with beat one of any bar in Explore. If the switch always happens exactly on a bar line, the transition sounds like the composer wrote it that way on purpose — because, in effect, they did: they wrote every segment able to follow any other segment, as long as the switch respects the beat grid.
There is a real cost: quantizing adds latency. At 120 BPM with a 4-beat bar, a request arriving right after a bar line just started has to wait almost the full 2 seconds for the next one. For music, that small delay is worth it — a 2-second-late but clean transition sounds far better than an instant but jarring one. Section 9 revisits this trade-off for stingers, where the opposite choice is usually correct.
Turning section 5's diagram into code needs two Unity features: AudioSource.PlayScheduled(double dspTime) starts a clip at a precise, future point on the audio clock instead of "as soon as possible," and AudioSource.SetScheduledEndTime(double dspTime) stops a currently-playing clip at a precise future point. Both take the same dspTime units as AudioSettings.dspTime, which is what makes them line up exactly.
using UnityEngine;
// Horizontal re-sequencer: only one segment plays at a time, but a
// requested switch always waits for the next bar line before it happens.
public class HorizontalMusicPlayer : MonoBehaviour
{
public AudioSource activeSource; // currently playing segment
public AudioSource standbySource; // holds the next segment, cued up in advance
public float bpm = 120f;
public int beatsPerBar = 4;
private double segmentStartDsp; // dspTime the active segment's beat 1 landed on
private AudioClip queuedClip;
private bool hasQueuedClip = false;
public void PlayFirstSegment(AudioClip clip)
{
segmentStartDsp = AudioSettings.dspTime + 0.1; // small safety offset
activeSource.clip = clip;
activeSource.PlayScheduled(segmentStartDsp);
}
// called by gameplay code: "switch to this segment, whenever the beat allows it"
public void RequestSegment(AudioClip clip)
{
queuedClip = clip;
hasQueuedClip = true;
}
void Update()
{
if (!hasQueuedClip) return;
double secondsPerBeat = 60.0 / bpm;
double secondsPerBar = secondsPerBeat * beatsPerBar;
double now = AudioSettings.dspTime;
double elapsed = now - segmentStartDsp;
double barsSoFar = System.Math.Ceiling(elapsed / secondsPerBar);
double nextBarDsp = segmentStartDsp + barsSoFar * secondsPerBar;
standbySource.clip = queuedClip;
standbySource.PlayScheduled(nextBarDsp);
activeSource.SetScheduledEndTime(nextBarDsp);
Debug.Log("Queued switch to " + queuedClip.name + " at dsp time " + nextBarDsp
+ " (in " + (nextBarDsp - now).ToString("F2") + "s)");
AudioSource temp = activeSource; // swap roles for the next request
activeSource = standbySource;
standbySource = temp;
segmentStartDsp = nextBarDsp;
hasQueuedClip = false;
}
}
activeSource and standbySource are two separate AudioSource components holding the same role in turn — never both playing at once, but always ready to hand off. Two are needed, not one, because you cannot safely change the clip on a source that is mid-playback without stopping it first; the incoming segment has to be cued up on a second source in advance, scheduled to start the instant the outgoing one is scheduled to end.
RequestSegment just records which clip is wanted and sets a flag — it does no math itself, because the request can arrive at any arbitrary moment and should not block anything. The real work happens next time Update() runs: elapsed is how far into the active segment playback currently is, in seconds; dividing by secondsPerBar and rounding up with Math.Ceiling finds how many whole bars have to pass before reaching a bar line at or after right now; multiplying back by secondsPerBar and adding to segmentStartDsp gives the exact dspTime of that bar line.
Trace it with bpm = 120, beatsPerBar = 4 (so secondsPerBar = 2.0), a segment that started at segmentStartDsp = 10.0, and a RequestSegment call that lands right when Update() runs at dspTime = 11.3:
Queued switch to CombatA at dsp time 12 (in 0.70s)
elapsed came out to 1.3 seconds into a 2-second bar (bar one spans dsp 10.0 to 12.0). Math.Ceiling(1.3 / 2.0) rounds 0.65 up to 1, meaning "wait for one more bar boundary," which lands at 10.0 + 1 * 2.0 = 12.0 — the very next bar line, 0.7 seconds after the request came in. If the request had instead arrived at dsp 11.9, only 0.1 seconds before that same bar line, the wait would have been a mere 0.1 seconds instead of nearly a full bar — the wait length depends entirely on how close the request lands to the next bar line, never longer than one full bar.
elapsed using Time.time instead of AudioSettings.dspTime. Time.time is tied to the (variable) frame rate; dspTime is the audio hardware's own clock. Mixing the two means your calculated "bar line" slowly drifts away from where the audio hardware actually is, and after enough transitions the drift becomes an audible click or an early/late switch. Compute all audio scheduling entirely in dspTime, and only ever read Time.time for things allowed to be a frame or two loose, like UI.Vertical layering starts from a different premise: instead of many different pieces of music, there is one ongoing piece, exported from the composer's session as several stems (separate audio files for each instrument group — drums, bass, strings/pads, melody — all the exact same length, all the exact same tempo, all meant to be played together). At any moment every stem is playing; what changes is how loud each one is.
The single most important rule of vertical layering: never stop or restart a layer once the piece begins. All four AudioSources start together, at the same instant, and then keep running for the entire time the player is in that state — sometimes for minutes. Only their volume changes. This guarantees the four layers can never drift apart rhythmically, because from the audio engine's point of view they are not four separate pieces of music being coordinated — they are four copies of one piece of music, always at the exact same playback position, just mixed at different loudness.
Compare this directly with horizontal re-sequencing: horizontal changes which melody the player hears (Explore vs Combat are different musical ideas). Vertical changes how much of one ongoing musical idea the player hears (the same boss theme, thin and quiet at first, full and loud once the fight escalates). A single float — an intensity value from 0 to 1 — is usually enough to drive it.
using UnityEngine;
// All four layers share ONE clip length and ONE start time. This code
// never starts or stops a layer -- it only changes volume, so the
// layers can never drift out of sync with each other.
public class VerticalMusicMixer : MonoBehaviour
{
public AudioSource drums;
public AudioSource bass;
public AudioSource strings;
public AudioSource melody;
[Range(0f, 1f)] public float intensity = 0f;
public float fadeSpeed = 1.5f; // volume units per second
void Start()
{
double startDsp = AudioSettings.dspTime + 0.1;
drums.volume = 0f;
bass.volume = 0f;
strings.volume = 0.3f;
melody.volume = 1f;
drums.PlayScheduled(startDsp);
bass.PlayScheduled(startDsp);
strings.PlayScheduled(startDsp);
melody.PlayScheduled(startDsp);
}
void Update()
{
float drumsTarget = intensity >= 0.6f ? 1f : 0f;
float bassTarget = intensity >= 0.35f ? 1f : 0f;
float stringsTarget = Mathf.Lerp(0.3f, 0.7f, intensity);
float melodyTarget = 1f; // melody is always present, at full volume
drums.volume = Mathf.MoveTowards(drums.volume, drumsTarget, fadeSpeed * Time.deltaTime);
bass.volume = Mathf.MoveTowards(bass.volume, bassTarget, fadeSpeed * Time.deltaTime);
strings.volume = Mathf.MoveTowards(strings.volume, stringsTarget, fadeSpeed * Time.deltaTime);
melody.volume = Mathf.MoveTowards(melody.volume, melodyTarget, fadeSpeed * Time.deltaTime);
}
// called from gameplay: enemy distance, combat state, boss phase, etc.
public void SetIntensity(float value)
{
intensity = Mathf.Clamp01(value);
}
}
Start() schedules all four layers with PlayScheduled at the exact same startDsp, with a small 0.1-second safety offset (enough time for Unity to actually prepare the clips before the deadline arrives). Scheduling all four from the same dspTime value, in the same frame, is what guarantees they begin on the same sample — four separate Play() calls made across even two or three different frames could each land a few milliseconds apart, which is not a huge gap, but is exactly the kind of tiny desync (sometimes called phasing) a trained ear can pick out over a few minutes of listening.
Update() is the only place the actual mixing decision happens, and it never touches the intensity field directly — gameplay code only ever calls SetIntensity(value), and Update() reads it every frame. Each layer gets a target volume computed from intensity: drums and bass are simple on/off thresholds (drums only above 0.6, bass only above 0.35), strings ramp smoothly across the whole range with Mathf.Lerp, and melody stays at full volume throughout, because a melody that vanishes at low intensity would make the music feel incomplete rather than calm. Mathf.MoveTowards eases each layer's actual volume toward its target at a fixed speed (fadeSpeed, in volume units per second) instead of snapping instantly, so a sudden intensity change fades in smoothly rather than jumping.
Trace a scenario with fadeSpeed = 1.5: intensity starts at 0 (settled), then SetIntensity(0.5) is called at t = 2.0s, and SetIntensity(0.8) is called at t = 5.0s:
Notice bass never needed to move the second time (it was already at its target of 1), and drums only started moving at t = 5.0s, because 0.5 never crossed its 0.6 threshold but 0.8 did. Nothing snaps — every change is a smooth ramp at the same fixed speed, which is why a sudden jump from 0 to 0.8 intensity still sounds like a fade, not a cut.
Mathf.Clamp01 inside SetIntensity. If gameplay code derives intensity from something unbounded, like (maxHp - currentHp) / someDivisor, it is easy to pass a value like 1.4 or -0.2 by accident. Every threshold and Mathf.Lerp call above assumes intensity stays between 0 and 1; outside that range, Lerp will happily extrapolate past 0.7 for strings, and a negative value can make every comparison behave in ways nobody tested. Clamp once, at the only entry point, and every line after it can trust the value.A stinger is a short, one-shot musical phrase — a brass hit, a choir stab, a rising sweep — layered on top of whatever music is already playing, to punctuate a single instant: a boss appearing, the player leveling up, a rare item drop. Unlike sections 4-8, a stinger does not represent an ongoing state; it fires once and is done.
using System.Collections;
using UnityEngine;
public class MusicStingerPlayer : MonoBehaviour
{
public AudioSource stingerSource; // separate channel, layered on top of the music
public AudioSource[] musicLayers; // duck these briefly while the stinger plays
public float duckAmount = 0.4f; // fraction to lower music volume by
public float duckTime = 0.15f;
public float minCooldown = 1.0f; // ignore repeats faster than this
private float lastPlayTime = -999f;
public void PlayStinger(AudioClip stingerClip)
{
if (Time.time - lastPlayTime < minCooldown)
{
Debug.Log("Stinger " + stingerClip.name + " suppressed (too soon)");
return;
}
lastPlayTime = Time.time;
stingerSource.PlayOneShot(stingerClip);
StartCoroutine(DuckMusic());
Debug.Log("Stinger: " + stingerClip.name);
}
private IEnumerator DuckMusic()
{
float[] original = new float[musicLayers.Length];
for (int i = 0; i < musicLayers.Length; i++)
original[i] = musicLayers[i].volume;
for (int i = 0; i < musicLayers.Length; i++)
musicLayers[i].volume *= (1f - duckAmount);
yield return new WaitForSeconds(duckTime);
for (int i = 0; i < musicLayers.Length; i++)
musicLayers[i].volume = original[i];
}
}
PlayOneShot plays the stinger clip without disturbing anything else stingerSource might play later, layered on top of the ongoing music. DuckMusic briefly ducks (temporarily lowers one sound's volume so another sound is clearly audible on top of it) every layer's volume by duckAmount for duckTime seconds, then restores each layer's original volume exactly, so a big stinger is not fighting the full mix for the listener's attention.
Trace two PlayStinger calls for the same clip, 0.3 seconds apart, with minCooldown = 1.0:
Stinger: BossReveal
Stinger BossReveal suppressed (too soon)
The cooldown exists because game events do not politely space themselves out — three enemies could die within the same half-second, each one wanting to fire the same "enemy defeated" stinger. Without a cooldown, three overlapping copies of the same short phrase turn into noise instead of three clean sting hits.
Unlike section 5's segment transitions, PlayStinger fires immediately, with no wait for a bar line. That is a deliberate choice: a stinger usually represents a sudden, surprising event, and instant feedback matters more here than rhythmic alignment — the player should hear the boss reveal the moment it happens, not up to two seconds later. Some games do quantize certain stingers to the nearest beat for a punchier, more "musical" hit; which way to go is a call the composer and the gameplay designer make together, event by event.
Everything in sections 5 through 8 is real, useful code — and also exactly the kind of code a dedicated tool exists to do for you. Audio middleware (external tools used alongside Unity or Unreal, most commonly Audiokinetic Wwise and FMOD Studio) lets a composer or sound designer build the entire adaptive music system visually, without writing C#, and hand the game programmer a tiny API surface to call into.
Three ideas map directly onto what you already built:
MusicState enum, Explore/Combat/Boss) that the game sets with one call. The middleware's authoring tool already knows which segments belong to which state and how they are allowed to follow each other — this is horizontal re-sequencing, authored visually instead of in a C# switch statement.intensity float, that the game updates every frame. The composer draws a curve, per layer, mapping the parameter to that layer's volume inside the authoring tool — this is vertical layering, with the fadeSpeed/Mathf.Lerp math from section 8 replaced by a curve the composer can see and tweak without asking a programmer to change code.Math.Ceiling bar-line calculation, done for you.// Hand-rolled (sections 5-8 of this chapter): the game computes the
// next bar line itself and schedules the switch/fade by hand.
horizontalPlayer.RequestSegment(combatClip);
verticalMixer.SetIntensity(0.8f);
// Wwise-style: the game only announces WHAT changed. The sound designer
// already placed Bar/Beat/Exit Cue markers and wrote the transition
// rules inside the Wwise authoring tool -- no C# math needed for it.
AkSoundEngine.SetState("MusicState", "Combat");
// FMOD-style: a continuous parameter (an RTPC) drives the vertical
// layers, mixed by the composer in FMOD Studio instead of by our
// fadeSpeed/Mathf.Lerp code.
musicInstance.setParameterByName("Intensity", 0.8f);
Notice how small the C# side becomes: SetState and setParameterByName are one line each. All the scheduling, ducking, and curve math from this chapter still happens — it just happens inside the middleware's own engine, driven by data the composer authored in a visual tool, instead of inside your MonoBehaviour.
Repetition fatigue is what happens when a player notices, and gets tired of, hearing the exact same loop, transition, or stinger over and over. It is close to unavoidable in principle — a player can easily spend twenty minutes in one area that a composer wrote three minutes of music for — but a few habits keep it from being obvious:
using UnityEngine;
public class SegmentPicker
{
private AudioClip[] variants;
private int lastIndex = -1;
public SegmentPicker(AudioClip[] variants)
{
this.variants = variants;
}
// picks a random variant, but never repeats the same one twice in a row
public AudioClip PickNext()
{
int index;
do
{
index = Random.Range(0, variants.Length);
}
while (variants.Length > 1 && index == lastIndex);
lastIndex = index;
return variants[index];
}
}
Trace five calls to PickNext() with three variants, [E1, E2, E3]. One possible run (the exact sequence is random, but the rule is not):
Your own run will very likely land on a different sequence, because Random.Range is genuinely random — the only guarantee the do/while loop enforces is that index is never equal to lastIndex when the loop exits, so two identical segments can never play back to back.
AudioSettings.dspTime — Unity's steady audio-hardware clock, in seconds, used for precise scheduling instead of the frame-based Time.time.PlayScheduled / SetScheduledEndTime — Unity AudioSource methods that start or stop a clip at an exact future dspTime.HorizontalMusicPlayer (section 6) has bpm = 100 and beatsPerBar = 4. Its active segment started at segmentStartDsp = 20.0. RequestSegment is called, and Update() runs with AudioSettings.dspTime = 23.1.
Compute: (a) secondsPerBar, (b) elapsed, (c) nextBarDsp, (d) how many seconds after the request the switch actually happens, and (e) which bar (counting the segment's first bar as bar 1) the new segment starts on.
(a) secondsPerBeat = 60 / 100 = 0.6, so secondsPerBar = 0.6 * 4 = 2.4.
(b) elapsed = 23.1 - 20.0 = 3.1 seconds.
(c) barsSoFar = Math.Ceiling(3.1 / 2.4) = Math.Ceiling(1.291...) = 2, so nextBarDsp = 20.0 + 2 * 2.4 = 24.8.
(d) The switch happens 24.8 - 23.1 = 1.7 seconds after the request.
(e) Bar boundaries fall at dsp 20.0 (bar 1 starts), 22.4 (bar 2 starts), and 24.8 (bar 3 starts). The request at dsp 23.1 lands inside bar 2 (22.4 to 24.8), so the earliest legal switch point is the start of bar 3, and that is exactly where nextBarDsp = 24.8 lands.
VerticalMusicMixer (section 8) has fadeSpeed = 2.0. Intensity has been sitting at 0.2 for a while, so every layer has already settled at its target: drums = 0, bass = 0, strings = 0.38, melody = 1. At exactly t = 0, SetIntensity(0.7) is called and never changes again.
Compute: (a) the new target volume for each of the four layers, and (b) the exact time (in seconds after t = 0) each layer finishes fading to its new target.
(a) With intensity = 0.7: drumsTarget = 1 (0.7 >= 0.6), bassTarget = 1 (0.7 >= 0.35), stringsTarget = Lerp(0.3, 0.7, 0.7) = 0.3 + 0.4 * 0.7 = 0.58, melodyTarget = 1 (unchanged, it is always 1).
(b) drums: distance |1 - 0| = 1.0, at 2.0 units/sec that takes 0.5s, finishing at t = 0.5s. bass: same distance, same fade time, finishing at t = 0.5s. strings: distance |0.58 - 0.38| = 0.20, at 2.0 units/sec that takes 0.1s, finishing at t = 0.1s. melody: already at its target (1), so it needs no fade at all — effectively t = 0s.
(a) For each scenario, name the best fit — horizontal re-sequencing, vertical layering, or a stinger — with one line of reasoning: (i) the melody must completely change the moment the player leaves a village and enters a dungeon; (ii) a boss fight's music should get fuller and louder as the boss's health drops, without ever changing melody; (iii) the player lands a perfect dodge, and the game wants an instant one-off audio flourish.
(b) In section 6, HorizontalMusicPlayer always waits for the next bar line before switching, even though this can delay the switch by nearly a full bar. Why not skip the wait and switch instantly, the way NaiveMusicSwitcher in section 1 does? What would break?
(a) (i) Horizontal re-sequencing — a village and a dungeon need genuinely different musical ideas, not just a louder or quieter version of the same one. (ii) Vertical layering — it stays the same ongoing piece the whole fight, just with more layers turned up as intensity (boss health dropping) rises. (iii) Stinger — a one-off flourish tied to a single instant, not an ongoing state, is exactly what a stinger is for.
(b) Switching instantly would very likely cut the outgoing segment mid-phrase, at a beat unrelated to the incoming segment's own beat one — exactly the problem NaiveMusicSwitcher has in section 1, with the same audible clash. The whole point of sections 4-6 is removing that clash by only ever switching on a bar line, where any segment can follow any other segment cleanly. The wait (at most one bar) is a small, worthwhile trade for a transition that sounds deliberate instead of broken.
That is adaptive music end to end: a fixed loop breaks because the game does not know its own timing in advance, horizontal re-sequencing solves it by swapping between authored segments on a quantized bar line, vertical layering solves a different problem by mixing always-running stems with one intensity value, stingers punctuate single instants on top of either, and middleware like Wwise or FMOD lets a composer author states, RTPCs, and sync points visually so the C# side shrinks to a couple of calls. The one idea that ties all of it together is musical time — BPM, beats, and bars, measured in dspTime instead of frame time — because every technique in this chapter is really just different ways of respecting it.