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:
It's been the first week of work at my real job. Tired, excited, etc.
Probably means output on LETSGO will crater as things get going, but I’ve been so amped that I have energy to work on this as well.
Especially as I have the pieces necessary to implement Designing The Core Gameplay Loop
Here’s the thing, that design is bad.
Over-engineered slop that doesn’t actually do the thing it’s supposed to.
I quite literally fell into Architecture Astronomy.
The main problem is the SetTonic
phase needs a reference to the AudioPlatform
that sends the OnAudioPlatformTriggered
event.
My whole design is supposed to separate those objects.
Start With What’s Real
So I decided to work backwards from what currently exists.
My last big task for LETSGO was Refactor the Old Before Creating the New, where I have my Audio Platform triggering an event when stepped on.
So starting with the AudioPlatform and the OnAudioPlatformTriggered
delegate, I started to come up with a design to get it connected to the Set Tonic Phase as easily as possible.
* AudioPlatform Broadcasts OnAudioPlatformTriggered
* SetTonic Phase has references to three AudioPlatforms
* On Activate()
* -> SetTonic spawns the three AudioPlatforms in front of the Player
* -> Binds to all OnAudioPlatformTriggered events
* When OnAudioPlatformTriggered event received:
* -> Destroy all Platform
* -> Set Tonic in GameState
The key insight about this list is I started with what I already have - something broadcasting an event when the Audio Platform is triggered, and working backwards from there.
The original design idea was good in theory, but good design can only be accomplished if it takes the current system into account.
Good design starts with what you already have, and works backwards to define what you want next.
So, feeling pretty good about that- especially since it’s massively reduced the scope of this core gameplay loop thing.
FeelsBadMan: Refactoring Audio Platform Spawner
Those good feels hit a brick wall of “Oh I have yet more things to refactor”.
I have an Audio Platform Spawner blueprint that technically works, but needs to be updated to support spawning multiple platforms as a set:
This BP does a couple things:
- Generate all the notes for all the scales
- Add those notes to a
NoteContainer
- Call the Spawn Platform function every 1.0 seconds.
It’s this BP that is creating the Platforms and deciding the note of each platform:
I will need to update this so that:
- Note Container is provided as a parameter outside the object, instead of generating its own.
- Platforms are spawned next to each other in front of the player, instead of spawning every second
- Propagate a Note selected from any platform up to the SetTonic phase object.
The intent is that when the SetTonic
phase is active, three platforms will be spawned in front of the player, with 3 separate notes.
- Stepping on any Platform will destroy all platforms
- The Note is sent to the SetTonic phase object
- SetTonic updates the “Tonic” note, setting the musical key the rest of the song will use
So, first thing I need to do is make a AudioPlatformSpawner class, and update the BP parent class to it.
Got that done pretty quickly.
In order to spawn the actual platforms, I need a location for it to spawn to.
Currently I have a function that will get Vector Forward of the Camera and multiply by an arbitrary amount (500.0f).
That was all done in Blueprints, so I had to move that to code:
Now that I have that, I can use this function to get a root spawn location for the platforms SetTonic
intends to spawn.
Spawning Actors in Unreal
This is probably where I learned the most using Unreal while building this feature.
When I started building LETSGO, I would do everything in Blueprints. Now, I’m doing most of the logic in C++.
The optimal approach is to use both.
I created a C++ class AAudioPlatformSpawner : public AActor
An Actor is more-or-less the equivalent of a GameObject in Unity, the AAudioPlatformSpawner
lets me define all the properties specific to the Spawner:
Notice the Changed in 2 blueprints
notification. I have 2 blueprints whose base class is this AAudioPlatformSpawner, who can override default values in the parent class.
This Spawner is going to spawn an AudioPlatform using the method SpawnPlatform()
This one threw me for a loop. During development, I had this function spawning a new AAudioPlatform
.
The problem with that it loses the assets attached in the Unreal Blueprint- AAudioPlatform is a base class backing the Blueprint.
GetWorld()→SpawnActor<AAudioPlatform>(SpawnLocation)
is how I originally did this, but this would spawn the Parent class AAudioPlatform, not BP_AudioPlatform - the Blueprint built off AAudioPlatform.
In order to spawn the Blueprint, I need a TSubclassOf<Type>
.
You can see this in the property list picture above called AAudioPlatformClass
.
This property lets me define the blueprint I want to use in the editor, and map that to the AAudioPlatform I want to interact with in the Spawner C++ code:
This lets me do GetWorld()→SpawnActor<AAudioPlatform>(AAudioPlatformClass, SpawnLocation)
, spawning the BP and returning the parent C++ class I want the Spawner to manage.
And for AudioPlatformSpawner specifically, I’m doing a delayed spawn, allowing me to set a Note
value that is necessary for the AudioPlatform to spawn correctly.
This is great, I now have a AudioPlatformSpawner that can arbitrarily spawn AudioPlatform’s and it all works.
Now I can build a SetTonic
class that has an AudioPlatformSpawner.
Setting the Tonic Note: The First Phase
The one thing I did keep from my initial design was the concept of phases- an ordered list of gameplay events to make the game work like a game.
A Phase is fairly simple, defined by a PhaseController
interface describing what functions need to be added to be a phase:
// This class does not need to be modified.
UINTERFACE()
class UPhaseController : public UInterface
{
GENERATED_BODY()
};
class LETSGO_API IPhaseController
{
GENERATED_BODY()
// Add interface functions to this class. This is the class that will be inherited to implement this interface.
public:
virtual void Activate() = 0 ;
virtual bool IsActivated() = 0;
virtual void Deactivate() = 0;
virtual void Complete() = 0;
virtual bool IsCompleted() = 0;
virtual void InitiateDestroy() = 0;
};
The SetTonic
actor implements this, becoming a PhaseController
:
void ASetTonic::Activate()
{
// Get coordinates in front of the Camera
const FTransform CameraForward = Spawner->GetCameraVectorForward();
FVector RootLocation = CameraForward.GetTranslation();
// Each platform has a random note to set as the Tonic key
const TArray<FLetsGoMusicNotes> PlatformNotes = GetRandomNotes(NumPlatformsToSpawn);
// Spawn the Audio Platforms
for (int i = 0; i < NumPlatformsToSpawn; i++)
{
AAudioPlatform* SpawnedPlatform = Spawner->SpawnPlatform(CameraForward, PlatformNotes[i]);
// Offset the spawned platform position in a row in front of the player camera
const int SidePosition = i - (NumPlatformsToSpawn / 2);
const double YPos = SidePosition * OffsetAmountPerSpawnedPlatform;
RootLocation.Y += YPos;
RootLocation.Z = 1;
SpawnedPlatform->AddActorLocalOffset(FVector(0, YPos, 0));
// When user steps on platform, it will send event with the note triggered
// Subscribe to the event, trigger SetTonic() when received
SpawnedPlatform->OnAudioPlatformTriggered.AddDynamic(this, &ASetTonic::SetTonic);
}
// The PhaseManager event loop needs to know this Phase is Active
Active = true;
}
On Initialize, it creates the AudioPlatformSpawner
used above:
void ASetTonic::Initialize()
{
Spawner = GetWorld()->SpawnActor<AAudioPlatformSpawner>(AudioPlatformSpawnerClass);
}
On Complete
/ Deactivate
, it will gracefully destroy the Spawner and itself.
Concerns
One fear I have is that this interface implementing PhaseController
is a bit over-engineered. I have the difference between Deactivate and Complete. So far there hasn’t been any need to build Deactivation, just functionality to complete.
Regardless, I can’t bring myself to simplify it down. This is probably a mistake. I really want to build in the concept of being able to pause, and using Deactivate to achieve that. Still, this is probably a mistake as I should probably just put the explicit Pause function in the interface.
This is actually one of the big fears I have about this design choice. Using interfaces can be brittle if you’re not careful. Changing the definition of a heavily used interface means updating every object that implements it. The amount of work sprawls quickly if you are not careful.
With all that in mind, we forge ahead regardless, as this approach should, technically, work.
Managing Phases
The advantage of using interfaces is it allows us to create a Phase Manager:
void APhaseManager::BeginPlay()
{
Super::BeginPlay();
AStartClock* StartClock = GetWorld()->SpawnActor<AStartClock>();
StartClock->Initialize();
Phases.Emplace(StartClock);
ASetTonic* SetTonic = GetWorld()->SpawnActor<ASetTonic>(SetTonicClass);
SetTonic->Initialize();
Phases.Emplace(SetTonic);
UE_LOG(LogTemp, Display, TEXT("PhaseManager BeginPlay complete"));
}
The reason I like interfaces is because PhaseManager
only knows about PhaseControllers
.
The PhaseManager is only and explicitly managing the lifetime of a Phase, so it can have a set of IPhaseControllers
like SetTonic
and StartClock
. The specific logic for SetTonic doesn’t matter, all that matters is that is has the functions defined in the interface.
The other nice thing is that its harder for PhaseManager to leak its logic domain. By limiting it to only the functions surfaced by the PhaseController, the PhaseManager can only do the actions related to managing the PhaseControllers lifetime.
This allows me to create fairly simple queue processing for PhaseControllers managed by the PhaseManager:
void APhaseManager::ProcessPhases()
{
// Log spam if there's no Phases in the list
if (Phases.Num() == 0)
{
if (EmptyListWarnAmount < 5)
{
// TODO create custom Log Category
UE_LOG(LogTemp, Warning, TEXT("Empty Phase List"));
EmptyListWarnAmount++;
if (EmptyListWarnAmount == 5 )
{
UE_LOG(LogTemp, Warning, TEXT("Disabling Empty Phase List Warning."));
}
}
return;
}
EmptyListWarnAmount = 0;
// Currently the first phase in the list is the active one.
// It's removed once complete
// Eventually this will probably need to be expanded
// But for now a simple array with a single active Phase is fine
if (Phases[0]->IsCompleted())
{
IPhaseController* Completed = Phases[0];
Phases.RemoveAt(0);
Completed->InitiateDestroy();
}
// C++ has no chill if accessing out of bounds. Pretty lame for 2024 imo
// Like not guarding this crashes the program.
// Seems weird compiler can't catch this shenan, but I know nothing
if (Phases.Num() == 0)
{
UE_LOG(LogTemp, Warning, TEXT("Last Phase In Phase List Completed"));
return;
}
// Activate next phase
if ( Phases.Num() > 0 && ! Phases[0]->IsActivated())
{
Phases[0]->Activate();
}
}
Right now its just a simple array that we put the Phases in. We activate the first Phase in the list, then move on to the next one when the Phase is complete. Eventually this will likely be more complicated, potentially even requiring parallel Phases to be run, but for now this is sufficient.
Somehow It Works!
The amazing thing is that this scheme actually works. The PhaseManager allows for the ordered execution of gameplay events, and the use of interfaces allows me to contain the unique logic for each Phase:
This was fairly complex for me to implement. First, I had never done something like this in C++, nor Unreal.
Additionally, the use of interfaces like this is something that I usually get confused. In previous game prototypes my understanding of interfaces were limited, and therefore would end up making things more complicated.
I think having interfaces can be really useful if you have very clear scope for those interfaces.
This was a knockout success, and probably one of the more complex software designs I’ve implemented. Not the hardest- that’s probably reserved for So Your Technical Debt Has Gone To Collections, but still, this was highly gratifying to build out.
In the next episode, I look at rebuilding the drum machine, and with it start building out the concepts of playable Instruments.: