I have been poking at Unreal Engine 4 in the evenings for a couple of months now, and for most of that time I have been doing it badly. Not badly in the sense of the thing not working, it mostly worked, but badly in the sense that I could not have told you why it worked, and when it stopped working I had no map in my head to find the problem. The engine has a gameplay framework, a set of base classes that are supposed to give you a place to put each kind of logic. I had read the documentation. I had nodded along. And then I had put all my code in the Pawn anyway, because that was the thing I could see moving on screen.
This post is me writing down what finally clicked, mostly so I stop making the same mistake. If you are an Unreal veteran none of this will be news. If you are coming from gameplay-on-a-single-object engines, or from web and backend work like I am, it might save you the fortnight I lost.
the question that actually matters
The framework makes more sense once you stop asking "what class do I subclass" and start asking "who needs to know this, and for how long". Every base class in the gameplay framework answers a question about ownership and lifetime. Get those two right and the class almost picks itself.
- How long does this state live? A whole match, or one life of one character?
- Who is allowed to see it? Everyone, only the server, only one player?
- Is it a thing in the world with a transform, or just bookkeeping?
That last one is the trap I kept falling into. A Pawn is a thing in the world. It has a position, it gets possessed, it gets destroyed when the character dies. If you put your score on the Pawn, your score dies with the character. Which is exactly what happened to me, and I spent an embarrassing evening convinced I had a serialisation bug.
the cast of characters
Here is the rough division of labour as I now understand it. I am writing the C++ class names; the Blueprint equivalents are the same names without the leading A.
AGameModeBase (and AGameMode for the multiplayer flavour) is the rules of the game. It only exists on the server. It decides who spawns where, when the match starts, what happens when someone scores. If you find yourself writing if (HasAuthority()) everywhere, some of that logic probably wants to live here, because the GameMode is already server-only and you can stop apologising for it. Clients do not have a GameMode at all. That confused me for a day until I read it twice.
AGameStateBase is the replicated, everyone-can-see-it version of match state. Current score, time remaining, list of connected players. The GameMode computes it, the GameState carries it to the clients. The split feels redundant on a single-player prototype where you are both server and client, but it is the thing that makes the same code work in multiplayer later without a rewrite.
APlayerController is the player as an intent, not as a body. It is the thing that turns "the human pressed W" into "move forward". One per player, it survives respawns, and crucially it exists on both the owning client and the server. This is where input handling belongs. I had been doing input on the Pawn, which works right up until the Pawn is destroyed and the player is sat there dead pressing keys into the void.
APawn / ACharacter is the body. The thing with a mesh and a capsule and a position. It gets possessed by a Controller. The separation between Controller and Pawn is the bit of the framework I most underrated: it means an AI Controller and a Player Controller can possess the same Pawn class, and it means the player can leave one body and take another without losing who they are. A Character is just a Pawn with a movement component and some humanoid assumptions baked in.
APlayerState is the per-player bookkeeping that needs to outlive the body and be visible to everyone. Score, name, team. This is where my score should have been all along. It is replicated, it survives respawns, every client can read every other player's PlayerState. The day I moved score from the Pawn to the PlayerState, three separate bugs I had been treating as unrelated all vanished at once, which is always a sign you have finally put something in the right place.
a small example
Here is the shape of it, trimmed right down. The Controller reads input and asks the Pawn to move. The GameMode owns the rule. The PlayerState holds the result.
// In the PlayerController: input is intent, lives here
void AMyPlayerController::SetupInputComponent()
{
Super::SetupInputComponent();
InputComponent->BindAxis("MoveForward", this, &AMyPlayerController::MoveForward);
}
void AMyPlayerController::MoveForward(float Value)
{
if (APawn* ControlledPawn = GetPawn())
{
ControlledPawn->AddMovementInput(ControlledPawn->GetActorForwardVector(), Value);
}
}
// In the GameMode (server only): the rule lives here
void AMyGameMode::PlayerScored(APlayerController* Scorer)
{
if (AMyPlayerState* PS = Scorer->GetPlayerState<AMyPlayerState>())
{
PS->AddScore(1); // replicated to all clients via PlayerState
}
}
No HasAuthority() checks scattered about, because the GameMode is server-only by construction and the input is on the Controller where it always exists. The code reads almost like a description of the responsibilities, which is the tell that the framework is doing its job.
what I would tell myself in November
Stop putting everything on the Pawn. The Pawn is a body and bodies die. Decide, for every piece of state, how long it should live and who should see it, and the class falls out of those two answers nearly every time. The framework is not bureaucracy for its own sake; it is a set of pre-made answers to questions you are going to face anyway, and the cost of ignoring it is paid later, in the dark, when your score resets on death and you cannot work out why.
I am still slow at this. But I have a map now, and that is the difference between debugging and guessing.