Five Notes That Can't Be Played Wrong
In 2012 I spent twelve hours making a rhythm game that writes its own music. It's called Terrible Rhythm, the subtitle is "My procedural music is bad", and the repository is named spamgame.
The generator is about a hundred and forty lines of Unity C# and contains no music theory whatsoever. There's no scale, no chord table, no interval arithmetic, no check that one note goes acceptably after another. It picks notes at random and the result is listenable, because the constraint isn't in the code.
The Note Set Is a Chord
Here is the entire pitch selection:
pitch = Random.Range(0, spawnerArray.Length);An unguarded uniform pick over however many spawners exist in the scene. Nothing filters it and nothing corrects it afterward.
It works because of what's in the sounds folder:
e.wav g.wav b.wav g2.wav b2.wav
E, G and B are an E minor triad. g2 and b2 are two of those an octave up. Five playable voices, and all five belong to the same chord.
That's the whole design. Any note is consonant with any other note, so any sequence of them is consonant, and so is any two of them sounding at once while the release envelope is still open. There's no wrong choice available to make, which is why the selection code doesn't have to make a right one.
The music theory is real and it's all upstream, expressed once as a decision about which five files to record. The generator inherits it by having nothing else to pick from.
A second set sits beside the first:
notee.wav noteg.wav noteb.wav noteg2.wav noteb2.wav
Sustained chord voices that fade, and short retriggerable note voices, one pair per pitch. Music.cs keeps them in separate branches of the hierarchy, Chords and Notes, so a held key can swell the chord while each successful hit fires a percussive attack.
The mapping from key to pitch is authored rather than coded. A paddle carries both as plain strings:
public string note;
public string key;filled in per paddle in the scene, and the controls screen shows where they ended up: Q, W, E, R, and T, five adjacent keys under the left hand, one per voice. No script knows which key plays which note, or that there are five of them. Music.PlayNote takes a name and finds the child object with it, and the generator sizes its pitch range from FindGameObjectsWithTag("Spawner") at runtime, so a sixth voice would need a prefab, a spare key and one more line in Music.Start, which is the only place the five names are written down.
The Phrase Is the Unit, Not the Note
Generation happens eight beats at a time:
float phraseLength = 8;
int repeats = 4;
int currentRepeat = 4;A phrase is filled, played four times, and then thrown out and refilled. currentRepeat starts at the repeat count so the first frame generates immediately rather than needing a separate initialization path.
Repetition is what separates generated music from noise. A stream of individually plausible notes with no structure sounds like a stream of individually plausible notes. The same eight beats four times sounds like a decision somebody made. Then the phrase changes and the change is audible, because there's an established thing for it to differ from.
Eight beats and four repeats is thirty two beats, which at 128 BPM is fifteen seconds of music per composition.
Difficulty Is a Remapping, Not a Range
Note lengths come from a roll and then get bent:
int l = Random.Range(1,8);
...
int mult = (int)(GameObject.Find("GameTimer").GetComponent<GameTimer>().gameTimer/60.0f);
if(mult<1){
if(l==1) l=2;
else if(l==2) l=2;
else if(l==3) l=4;
...
else if(l==8) l=8;
}Ten of those blocks, one per difficulty band, selected by gameTimer/60 so the game gets harder every sixty seconds. length = l*0.5f at the end, so lengths land on half beats.
The construction is the interesting part. The roll is always the same, uniform over one to eight. Every band maps that roll onto the same small set of legal durations, and only the proportions change. In the first minute, rolls of six, seven, and eight all become 8, so long notes dominate. By the last band nearly everything becomes 1 and only an eight survives as a 2.
The output alphabet stays fixed at whole, half, quarter, and eighth. Difficulty moves weight across it. That's why the game gets faster without ever getting arrhythmic: it can't produce a duration that isn't on the grid, at any difficulty, because the remap has no other values to give.
Writing it as a hundred lines of if rather than a table of weights is the twelve hour deadline showing, and it has one real advantage. Every band is visible at once as a column of numbers, so tuning the curve means reading down the page rather than reasoning about a distribution.
Half Notes Come in Pairs
One flag carries the only piece of rhythmic grammar in the file:
if(nexthalf){
nexthalf = false;
l=1;
}
else{
... the difficulty remap ...
if(l==1) nexthalf = true;
}When the generator produces the shortest note, the next note is forced to match it.
Eighth notes arriving alone push everything after them off the beat. Arriving in pairs, they fill a beat exactly and the phrase stays aligned to the grid. It's one boolean, it bypasses the difficulty table entirely when set, and it's the difference between syncopation and drift.
The phrase is also closed off at the end:
if(length+usedTime<=phraseLength){
//good
}
else{
length = phraseLength-usedTime;
}The last note is shortened to whatever is left, so a phrase always totals exactly eight beats and the loop point lands on the barline. The empty if with a //good comment is the condition being read in the order it was thought about.
The Note and the Level Are the Same Object
In most rhythm games a chart is authored against a piece of music. Here there is no chart. The generated note is the gameplay:
public void BulletSpawn(float length){
...
float bulletLength = length*38.0f;
tailObj.transform.Translate(Vector3.up*bulletLength/2.0f);
tailObj.transform.localScale = new Vector3(2.5f,bulletLength,2.5f);
}The falling block's tail is scaled directly by the note's duration, times a constant that converts beats to world units. A held note is a long block because it is the same number.
The generator also runs its own clock from the composition:
spawnTime = phrase[phraseStep].length * baseTime;The delay before the next spawn is the current note's length, so spawning is variable rate and driven by the music rather than by a fixed tick. baseTime is 1.0f/(bpm/60.0f), seconds per beat written as the reciprocal of beats per second.
Hitting a block plays its pitch and scores it, with the multiplier taken from the same difficulty clock:
int mult = (int)(...gameTimer/60.0f)+1;
mult*=10;So one number, elapsed time, drives note density, score value, and how hard the phrase is to read, and they all move together.
What Was Switched Off at the Deadline
Two things are commented out, and in a twelve hour jam both are the sensible position for a switch to be left in.
//Random.seed = 1234;A fixed seed for hearing the same composition twice while tuning the remap tables, disabled so the shipped game varies.
And in the backing track:
if(...Find("Snare")...volume < 1) ...volume += fade * Time.deltaTime;
if(...Find("Hat")...volume < 1) ...volume += fade * Time.deltaTime;
//if(...Find("Bass")...volume < 1) ...
//if(...Find("Pad")...volume < 1) ...
//if(...Find("Seq")...volume < 1) ...Six backing stems exist, all six are wired up, and three of the fade-ins are switched off. Kick starts at full, snare and hat come up over time, and bass, pad, and sequencer stay silent. The arrangement that shipped is a subset of the arrangement that was built, chosen by commenting out three lines during the last hours.
What I'd Change
Clamp the release envelope. Music.cs subtracts from volume every frame with no floor, so the value keeps going negative once a note has faded. Unity clamps it on the way to the mixer, which is why it sounds correct, and the stored number drifts further from zero the longer the game runs.
Make the difficulty bands data. Ten near-identical blocks of eight comparisons is the fastest thing to type and the slowest thing to change, and the same curve is a small array of weights per band.
And cache the lookups. GameObject.Find("GameTimer"), GameObject.Find("Music") and FindGameObjectsWithTag("Spawner") all run inside Update, some of them per note. At this scale nothing notices, and it's the habit that stops scaling first.
What Transfers
Put the constraint in the content rather than the code. Every note this generator can play is consonant because of which five sounds were recorded, so the selection logic is one unguarded call to a random number generator. Choosing an alphabet where every symbol is valid is usually cheaper than validating symbols after the fact, and it can't be circumvented by a later change to the selection code.
Generate in phrases and repeat them. Structure is what makes generated output read as composed, and repetition is the cheapest structure there is.
Remap a fixed roll onto a fixed set of legal values, and vary the mapping rather than the range. The output can then never leave the set, whatever the difficulty, which is how this game speeds up without ever falling off the beat.
And when the generated thing is also the thing being played, there's no chart to keep in sync with anything. A note's duration is its block's height, and the two cannot disagree because they are one number.