This case study covers the combat showcase, a fully networked multiplayer system running on varied latency, featuring three player classes,
nine unique abilities, and a suite of co-op systems including revives, vote kicking and chat, all built on
the Gameplay Ability System. It also covers character and mission selection, and seamless travel from
the lobby into the mission.
// architecture
AbilitySystemComponent creation location decision:
If a character is destroyed and later re-created, their attributes should persist.
For this reason, the AbilitySystemComponent is created on the player state,
which doesn't get destroyed along with the pawn.
Input tags for input for activating abilities: When abilities are given to the player,
they can also be assigned a gameplay tag used for input. Input tags are given to that
ability's dynamic spec source tags, and in input callbacks, the dynamic spec source tags
are checked and used to activate certain abilities.
GAS damage pipeline: A custom FComplyGameplayEffectContext is used to pass in information
that the default context doesn't have, such as if a bullet passed through a shield before hitting an enemy.
Execution Calculations handle calculating damage. The execution calculation also captures the armor and
armor penetration attributes which get taken into account when calculating the final damage.
Target Data pattern for hitscan abilities: For any local predicted abilities that involve hitscans,
when activating from the client, doing the trace only locally would not work, as local traces are not reliable,
and certain things would not happen on the server if performed client side (such as applying damage, as
Execution Calculations require their Execute_Implementation function to be called server-side).
This is why any traces on local predicted abilities go through Target Data. The target data callback takes
the hit result passed in by the client to perform any activities authoritatively.
Data asset refactor: Before this refactor, all variables for players, enemies, abilities, etc. that are
only set once were created directly in headers. Moving to data assets made it so variables can be more easily tracked,
and each class that needed it got its own data asset. The data driven approach also allows designers to more easily
contribute, due to not having to go into blueprints to find and modify variables. The whole change modified 180+ files,
but it helped for the rest of the project and was very worth it.
Character selection persistence: Character selections are stored in the game instance because seamless
travel player migration is unreliable for the listen server host, whose controller is recreated via PostLogin
rather than migrated via InitSeamlessTravelPlayer. The game instance persists across all travels.
Vote kick state on the game state: For the vote kick, the best place for its state to live is on the
game state. The game state has server authority but is also replicated, which is important because clients
need to read vote kick data and interact with it.
Widget architecture: Most widgets exist directly on the HUD. The HUD itself is created in the PlayerController,
and the HUD binds to each widget added.
Chat system routed through PlayerController: The chat is needed in the lobby too for
communication, so instead of it being created on the HUD, it was moved to the player controller and
created there and added to the viewport directly.
Ability system blueprint library for abstracting repeating code and exposing data to Blueprint that would
otherwise be inaccessible: The blueprint library serves two purposes.
Abstraction: There are many places where a line trace from the camera with specific parameters is needed.
Instead of duplicating code, a static function was abstracted into the blueprint library, which
significantly reduced repetition across files, amounting to a reduced line count of over 500.
Exposing data to Blueprint: An FGameplayAbilityTargetDataHandle was needed in Blueprint for
a cue execution. A BlueprintPure function in the blueprint library gets it and returns it directly.
Why I used native gameplay tags: Native gameplay tags ensure type safety (no string errors),
they are easier to refactor, and they are simpler to access in C++. To use them, you simply include
the header, and get them directly from their file.
Ability base classes: All abilities with shared functionality share code from a base class.
Everything from base classes is made generic to significantly reduce the amount of code in child classes.
Current code reduction from the generic base classes amounts to over 1000 lines.
RPC for authoritative actor spawning from abilities: On locally predicted abilities, if a client attempts
spawning an actor, it won't spawn authoritatively. For this reason, any of these kinds of abilities call
their Server RPC (stored in the custom ComplyAbilitySystemComponent). The abilities pass in
any necessary information, and the actors are spawned authoritatively and use the information passed in by abilities.
Reusable BTTask for activating enemy abilities: All enemies share the same BTTask that stores
the enemy's ability and its event tag (as enemy abilities are activated through events). This makes it so
each enemy doesn't get its own task when it doesn't need it.
Save game for settings: Settings are stored in a save game so that when a player closes a game and opens
it later, their settings persist.
// challenges
Execution calculation never being reached from the client: Execution calculations require activation
from the server. They are now only being called from server authoritative actors or in the case of abilities,
from target data callbacks.
Calling CancelAbility from a client will not work: The ability must be canceled from the server so the
cancellation replicates correctly to all connections. Inside an ability, CancelAbility handles this directly.
Outside of an ability, CancelAbilitySpec is used instead with a reference to the AbilitySystemComponent.
Actors spawned from abilities not spawning by clients on local predicted abilities: Actors cannot
be spawned authoritatively from clients. A server RPC is called from abilities instead which spawns them authoritatively.
Calling an ability's functions directly from input won't work when clients call them: A function on an ability
was being called from an input callback on the character, which only executes on the server. The fix was
to move that logic into the ability itself using the WaitInputConfirmCancel task, with the function set as
the OnConfirmed callback so it also executes for clients.
Gameplay cues executing twice on clients: A locally executed cue and its replicated counterpart were both
firing on clients after round trip time. The fix was to wrap the replicated cue execution in an
FScopedPredictionWindow. If the same prediction key was used when activating the ability as
when the replicated cue arrives, it gets dropped, ensuring the cue only plays once. This same pattern
applies to anything that executes both locally and then replicates from the server afterwards.
Attribute changes not predicted causing delays in UI: Ranged weapons were previously applying a gameplay
effect manually to reduce an attribute that functioned as that ability's cost. Since applying a gameplay
effect is not predicted, the new attribute value wasn't shown to the client until after round trip time.
Ability costs must be used for the purpose of UI, as costs are predicted and immediately update the UI with
the new attribute value.
Multicasts executing twice on the owning client and listen server host: A local spawn for responsiveness
and a multicast were both executing on the owning connection, causing double execution. The fix was to skip
the multicast on them if locally controlled, since those connections already handle execution locally.
CharacterMovementComponent (CMC) settings being overridden by nav mesh:
When setting the movement mode and velocity for the charge, the AI's behavior tree and nav mesh were still active
and overriding the CMC changes on the next tick, causing the enemy to ignore their new CMC
settings and continue following the nav mesh path. The fix was to stop the AI controller movement and pause the
brain component before modifying any CMC settings.
Target data callback not firing: The callback was being bound too late in the ability's execution,
and the client was sending target data before the binding happened, causing it to be missed entirely.
Callbacks in most cases should be bound at the earliest possible entry point in the ability.
Data assets being null on clients for server-spawned actors from abilities: Actors spawned authoritatively
from abilities via Server RPCs had their data assets passed in from the RPC itself. This caused the data asset
to be valid only on the server, and since BeginPlay of replicated actors is called on both server and clients,
the data asset was null on clients, causing crashes. The fix is to replicate the data asset variable.
The same pattern can be applied to other cases where a pointer must be valid on clients. An OnRep
can also be used if required, if the actor has a chance of spawning before the replicated variable arrives on clients.
Players being blocked by enemy widget components: Enemy health bar widget components were blocking
player movement by default due to the default UI collision channel having collision enabled. The fix was to disable
collision on the widget components.
Ammo, cooldowns and charges widgets not always initializing on clients: These widgets were accessing
their pawn in order to call functions on them that return their equipped ability. Since pawns initialize
a lot later than something like player states, they were often invalid on clients, causing the widget to
not initialize. Fixed by using a timer that will keep retrying until the pawn becomes valid, at which
point the timer stops running and the widget gets properly initialized every time.
Damage numbers widget not always initializing due to invalid viewport on clients: The entry damage number
needs the viewport size to properly position itself. On clients, the viewport can sometimes be invalid at the start,
causing the widget size to return 0, and the widget does not get initialized. Fixed by using a timer that will
keep retrying until viewport size is not 0, at which point the damage numbers widget can get initialized properly every time.
Low ammo indicator not working on clients: The widget was reading weapon data from the ability spec,
but ability specs are not replicated to clients, so the data was unavailable. The fix was to use the ability's
Class Default Object (CDO) instead, which allows reading properties that are set in the header directly.
Area effect handle being overwritten by multiple players: Gameplay effect handles and timer handles for
area effects (AOE) were stored as single values. When multiple players entered the area, each new player's
handle overwrote the previous one, causing mismatches where effects and timers were no longer tracking the
correct players. The fix was to store handles in a map or array keyed per player, so each player's handle is tracked independently.
Ability not committing on clients: The ability was being committed inside an animation callback, which
on clients was running only locally and never executes on the server. Since CommitAbility must
be called server-side to authoritatively apply costs and cooldowns, the fix was to move the commit to a
server-side call instead. In this case, it was moved to the RPC that spawns that ability's actor (the buff totem).
Gameplay cue not reaching clients after actor destruction: The grenade actor was being destroyed
immediately after executing its explosion gameplay cue, which prevented the cue's RPC from reaching clients.
The fix was to set a short lifespan instead of destroying the actor immediately, giving the cue's RPC
time to reach all clients reliably.
Actor double spawning on clients: The montage completion callback (from which the grenade is spawned
by calling an RPC) runs on both server and client, which was causing the grenade actor in this case to spawn
twice, once from the client path and once from the server's authoritative spawn. The fix was to separate
the two paths, so on the client only the server RPC is called to spawn the grenade, and on the server
the grenade is only spawned directly from the ability, and preventing the RPC from executing on the listen server.
Loose gameplay tags stacking unintentionally: The Equipping gameplay tag was being added whenever
weapons were equipped and removed whenever the equip animation finishes. This would cause multiple
Equipping tags to stack if another equip was started before the previous one finished since
the animation doesn't finish. To fix this, instead of using Add/RemoveGameplayTag, I used
SetGameplayTagCount and set it to 1 explicitly. The same pattern applies when you want to prevent
loose gameplay tags from stacking in other cases.
Jittery projectiles: On high latency clients, projectiles were noticeably jittery due to replication
constantly updating the projectile's position on the client while in flight, causing rubber banding because
of constant corrections. Fixed by disabling movement replication on projectiles, and passing in the launch
velocity directly into an OnRep, so the movement is handled locally for clients.
// outcome
The outcome of everything leading up to the showcase is fully networked co-op combat with GAS working
smoothly across a listen server and clients with varied latency. Getting to this point refined my specialization
skills (GAS and multiplayer) heavily, while also improving skills in AI, UI, animation, level building, and
general programming, editor, and engine knowledge. Next up, I will be working on proper enemy spawning, two
mission types, and progression for a full gameplay loop. After that, it's just polish, and Comply is done.
// further reading on topics covered in this case study
Input Tags for Ability Activation in GASRelated to: architecture entry 2