It documents the process of me smashing my head against the keyboard to build a game called LETSGO
It’s gotten long enough to break into several sections:
Programming Clear Rules
When designing software, it’s critical to think in terms of data.
If you know what data you have, and what data you need, it becomes clear how your program must manipulate input data to create output data.
And by starting with constant data- the stuff you know will not change- you have a natural starting point for your software design.
In western music theory, notes are separated into octaves by 12 even steps.
This is technically out of tune:
Thankfully for us it doesn’t matter, because this form of tuning is constant.
Those constants give us 12 notes, labeled A → G, with some funny ♯ and ♭ symbols thrown in to get us 12 notes.
My first stab at designing a music theory engine is to capture these as constants:
// For now we are only concerned with 12TET traditional western music
enum Notes {
A,
B,
C,
D,
E,
F,
G,
CSHARP,
DSHARP,
FSHARP,
GSHARP,
ASHARP,
DFLAT,
EFLAT,
GFLAT,
AFLAT,
BFLAT
};
An enum (enumeration) gives us a symbolic representation of a set of related values.
These notes are related, and the enum gives us a intuitive way to understand the data of our program:
Notes tonic = A;
The “tonic” here means that A will be our “home” key. The vast majority of music that culturally western people hear are structured around a tonic note.
Given these notes, we can start to map out different scales:
struct Scale {
std::string name;
Notes notes[7]; // Use the Notes enum type
Scale(const std::string& scaleName, const Notes(&scaleNotes)[7]) : name(scaleName)
{
for (int i = 0; i < 7; i++) {
notes[i] = scaleNotes[i];
}
}
};
Scale cMajor("C Major", { C, D, E, F, G, A, B });
Scale cMinor("C Minor", { C, D, EFLAT, F, G, AFLAT, BFLAT });
Scale dMajor("D Major", { D, E, FSHARP, G, A, B, CSHARP });
Scale dMinor("D Minor", { D, E, F, G, A, BFLAT, C });
Scale eMajor("E Major", { E, FSHARP, GSHARP, A, B, CSHARP, DSHARP });
Scale eMinor("E Minor", { E, FSHARP, G, A, B, C, D });
Scale fMajor("F Major", { F, G, A, BFLAT, C, D, E });
Scale fMinor("F Minor", { F, G, AFLAT, BFLAT, C, DFLAT, EFLAT });
Scale gMajor("G Major", { G, A, B, C, D, E, FSHARP });
Scale gMinor("G Minor", { G, A, BFLAT, C, D, EFLAT, F });
Scale aMajor("A Major", { A, B, CSHARP, D, E, FSHARP, GSHARP });
Scale aMinor("A Minor", { A, B, C, D, E, F, G });
Scale bMajor("B Major", { B, CSHARP, DSHARP, E, FSHARP, GSHARP, ASHARP });
Scale bMinor("B Minor", { B, CSHARP, D, E, FSHARP, G, A });
Complexity of Scales
This implementation isn’t bad as a first random stab at organizing the data.
But we’re missing all those ♯ and ♭ (called accidentals)- such as bFlatMinor
(B♭, C, D♭, E♭, F, G♭, A♭)
Manually mapping each one gets problematic. I'd have to double this list for those sharps and flats, and hope I didn’t copy anything down incorrectly.
Things get especially problematic since Major and Minor are only two types of “modes”. There are 5 other modes- and that’s just in the Greek(?) Church(?) Western Triadic(?) tradition:
- Ionian (Major)
- Dorian
- Phrygian
- Lydian
- Mixolydian
- Aeolian (Minor)
- Locrian
If I wanted to support these modes in the future, I have to map each one out- so that giant list multiplied seven times. In terms of organizing my data, this doesn’t feel like a great approach.
The good news is, each note in a scale is being mathematically derived.
Dead simple math too.
It turns out computers are really fast at doing simple math.
My next attempt is to codify the rules by which the notes can be derived:
enum Steps {
WHOLE = 2,
HALF = 1
};
const struct Mode {
std::string name;
Steps steps[7];
Mode(const std::string& modeName, const Steps(&modeSteps)[7]) : name(modeName)
{
for (int i = 0; i < 7; i++) {
steps[i] = modeSteps[i];
}
}
};
// Given what we've defined above, we can define:
Mode ionian( "Ionian", { WHOLE, WHOLE, HALF, WHOLE, WHOLE, WHOLE, HALF });
A “Mode” is a pattern for generating a scale. It consists of seven “steps” (sorry Brits, we’re not calling them tones here). Whether you go a whole step (C → D) or half step (D → D#) is what defines the mode.
The Ionian pattern of whole/half steps is the constant rule for this scale. Whole steps means you jump 2 notes, half means you jump 1 note.
Consider the following array:
//No B# - it's the same as C so we just use C.
// It gets weird if you think about it too long
const Notes musical_notes[] = { A, ASHARP, B, C, CSHARP, D, DSHARP, E, F, FSHARP, G, GSHARP, };
Tonic is A
Ionian has the pattern WHOLE
, WHOLE
, H
alf, etc.
- Start with
A
- Move up 2 notes (WHOLE) =
B
- Move up 2 notes (WHOLE) =
CSHARP
- Move up 1 note (HALF) =
D
- Etc.
And this gives us the A Major scale (A, B, C♯, D, E, F♯, G♯). Doesn’t matter which key you start with, the step-pattern will always give you the correct notes for the scale.
This approach is better than our initial one, because we have a better ability to derive the notes of the scale.
I realized after the fact that one problem with the above implementation is that it assumes a 7 note scale. This excludes 5 note pentatonic scales, or the whole 12 note chromatic scale.
This exposes one of the dangers of working with constant data: expressing something that should be dynamic as a constant can have long term consequences.
In essence notes must be in alphabetical order at all times, but the first letter of the alphabet is the tonic… It starts generating weird edge cases with odd accidentals like 𝄫 and 𝄪
I’ll have to learn the particulars of this and act accordingly, but it’s no where near the critical path for making a music game.
The Critical Path to Something Playable
The point is, using this system we could imagine the implementation of an improvement to our game so far:
- At the beginning of play, a
tonic
and amode
are randomly selected - Generate the
scale
for that tonic - Each time a Music Platform is spawned, the note for that platform is the next whole/half step for that mode.
So the first music platform might randomly choose a A
as the tonic.
The next spawned platform would show B
Then C#, D, E, F#, G#, back to A.
This only works if the notes provided are in the correct order. In the Notes
enum we initially created, we listed A-G, then all supported sharps and flats.
But from a sound perspective we only really have the 12 notes.
I expect this is a somewhat thorny issue. On Abletons piano roll, it only sharpens.
The good news is that this gives us a canonical ordered list:
[
C, C#, D, D#, E, F, F#, G, G#, A, A#, B ]
A + 2 = B
, B + 2
wraps around to C#
, and so on.
We can traverse the array using our Modes step pattern. A → B → C# → etc.
The note we display might be incorrect, but in theory the sound will be correct and that should be considered our critical path.
There is probably a way to calculate the correct notation (D♭ and C are the same note, but notated differently based on alchemic principles of music notation, and the context of the tonic).
But that calculation is in the weeds. I just want to hear the correct sound, bruh.
// For now we are only concerned with 12TET traditional western music
// Named by circle of fifths chart. Not A#, Bb. Notation is bonkers.
// This is an ordered list. That might have consequences down the road.
UENUM(BlueprintType, Category = LetsGoBlueprintCategory)
enum ELetsGoMusicNotes : uint8 {
C UMETA(DisplayName = "C"),
CSharp UMETA(DisplayName = "C♯"),
D UMETA(DisplayName = "D"),
EFlat UMETA(DisplayName = "E♭"),
E UMETA(DisplayName = "E"),
F UMETA(DisplayName = "F"),
FSharp UMETA(DisplayName = "F♯"),
G UMETA(DisplayName = "G"),
AFlat UMETA(DisplayName = "A♭"),
A UMETA(DisplayName = "A"),
BFlat UMETA(DisplayName = "B♭"),
B UMETA(DisplayName = "B"),
};
I might change it to Gb instead of F#, that one seems more 50/50 than other notes?
So loosely we want something that does:
int main() {
Notes tonic = A;
Notes currentNote = A;
Mode mode = dorian;
for (int i = 0; i < 9; i++) {
// print currentNote
// currentNote = getNextNoteInMode(tonic, currentNote, dorian)
}
}
Good ol Chat Jippity gives us the following:
// find currentNote index in musical_notes[]
// set stepIncrement - from tonic and mode, determine if the next note is a WHOLE (stepIncrement == 2) or a HALF (stepIncrement == 1)
// return musical_notes[current_note + stepIncrement]
Notes getNextNoteInMode(const Notes& tonic, const Notes& currentNote, const Mode& mode) {
// Find the index of the currentNote in the musical_notes array
int currentNoteIndex = -1;
for (int i = 0; i < sizeof(musical_notes) / sizeof(musical_notes[0]); i++) {
if (musical_notes[i] == currentNote) {
currentNoteIndex = i;
break;
}
}
if (currentNoteIndex == -1) {
return tonic;
}
// Determine the stepIncrement based on the mode
Steps stepIncrement = mode.steps[currentNoteIndex % 7];
// Calculate the index of the next note
int nextNoteIndex = (currentNoteIndex + stepIncrement) % 12;
// Return the next note from the musical_notes array
return musical_notes[nextNoteIndex];
}
This seems like a reasonable first pass at jumping around a scale array.
But this is untested, bringing us neatly to the next sticking point.
Ain’t No main()
Here
Now we must figure out how to make this an Unreal function… not to mention make our enums recognized in Unreal…
In blueprints, I want to select a random mode
and tonic
and have the Spawner spit out the next note
in that modes scale
.
Which means figuring out how to surface these things in Unreal Blueprints.
It turns this is a #wholething.
In the next section, we’ll start exploring the vagaries of C++ development in Unreal.
Surely previous programming experience will translate neatly into to learning C++.
Naturally Unreal won’t have its own deep, poorly documented flavor of C++.