Lyra FPS Prototype
Team Deathmatch prototype built on Lyra-inspired architecture in Unreal Engine C++ - experience-driven pawn setup, GAS shoot/death abilities, replicated health with delegate-driven HUD updates, and a full death/respawn/scoring loop.
TL;DR
A custom Lyra-inspired Team Deathmatch prototype built in Unreal Engine C++. The project is not a modification of the Lyra sample - it reimplements Lyra's architectural patterns from scratch using the same design philosophy:
Experienceasset selected in GameMode and loaded asynchronously through a replicated managerPawnDataandAbilitySetassets applied through aPawnExtensioninit-state pipeline- Abilities granted server-side with input tags; blocked by gameplay tags during death/invincibility
- Health changes propagate to HUD through delegate binding at
DataInitializedstate - Death, respawn, team scoring, and spawn invincibility enforced through GAS + GameMode orchestration
The engineering focus is initialization correctness and replication timing across the full match lifecycle.
Role & Scope
- Role: Solo gameplay systems engineer
- Duration: 3 weeks
- Engine/Stack: Unreal Engine C++, GAS, ModularGameplay, team subsystem, UMG
Problem
Lyra-style modular systems are powerful but timing-sensitive. The hard part was not implementing a line trace - it was making every system become ready in the correct order across server and client:
- Experience ID replication and async load completion
- PawnData application and ability grants (server authority)
InitAbilityActorInfoon every respawn- Health/UI binding only after
DataInitializedstate
Without strict gating these produce race conditions: missing abilities on early spawn windows, stale HUD values, and input lock lingering through death transitions.
Architecture
Startup flow - game boot to first shot
GameMode::InitGameparses?ExperienceId=(fallback:UZ_GameMode_TeamDeathmatch)ExperienceManagerComponentreplicatesExperienceId; both server and client async-load the experience asset- Pawn is spawned with
PawnDatafrom the experience definition UUZ_PawnExtensionComponentadvances throughSpawned → DataAvailable → DataInitialized → GameplayReady- On
DataInitialized(server): abilities granted, input tags injected into spec source tags, handles stored UUZ_HealthComponentbinds to ASC health delegate atDataInitialized; broadcasts current value immediately- Player presses fire; GAS validates tags, cooldown, and invincibility; line trace executes server-side
- Damage GE applied; health zero triggers death ability; GameMode increments score and starts respawn timer
Implementation Breakdown
UUZ_ExperienceManagerComponent - async load and replication
- Server parses
?ExperienceId=inGameMode::InitGameand callsSetCurrentExperienceId - Component replicates
ExperienceId; clients begin async load fromOnRep_ExperienceId - Load state machine (
Unloaded → Loading → Loaded) prevents duplicate load attempts CallOrRegister_OnExperienceLoaded()allows any system to safely defer its setup behind experience readiness
UUZ_PawnExtensionComponent - init-state pipeline
The core Lyra-style initialization gate. Implements IGameFrameworkInitStateInterface with a four-state chain:
| State | Gate condition |
|---|---|
Spawned | Actor exists |
DataAvailable | PawnData != nullptr |
DataInitialized | Valid ASC + avatar actor match |
GameplayReady | Abilities granted (server); replicated ability state (client) |
Ability grant flow (server, at DataInitialized):
- Reads
PawnData->AbilitySetIds - Sync-loads each
UUZ_AbilitySet - Grants via
ASC->GiveAbility(FGameplayAbilitySpec) - Injects input tags into
DynamicSpecSourceTags - Stores
FUZ_AbilitySet_GrantedHandlesfor teardown cleanup
AUZ_PlayerState - ASC and attribute host
PlayerState owns the ASC (Mixed replication mode) and UUZ_HealthSet. Placing ASC here improves continuity across pawn death/respawn and keeps attribute ownership stable. InitializeAbilitySystem(APawn*) calls InitAbilityActorInfo(this, InPawn), resets health to max, and forwards ASC into the PawnExtension pipeline.
UUZ_GameplayAbility_Shoot
- Activated by
TAG_Input_Shoot; blocked byTAG_Gameplay_Dead - Server-only execution policy
- Cooldown GE (
UZ_GE_ShootCooldown) gates fire rate - Server line trace against WorldStatic/WorldDynamic/Pawn channels
- Friendly-fire validated through
UUZ_TeamSubsystem::CanCauseDamage - Invincibility tag check before applying damage
- Applies
UZ_GE_Damageto target ASC - Ends immediately (single-shot model)
UUZ_GameplayAbility_Death
- Activated when health reaches zero
- Applies owned tags while active:
TAG_Gameplay_Dead,TAG_Gameplay_MovementStopped - Cancels shoot ability; disables move/look input
- Resolves killer actor from
UUZ_HealthComponent::LastKillerActor - Notifies GameMode for score increment + respawn scheduling
- Intentionally stays active until GameMode cancels it on respawn to hold the dead state
UUZ_HealthComponent - binding timing
- Subscribes to PawnExtension init-state changes
- Binds to ASC health delegate only at
DataInitialized- never earlier - Stores delegate handle for clean unbind on
EndPlay - Broadcasts initial health value immediately after binding so the HUD is never empty on first frame
- On server: reacts to
OnOutOfHealthby activating death ability
AUZ_PlayerController - HUD connection
- Rebinds on every
SetPawn(covers respawn) - Binds to
HealthComponent::OnHealthChanged - Fires
OnHealthValueChangedimmediately if attributes are already bound at bind time - Covers the spawn/respawn timing variation where delegate registration order differs by net role
Team subsystem and scoring
UUZ_TeamCreationComponentspawns team info actors at match startAUZ_PlayerStatereplicates team ID with change delegatesUUZ_TeamSubsystem::CanCauseDamageprevents friendly fire at the shoot ability level- Death ability reports killer to GameMode; GameMode increments killer team score
- Score-limit check triggers match-end; dead pawn destroyed; respawn timer scheduled;
CancelAbilitiesByTag(Dead)thenRestartPlayer
Code Snippet
void UUZ_GameplayAbility_Shoot::ActivateAbility(
const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo,
const FGameplayAbilityActivationInfo ActivationInfo,
const FGameplayEventData* TriggerEventData)
{
// CommitAbility validates tags, cooldown, and cost; returns false if any fail.
if (!CommitAbility(Handle, ActorInfo, ActivationInfo))
{
EndAbility(Handle, ActorInfo, ActivationInfo, true, true);
return;
}
// Line trace - server only (execution policy: ServerOnly).
FHitResult Hit;
const bool bHit = PerformLineTrace(ActorInfo, Hit);
if (bHit && Hit.GetActor())
{
// Friendly-fire check through team subsystem.
UUZ_TeamSubsystem* Teams = GetWorld()->GetSubsystem<UUZ_TeamSubsystem>();
if (Teams && Teams->CanCauseDamage(ActorInfo->OwnerActor.Get(), Hit.GetActor()))
{
// Invincibility check before applying damage.
UAbilitySystemComponent* TargetASC =
UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(Hit.GetActor());
if (TargetASC && !TargetASC->HasMatchingGameplayTag(TAG_Gameplay_Invincible))
{
FGameplayEffectSpecHandle DamageSpec =
MakeOutgoingGameplayEffectSpec(DamageEffectClass, GetAbilityLevel());
ApplyGameplayEffectSpecToTarget(Handle, ActorInfo, ActivationInfo,
DamageSpec, FGameplayAbilityTargetDataHandle());
}
}
}
EndAbility(Handle, ActorInfo, ActivationInfo, true, false);
}Video Demo
Network & Replication
PlayerStatenet update frequency raised to 100 Hz - reduces sluggish remote health updates on default tick ratesUUZ_HealthSet::Healthreplicated withCOND_None(all observers need it for damage feedback)- Ability grants are server-only; no client race on grant timing
- Shoot and death abilities run server-side - no client divergence in damage or state transitions
- GameInstance explicitly registers init-state tags in progression order for at-or-past comparisons
Challenges & Solutions
Challenge 1: HUD desync after spawn/respawn
Root cause: HUD binding could execute before the health delegate was registered, or the first frame value was never pushed.
Fix: Bind at DataInitialized init-state + immediately broadcast current health value on bind + controller re-binds on SetPawn with an immediate callback if already bound. All three are required to handle the timing variation across net roles.
Challenge 2: Abilities absent on early spawn window
Root cause: Ability grant pipeline and pawn readiness converged at different times depending on network mode. Input presses during the gap found no ability specs.
Fix: Gate readiness using the PawnExtension four-state chain with server authority checks. Only mark GameplayReady after grants complete on server; clients advance via replicated ability state.
Challenge 3: Dead-state tags lingering after respawn
Root cause: Death ability applied TAG_Gameplay_Dead and input locks. Without explicit cleanup, these persisted through pawn destruction and restart.
Fix: Keep death ability active by design through the respawn transition. GameMode cancels it by tag (CancelAbilitiesByTag) before calling RestartPlayer, ensuring clean transition from dead to alive state.
Scope - Implemented vs Planned
Implemented:
- Experience loading pipeline with replicated ID and async asset load
- PawnData-driven ability set grant pipeline with handle cleanup
- Shoot ability: cooldown GE, line trace, friendly-fire + invincibility checks, damage GE
- Health attribute replication and delegate-driven HUD update path
- Death ability: state tags, input lock, killer resolution, score notification
- Respawn orchestration, spawn invincibility, team scoring, match-end check
Partial / planned:
UUZ_GameplayAbility_Reload: class exists, behavior not implemented- Predicted fire/reload for low-latency feel
- Additional inventory/weapon modularization
What I'd Do Next
- Implement reload ability behavior and connect ammo attribute to cooldown gating
- Add client-side prediction for fire to reduce perceived latency on higher RTT sessions
- Profile GE application throughput at sustained automatic fire rates under 16ms frame budget
- Integrate inventory modularization for multi-weapon support
- Extend HUD to show weapon state, reload phase, and team score progression