Mukund Pareek / mukund pareek
  • Projects
  • Blog
  • Notes
  • Resume

MP Mukund Pareek

Unreal Engine C++ Developer

  • Projects
  • Blog
  • Notes
  • Resume

© 2026 Mukund Pareek. Built with Next.js and Tailwind CSS.

Blog
unreal
gas
multiplayer
replication
architecture

March 10, 2026

Co-op Health Architecture: Body and Soul

Body and Soul is a co-op prototype where two players - one spawning as Body, one as Soul - share a single team health pool. Here's the architecture behind that, the key decisions, and why each choice was made the way it was.

The Central Design Decision

The obvious GAS approach for shared health would be: put both players' health into a single UAttributeSet on a shared UAbilitySystemComponent. Both characters read from it, damage effects apply to it, and replication handles the rest.

That's not what Body and Soul does.

Instead:

  • Each player has their own ASC, owned by their ABnSPlayerState
  • GAS is used for per-player character attributes - individual health, abilities
  • The shared team health lives on ABnSGameState as a replicated float

The reason is deliberate: coupling shared match state to GAS early introduces multi-avatar ASC ownership complexity that's risky to get right quickly. ABnSGameState gives a single server-authoritative location that any client can read without querying individual player state.

This separates ability evolution risk from co-op fail-state reliability. Each can evolve independently.

The PlayerState-Owned ASC Pattern

Why does each player's ASC live on ABnSPlayerState rather than on the character pawn?

Two reasons:

  1. Persistence across respawns - PlayerState is not destroyed when the pawn dies. If the ASC lived on the pawn, all abilities and attribute state would be torn down and rebuilt on every death.
  2. Replication stability - with Mixed replication mode, PlayerState provides a stable replication owner that survives possession changes.

The Dual Init Path

The tricky part of PlayerState-owned ASC is initialization timing. The ASC must have its InitAbilityActorInfo(OwnerActor, AvatarActor) called before abilities can fire. But on different machines, the required objects arrive at different times:

// Server path - fires when the controller takes possession.
// Both PlayerState and Pawn exist here on authority.
void APlayerCharacter::PossessedBy(AController* NewController)
{
    Super::PossessedBy(NewController);

    ABnSPlayerState* PS = GetPlayerState<ABnSPlayerState>();
    if (!PS) return;

    AbilitySystemComponent = PS->GetAbilitySystemComponent();
    AbilitySystemComponent->InitAbilityActorInfo(PS, this);

    // Grant abilities and initialize attributes - server only.
    GiveDefaultAbilities();
    InitDefaultAttributes();
}

// Client path - fires when PlayerState replicates to the client.
// Pawn already exists, but PlayerState wasn't available at BeginPlay.
void APlayerCharacter::OnRep_PlayerState()
{
    Super::OnRep_PlayerState();

    ABnSPlayerState* PS = GetPlayerState<ABnSPlayerState>();
    if (!PS) return;

    AbilitySystemComponent = PS->GetAbilitySystemComponent();
    AbilitySystemComponent->InitAbilityActorInfo(PS, this);
}

Both paths are required. Skip PossessedBy and the server never initializes. Skip OnRep_PlayerState and clients have a null ASC reference until the next possession event. This is the most common initialization bug in PlayerState-owned GAS setups.

The Shared Health Flow

The team health path is kept intentionally simple:

ABodyAndSoulCharacter overlap callback
  → HasAuthority() check
  → ABnSGameState::ApplyDamageToSharedHealth(1.f)
  → fPlayerHealth clamped, OnRep_SharedHealth fires
  → UHealthWidget reads fPlayerHealth, updates progress bar and text

Damage application is server-gated. HasAuthority() prevents clients from directly mutating the shared pool. The widget reacts to the OnRep - it doesn't poll.

GAS Attribute Replication for Per-Player State

Individual player attributes (Health, MaxHealth on UBnSAttributeSet) replicate normally:

void UBnSAttributeSet::GetLifetimeReplicatedProps(
    TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    DOREPLIFETIME_CONDITION_NOTIFY(UBnSAttributeSet, Health,
        COND_None, REPNOTIFY_Always);
    DOREPLIFETIME_CONDITION_NOTIFY(UBnSAttributeSet, MaxHealth,
        COND_None, REPNOTIFY_Always);
}

COND_None because both players benefit from knowing each other's individual health state. REPNOTIFY_Always avoids silent no-ops when the same damage value is applied twice in one frame - the rep notify fires regardless.

Separate ASCs per player on PlayerState; shared match health routed through GameState.

What I Learned

  • Separate concerns early. Shared match state and per-player ability state are different problems. Routing shared fail-state through GameState keeps GAS clean and avoids premature multi-avatar ASC coupling.
  • The dual init path is mandatory. PossessedBy alone works in PIE single-player but fails on clients in a networked session. OnRep_PlayerState alone works on clients but leaves the server uninitialized. You need both.
  • REPNOTIFY_Always for gameplay-critical attributes. Silent no-ops on repeated values cause hard-to-diagnose UI desync. If your widget relies on an OnRep, use REPNOTIFY_Always.
  • Server-gate all shared state mutations. Even if clients can read fPlayerHealth freely, only the server should write to it. HasAuthority() on every damage call keeps the authority model unambiguous.