Ramblings of an aging IT geek
← Ramblings of an aging IT geek
gamedev

what actually owns what in unreal's gameplay framework

A working map of Unreal's GameMode, GameState, PlayerController, Pawn and PlayerState, who owns whom, what replicates, and where to put your code so it survives the network.

A game engine viewport with a debug overlay

Unreal's gameplay framework is not hard. It is just badly explained, mostly by tutorials that put the wrong code in the wrong class and then wonder why it stops working the moment a second player connects. I spent an embarrassing number of evenings moving logic from one class to another before the shape of it finally clicked, so this is the post I wish I'd had: who owns whom, what survives a respawn, and where a given bit of code actually belongs.

The framework is a set of classes with very particular responsibilities. The trouble is the names sound interchangeable. GameMode, GameState, PlayerController, PlayerState, Pawn, Character. They are not interchangeable, and the differences only really announce themselves once you go multiplayer. So let's go multiplayer from the start, because single-player is just multiplayer with one client and a listen server, and pretending otherwise is how you build something that has to be torn apart later.

the server-only classes

AGameModeBase (and AGameMode if you want the match-state machinery) exists on the server and nowhere else. This is the single most important fact in the whole framework and the one most tutorials skip. If you put a variable on the GameMode and try to read it from a client, you will get garbage, because on the client the GameMode pointer is null. It was never there. The server owns the rules: who can join, where they spawn, what happens when someone dies, when the match ends. Rules that clients must not be allowed to decide for themselves.

So the GameMode is where I put spawn logic, scoring rules, win conditions, the authority on "is this move legal". None of it replicates, because none of it needs to. The results of those decisions replicate, via the classes below, but the decisions themselves stay server-side where a modified client can't reach them.

// Server-only. Clients never see this object.
void AMyGameMode::HandlePlayerDeath(AController* Killer, AController* Victim)
{
    // Authoritative scoring lives here.
    if (AMyPlayerState* PS = Killer->GetPlayerState<AMyPlayerState>())
    {
        PS->Score += 1;            // replicated property, see below
    }
    RestartPlayer(Victim);         // framework spawns a fresh pawn
}

GameState is the GameMode's public face. It lives on the server and is replicated to every client, so it is where match-wide state that everyone needs to see belongs: the current score limit, the time remaining, the list of connected players. Rule of thumb that has never once let me down: if the GameMode decides it and the clients need to know it, the GameState carries it across.

A close-up of replicated property declarations in C++

the per-player classes

Now the per-player half, which is where the ownership gets genuinely interesting. Each player has a PlayerController, a Pawn, and a PlayerState, and they are emphatically not the same lifetime.

The Pawn (or Character, which is just a Pawn with a movement component and a capsule bolted on) is the physical thing in the world. It gets destroyed when you die and a new one is spawned when you respawn. So anything you store on the Pawn dies with the Pawn. Your current health belongs here, because health is a property of this body and a fresh body starts fresh. Your kill count does not belong here, because you'd lose it every time you died, which is a fun bug to chase.

The PlayerState is the player's persistent record. It survives respawns. It survives the Pawn being destroyed. It is replicated to all clients, which is exactly why score lives there: everyone needs to see everyone's score, and it has to outlive any single body. The framework even hands it to you across seamless travel between maps. Treat PlayerState as "the things true about this player regardless of whether they currently have a body".

The PlayerController is the brain, and it is the one with the awkward ownership story. On the owning client it is your input, your UI, your camera. On the server there is a copy of every player's controller, because the server needs to validate what each client is trying to do. But a client only ever has its own controller; it cannot see anyone else's. So the PlayerController is the right home for anything that is "this player's, and private to them": their HUD, their input bindings, RPCs they want to fire at the server.

That last point is the one that costs people days. If you want a client to ask the server to do something, the RPC almost always wants to live on the PlayerController or the Pawn, because those are the objects the client actually owns. Ownership is what gives you the right to call a server RPC. Try it from an object the client doesn't own and the call is silently dropped, no error, no warning, just nothing.

a checklist that has survived contact

When I'm deciding where a piece of state goes, I ask three questions in order:

  • Does it survive a respawn? If no, Pawn. If yes, keep going.
  • Does every client need to see it? If yes, PlayerState (per-player) or GameState (match-wide). If no, PlayerController.
  • Is it a rule the server must enforce alone? Then the decision lives on GameMode, even if the result replicates out via GameState or PlayerState.

That's genuinely most of it. The framework isn't fighting you, it's encoding a network topology, and once you see the classes as positions in that topology rather than a bag of base classes to inherit from, the "where does this go" question mostly answers itself. Health on the Pawn, score on the PlayerState, rules on the GameMode, match state on the GameState, input and UI on the PlayerController. Put the code where the framework expects it and replication, respawning and authority all just work. Put it in the wrong class and you'll spend a week discovering, one null pointer at a time, exactly why it was the wrong class.