Body and Soul - Replicated Co-op Health Prototype
Asymmetric co-op prototype in Unreal Engine 5 where players spawn as Body/Soul roles and share a replicated team-health pool. Built with C++, GAS integration, and server-authoritative multiplayer replication.
TL;DR
Multiplayer co-op prototype in Unreal Engine 5 where players alternately spawn as ABodyCharacter or ASoulCharacter and draw from a single replicated team-health value. GAS is integrated for ability and attribute foundations - using the PlayerState-owned ASC pattern for players and a pawn-owned ASC for enemies. Shared match health is managed by ABnSGameState for deterministic server authority, keeping GAS evolution risk separate from co-op fail-state reliability.
Role & Scope
- Role: Solo gameplay systems programmer
- Duration: 2 weeks (prototype sprint)
- Responsibilities: Spawn-role architecture, GAS initialization patterns, shared-health replication, prototype UI feedback, multiplayer PIE validation
Problem
The co-op design goal was: two players, one team life pool.
The challenge was implementing this quickly while still laying groundwork for deeper GAS-driven expansion. Rather than immediately coupling both players to a shared ASC - which introduces complex multi-avatar ownership edge cases - the prototype uses a deliberate intermediate architecture:
- GAS per player for character capability scaffolding (abilities, attributes, effects).
- GameState as a single source of truth for the shared team health value.
This separates ability evolution risk from core co-op fail-state reliability.
Codebase Structure
Source/BodyAndSoul/
├── BodyAndSoulGameMode.* # alternates spawn classes (Body / Soul)
├── BnSGameState.* # replicated shared-health authority
├── BnSPlayerState.* # player-owned ASC + AttributeSet
├── BnSAttributeSet.* # GAS attributes (Health, MaxHealth) with replication
├── BodyAndSoulCharacter.* # base character: movement, input, GAS helpers, damage hook
├── PlayerCharacter.* # possession + OnRep init of ASC from PlayerState
├── BodyCharacter.* # Body role variant
├── SoulCharacter.* # Soul role variant
├── EnemyCharacter.* # enemy with pawn-local ASC + attribute set
├── HealthWidget.* # shared-health UI
└── ReduceHealthAbility.* # placeholder gameplay ability
Architecture
System Design
Spawn / Role Assignment
ABodyAndSoulGameMode stores FirstPawn and SecondPawn class references and alternates which is returned by GetDefaultPawnClassForController_Implementation. Each joining player is assigned the next role in sequence - Body, Soul, Body, Soul - without a lobby or role-picker.
This keeps role assignment server-authoritative and makes asymmetric co-op testing fast to set up.
GAS Ownership - Two Patterns in One Project
Player path (PlayerState-owned ASC):
ABnSPlayerStateownsUBnSAbilitySystemComponentandUBnSAttributeSetAPlayerCharacterinitializes GAS pointers in bothPossessedBy(server) andOnRep_PlayerState(client)ABodyAndSoulCharacterexposes shared helpers:GiveDefaultAbilities()andInitDefaultAttributes()
Enemy path (pawn-owned ASC):
AEnemyCharactercreates ASC and AttributeSet directly on the character in the constructor- Initializes actor info in
BeginPlay
Demonstrating both patterns in one project makes the ownership tradeoffs explicit: PlayerState-owned survives possession changes and replication events; pawn-owned is simpler and sufficient for AI-controlled actors.
Shared Health & Damage Flow
Team health lives in ABnSGameState as fPlayerHealth:
ABodyAndSoulCharacterbinds overlap callbacks on spawn- On overlap with an actor tagged
Damage, the character callsApplyDamage(1.f)server-side - Server routes to
ABnSGameState::ApplyDamageToSharedHealth fPlayerHealthis clamped and replicated viaOnRep_SharedHealthUHealthWidgetreads the updated value and refreshes the progress bar and status text
Centralizing this in GameState gives a single server-authoritative location that any client can read without querying individual player state.
UI Feedback
UHealthWidget reads ABnSGameState::fPlayerHealth and updates:
- Progress bar fill percentage
- Status text:
"You are damaged!"/"You are dead!"
Current implementation uses a button-triggered update path for prototype validation. The step to event-driven UI is a delegate binding once the core replication is proven stable.
Code Snippet
// GAS must be initialized on both server and client paths.
// PossessedBy fires on the server when the controller takes possession.
// OnRep_PlayerState fires on the client when PlayerState replicates.
// Both paths are required - skipping either leaves ASC uninitialized on one side.
void APlayerCharacter::PossessedBy(AController* NewController)
{
Super::PossessedBy(NewController);
ABnSPlayerState* PS = GetPlayerState<ABnSPlayerState>();
if (!PS) return;
AbilitySystemComponent = PS->GetAbilitySystemComponent();
AbilitySystemComponent->InitAbilityActorInfo(PS, this);
AttributeSet = PS->GetAttributeSet();
// Grant starting abilities and initialize attribute defaults (server only).
GiveDefaultAbilities();
InitDefaultAttributes();
}
void APlayerCharacter::OnRep_PlayerState()
{
Super::OnRep_PlayerState();
// Client-side init - PlayerState may not exist until this rep fires.
ABnSPlayerState* PS = GetPlayerState<ABnSPlayerState>();
if (!PS) return;
AbilitySystemComponent = PS->GetAbilitySystemComponent();
AbilitySystemComponent->InitAbilityActorInfo(PS, this);
AttributeSet = PS->GetAttributeSet();
}Video Demo
Challenges & Solutions
Challenge 1: GAS health vs. shared match health
Issue: The prototype needed both GAS attribute infrastructure and a co-op team health pool. Routing all damage through GAS Gameplay Effects to a shared attribute on a manager actor introduces multi-avatar ASC ownership complexity that is risky to get right in a short sprint.
Solution: Keep GAS attributes per player and per enemy for ability/effect scaffolding. Route the shared fail-state health through ABnSGameState where server authority is trivial to enforce and replication is straightforward. The two concerns evolve independently.
Challenge 2: ASC initialization timing in networked play
Issue: On clients, ABnSPlayerState is not guaranteed to exist at the moment BeginPlay or PossessedBy fires. Initializing ASC pointers only in PossessedBy left clients with null references.
Solution: Initialize in both PossessedBy (server) and OnRep_PlayerState (client) inside APlayerCharacter. The server path handles authority setup and ability granting; the client path ensures GAS pointers are valid as soon as the PlayerState replicates.
Challenge 3: Keeping prototype UI testable without event-driven bindings
Issue: Wiring full delegate-based UI updates early adds complexity before the underlying replication is confirmed stable.
Solution: Use a button-triggered update and direct GameState reads during prototyping. This gives immediate PIE multiplayer visibility into shared-health state changes and makes the underlying logic easy to step through before adding event-driven polish.
Replication Notes
ABnSGameState::fPlayerHealth- declared withReplicatedUsing=OnRep_SharedHealth; registered inGetLifetimeReplicatedPropsUBnSAttributeSet-HealthandMaxHealthreplicated withUPROPERTY(ReplicatedUsing=...)and rep-notify macros- Shared health mutation is server-gated (
HasAuthority()) inABnSGameState
Current Gaps
UReduceHealthAbilityexists as a placeholder - the ability body is not yet implementedUSharedHealthUObject exists in the source but is not the active authority path;ABnSGameStateholds that role- UI update is button-triggered, not event-driven
What I Would Change Next
- Move shared health fully into GAS via a dedicated authority actor with its own ASC, replacing the float in GameState
- Replace the button-triggered UI path with delegate/event-driven binding against the replicated value
- Add explicit death-state replication and synchronized animation triggers on both characters
- Add automated multiplayer functional tests covering damage application and death parity across clients
- Add reconnect/disconnect handling for shared-state ownership transitions