Let's talk

ASC on the PlayerState, done right

Moving the Ability System Component to the PlayerState keeps abilities and attributes alive through death and respawn. It also adds three ways to break multiplayer. This is the setup that avoids all three.

Why the PlayerState

The Ability System Component (ASC) holds granted abilities, active effects, attributes and tags. If it lives on the character, all of that dies with the character. On respawn you re-grant abilities, re-apply passive effects and lose any cooldowns that should have survived.

The PlayerState lives as long as the player is connected. Put the ASC there and the pawn becomes a body the ASC drives. Lyra does this, and it is the standard choice for player-controlled characters in a multiplayer game.

Keep the ASC on the pawn for AI. AI pawns usually have no PlayerState, and they do not respawn as the same "player".

Owner and avatar

GAS separates two actors, and most PlayerState bugs come from mixing them up:

  • Owner actor: the actor that owns the ASC component. Here, the PlayerState.
  • Avatar actor: the actor in the world. It has a location, a mesh and an AnimInstance. Here, the character.

Montages, root motion, target data and gameplay cues need the avatar. Replication and ownership come from the owner. InitAbilityActorInfo(Owner, Avatar) connects them, and it must run on the server and on the owning client.

Step 1: the PlayerState

C++RSExamplePlayerState.cpp
ARSExamplePlayerState::ARSExamplePlayerState()
{
    AbilitySystem = CreateDefaultSubobject<UAbilitySystemComponent>(TEXT("AbilitySystem"));
    AbilitySystem->SetIsReplicated(true);
    AbilitySystem->SetReplicationMode(EGameplayEffectReplicationMode::Mixed);

    Attributes = CreateDefaultSubobject<URSExampleAttributeSet>(TEXT("Attributes"));

    SetNetUpdateFrequency(100.f);
}

Three lines matter here.

Mixed replication mode. The owning client receives full gameplay effect data, which it needs to predict cost and cooldowns and to show cooldown timers. Other clients receive only tags and cues. Full sends everything to everyone and costs bandwidth. Minimal breaks prediction for the owner, because the owner never sees the effects it predicted.

Attribute set as a subobject. An attribute set created as a default subobject of the ASC's owner is found and registered by the ASC automatically.

Net update frequency. A PlayerState replicates rarely by default because it normally holds a name and a score. Now it carries your abilities and attributes, so a low rate shows up as input lag on ability confirmation. Lyra sets 100. Profile before you lower it. On engine versions before 5.5, set the NetUpdateFrequency property directly.

Step 2: init on both sides

The character implements IAbilitySystemInterface and returns the PlayerState's ASC. Then it calls InitAbilityActorInfo at the two moments the server and the owning client each learn about the PlayerState:

C++RSExampleCharacter.cpp
void ARSExampleCharacter::PossessedBy(AController* NewController)
{
    Super::PossessedBy(NewController);   // server

    InitAbilityActorInfo();
    GrantStartupAbilities();
}

void ARSExampleCharacter::OnRep_PlayerState()
{
    Super::OnRep_PlayerState();          // owning client and simulated proxies

    InitAbilityActorInfo();
}

void ARSExampleCharacter::InitAbilityActorInfo()
{
    APlayerState* PS = GetPlayerState();
    UAbilitySystemComponent* ASC = PS ? URSAbilitySystemLibrary::FindAbilitySystemComponent(PS) : nullptr;
    if (!ASC)
    {
        return;
    }

    ASC->InitAbilityActorInfo(PS, this);
}

PossessedBy only runs on the server. OnRep_PlayerState runs on clients when the PlayerState pointer replicates. Skip the client call and everything looks fine in a listen-server PIE session as the host, then predicted abilities fail on every real client, because their ASC has no avatar.

FindAbilitySystemComponent comes from the free GAS Helpers library. It checks the actor, its PlayerState, its controller's PlayerState and its owner, so the same call works from a widget, a projectile or an AI task.

Step 3: grant abilities once

C++RSExampleCharacter.cpp
void ARSExampleCharacter::GrantStartupAbilities()
{
    UAbilitySystemComponent* ASC = GetAbilitySystemComponent();
    if (!ASC || !HasAuthority())
    {
        return;
    }

    for (const TSubclassOf<UGameplayAbility>& AbilityClass : StartupAbilities)
    {
        if (AbilityClass && !ASC->FindAbilitySpecFromClass(AbilityClass))
        {
            ASC->GiveAbility(FGameplayAbilitySpec(AbilityClass, 1, INDEX_NONE, this));
        }
    }
}

The ASC survives respawn, but PossessedBy runs for every new pawn. Without the FindAbilitySpecFromClass check, the second life has two specs of each ability. Activation by tag then activates both, and you get the classic "ability fires twice after the first death" bug. The next tutorial covers that one in detail.

GiveAbility must run on the server. Called on a client it does nothing useful.

Check that it works

  1. Run PIE with 2 players and Net Mode: Play as Client (a dedicated server, so neither player is the host).
  2. On each client, open the console and run showdebug abilitysystem. You should see your granted abilities and attributes.
  3. Kill and respawn a character, then run it again. The ability list should be the same length as before.
  4. Call DumpAbilitySystemState from the helpers on the server and on the client and compare the two logs. Owner should be the PlayerState, avatar the character, on both.

Common mistakes

  • Calling InitAbilityActorInfo only in BeginPlay. On clients, the PlayerState may not have replicated yet.
  • Using Minimal replication mode for player ASCs. The owner cannot see its own predicted effects.
  • Giving the attribute set to the character while the ASC lives on the PlayerState. The ASC never finds it.
  • Forgetting the PlayerState net update frequency, then blaming "GAS latency".

Get the code

The full PlayerState, Character and AttributeSet examples are in GAS Helpers v0.1.0, free under the MIT license. Browse RSExampleCharacter.cpp or download the ZIP.

If your project already has the ASC on the PlayerState and abilities still misbehave on real clients, the cause is usually spread across several classes. That is what the Diagnostic is for.

Still desyncing in packaged builds?

Free code fixes one symptom. A Diagnostic finds the cause across your project in two days.