Praxis Gameplay Ability System (GAS) Technical Highlights

Praxis is a 1v1 multiplayer project developed using Unreal Engine’s Gameplay Ability System (GAS).

Praxis is no longer in active development and was intentionally concluded after implementing the core gameplay systems showcased here. The project is maintained as a technical portfolio piece rather than a finished playable game.

Overview
  1. Architecting and implementing multiplayer gameplay systems using Unreal Engine Gameplay Ability System (GAS).
  2. Implementing Gameplay Abilities, Gameplay Effects, Attribute Sets and custom Execution Calculations.
  3. Designing and Programming modular and scalable damage and status effect system.
  4. Developing a multiplayer-ready targeting system with replication and client prediction.
Responsibilities

Damage System

The damage system is designed to support multiple damage types while keeping damage application and behaviour centralised and reusable.

In Praxis, there are four damage types, each with its own customisable damage behaviour and resistance:

  • Physical Damage: Damages Health and can be absorbed by Shields.
  • Fire Damage: Damages Health and is reduced by Fire Resistance. It can also apply the Burning status effect, which deals Health damage over time.
  • Poison Damage: Damages Health and Stamina and is reduced by Poison Resistance. It can also apply the Poisoned status effect, which deals Health and Stamina damage over time.
  • Lightning Damage: Damages Health and Stamina and is reduced by Lightning Resistance. It can also build Shocks on the target, which can trigger additional effects when enough Shocks are accumulated.

The behaviour of each damage type is defined through the FPraxisDamageInfo struct.

It contains the configuration required to determine how the damage is applied, including:

  • Damage Source
  • Damage Application
  • Status Effect Application

 

➕➕
PraxisDamageTypes.h
UENUM(BlueprintType)
enum class EDamageSource : uint8
{
	/**
	 * Uses the damage value defined by the GameplayEffect modifiers.
	 */
	GameplayEffectModifiers,

	/**
	* Uses the damage value provided by the received gameplay event payload.
	* The damage value must be assigned manually in code.
	*/
	DamageEvent,

	/**
	 * Uses the value evaluated from a 'ScalableDamages'.
	 */
	ScalableDamage
};

UENUM(BlueprintType)
enum class EDamageApplication : uint8
{
	/*
	* Applies full damage to a single target.
	*/
	SingleTarget,

	/*
	* Applies full radial damage to all targets within the radius.
	*/
	Radial,

	/*
	* Applies radial damage with falloff to all targets within the radius.
	*/
	RadialWithFalloff
};

UENUM()
enum class EStatusEffectApplication : uint8
{
	/*
	* Never applies the status effect.
	*/
	Never,

	/*
	*  Uses a custom chance to apply the status effect to the target.
	*/
	Custom,

	/*
	* Always applies the status effect when the target is hit.
	*/
	Always,

	/*
	* Applies the status effect only on a critical hit.
	*/
	OnCriticalHit,

	/*
	* Calculates the status effect application chance from the final damage dealt.
	* Resistance also reduces the status effect application chance.
	* 
	* Application Chance = Final Damage Dealt / Base Damage.
	*
	* Example:
	* - Base Damage: 10
	* - Damage Dealt: 6 (60% chance)
	* - if the target has 25% resistance: 4.5 damage dealt (45% chance)
	*/
	FromDamageAmount,
};

Each ability that can deal damage contains an FPraxisDamageInfo configuration.

The damage behaviour can be configured directly from the ability’s Damage section. The editor only exposes properties relevant to the selected configuration, keeping the ability setup clean and easier to work with.

For example, selecting a radial damage application exposes the properties required for radial damage, while irrelevant properties remain hidden.

Fire Ability Damage Configuration

Sword Attacks Damage Configuration

Damage application is centralised in FPraxisDamageApplicator, which provides the following functions:

  • ApplyDamageSingleTarget()
  • ApplyRadialDamage()

FPraxisDamageApplicator::ApplyDamageSingleTarget()

➕➕
PraxisDamageInfo.cpp
void FPraxisDamageApplicator::ApplyDamageSingleTarget(const FPraxisDamageInfo& DamageInfo, int32 Level, UAbilitySystemComponent* InstigatorASC, UAbilitySystemComponent* TargetASC, const FGameplayEventData* Payload /*nullptr*/, bool bLogOnApplyDamage /*false*/, const FString& CallerName /*FString()*/)
{
	constexpr bool bPrintToLog = true, bPrintToScreen = true, bDumpStackTrace = true;
	if (!IsValid(InstigatorASC) || !IsValid(TargetASC))
	{
		PRAXIS_DEBUG_ERROR(InstigatorASC, FString::Printf(TEXT("[Caller: %s] [InstigatorASC = %s] or [TargetASC = %s] is invalid"), *CallerName, *GetNameSafe(InstigatorASC), *GetNameSafe(TargetASC)), bPrintToLog, bPrintToScreen, bDumpStackTrace);
		return;
	}

	const EDamageApplication DamageApplication = DamageInfo.GetDamageApplication();

	if (DamageApplication != EDamageApplication::SingleTarget)
	{
		PRAXIS_DEBUG_ERROR(InstigatorASC, FString::Printf(TEXT("[Caller: %s] called ApplyDamageSingleTarget() but DamageApplication is [%s]"), *CallerName, *StaticEnum<EDamageApplication>()->GetValueAsString(DamageApplication)), bPrintToLog, bPrintToScreen, bDumpStackTrace);
		return;
	}

	FGameplayEffectSpecHandle EffectSpecHandle = MakeDamageEffectSpecHandle(DamageInfo, InstigatorASC, Level);
	check(EffectSpecHandle.IsValid());

	ConfigureDamageMagnitudes(EffectSpecHandle, DamageInfo, Level, Payload, CallerName, [](float BaseDamage) { return BaseDamage; });

	const FActiveGameplayEffectHandle AppliedEffectHandle = InstigatorASC->ApplyGameplayEffectSpecToTarget(*EffectSpecHandle.Data, TargetASC);

#if !UE_BUILD_SHIPPING
	if (bLogOnApplyDamage)
	{
		LogAppliedDamage(DamageInfo, Level, InstigatorASC, TargetASC, AppliedEffectHandle, Payload, CallerName);
	}
#endif // !UE_BUILD_SHIPPING
}

FPraxisDamageApplicator::ApplyRadialDamage()

➕➕
PraxisDamageInfo.cpp
void FPraxisDamageApplicator::ApplyRadialDamage(const FVector& ImpactPoint, const FPraxisDamageInfo& DamageInfo, int32 Level, UAbilitySystemComponent* InstigatorASC, const TArray<AActor*>& Targets, const FGameplayEventData* Payload /*nullptr*/, bool bLogOnApplyDamage /*false*/, const FString& CallerName /*FString()*/)
{
	if (Targets.IsEmpty())
	{
		return;
	}
	constexpr bool bPrintToLog = true, bPrintToScreen = true, bDumpStackTrace = true;
	if (!IsValid(InstigatorASC))
	{
		PRAXIS_DEBUG_ERROR(InstigatorASC, FString::Printf(TEXT("[Caller: %s] ApplyRadialDamage() InstigatorASC is invalid"), *CallerName), bPrintToLog, bPrintToScreen, bDumpStackTrace);
		return;
	}

	const EDamageApplication DamageApplication = DamageInfo.GetDamageApplication();

	const bool bIsRadialDamage = (DamageApplication == EDamageApplication::Radial || DamageApplication == EDamageApplication::RadialWithFalloff);
	if (!bIsRadialDamage)
	{
		PRAXIS_DEBUG_ERROR(InstigatorASC, FString::Printf(TEXT("[Caller: %s] Damage application is neither 'Radial' or 'RadialWithFalloff' but ApplyRadialDamage() is called. [DamageApplication = %s] "), *CallerName, *StaticEnum<EDamageApplication>()->GetValueAsString(DamageApplication)), bPrintToLog, bPrintToScreen, bDumpStackTrace);
		return;
	}

	for (AActor* Actor : Targets)
	{
		UAbilitySystemComponent* TargetASC = UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(Actor);
		if (!IsValid(TargetASC))
		{
			continue;
		}

		FGameplayEffectSpecHandle EffectSpecHandle = MakeDamageEffectSpecHandle(DamageInfo, InstigatorASC, Level);
		check(EffectSpecHandle.IsValid());

		float RadialFalloff = 0.f;

		ConfigureDamageMagnitudes(EffectSpecHandle, DamageInfo, Level, Payload, CallerName,
			[&](float BaseDamage)
			{ 
				if (DamageApplication == EDamageApplication::RadialWithFalloff)
				{
					const float Radius = DamageInfo.GetDamageRadius(Level);
					const UCurveFloat* FalloffCurve = DamageInfo.GetDamageFalloffCurve();
					float RadialFalloffMultiplier = 0.f;

					FOutcome Result = CalculateRadialFalloffMultiplier(ImpactPoint, Radius, FalloffCurve, Actor, RadialFalloffMultiplier);
					if (Result.IsFailure())
					{
						PRAXIS_DEBUG_ERROR(InstigatorASC, FString::Printf(TEXT("[Caller: %s] [%s]. Actor[%s]"), *CallerName, *Result.GetMessage(), *GetNameSafe(Actor)), bPrintToLog, bPrintToScreen, bDumpStackTrace);
					}

					EffectSpecHandle.Data->SetSetByCallerMagnitude(PraxisTags::SetByCaller_Damage_RadialFalloffMultiplier, RadialFalloffMultiplier);
				}
				return BaseDamage;
			});

		const FActiveGameplayEffectHandle AppliedEffectHandle = InstigatorASC->ApplyGameplayEffectSpecToTarget(*EffectSpecHandle.Data, TargetASC);
#if !UE_BUILD_SHIPPING
		if (bLogOnApplyDamage)
		{
			LogAppliedDamage(DamageInfo, Level, InstigatorASC, TargetASC, AppliedEffectHandle, Payload, CallerName);
		}
#endif // !UE_BUILD_SHIPPING
	}
}

When damage is applied, the system can optionally log detailed information if bLogOnApplyDamage is enabled.

The log shows information about the damage application, including the caller, effect class, instigator, target, damage type, status effect, damage source, application method, radius, falloff curve, and base damage values.

The logged base damage represents the damage before the Execution Calculation and radial falloff are applied.

This provides a clear overview of how each damage type is configured and applied, making the system easier to inspect and validate.

Poison Ability Damage Application Log

UPraxisDamageExecutionCalculation is responsible for converting the configured base damage into the final damage applied to the target.

The calculation order:

  1. Retrieves the base damage values from the Gameplay Effect Spec.
  2. Applies the attacker’s Attack Power.
  3. Applies radial falloff when applicable.
  4. Handles Shield absorption for Physical Damage.
  5. Applies the target’s damage resistances
  6. Calculates Critical Hits and their damage bonuses.
  7. Calculates whether the configured Status Effect should be applied.
  8. Outputs the final Health, Stamina, Shield, and Shock
Damage Execution Calculation

UPraxisDamageExecutionCalculation::Execute_Implementation()

➕➕
PraxisDamageExecutionCalculation.cpp
void UPraxisDamageExecutionCalculation::Execute_Implementation(const FGameplayEffectCustomExecutionParameters& ExecutionParams, FGameplayEffectCustomExecutionOutput& OutExecutionOutput) const
{
	
	constexpr bool bPrintToLog = true, bPrintToScreen = true, bDumpStackTrace = true;
	const FGameplayEffectSpec& OwningSpec = ExecutionParams.GetOwningSpec();

	const FGameplayTagContainer* SourceAggregatedTags = OwningSpec.CapturedSourceTags.GetAggregatedTags();
	const FGameplayTagContainer* TargetAggregatedTags = OwningSpec.CapturedTargetTags.GetAggregatedTags();

	if (!SourceAggregatedTags || !TargetAggregatedTags)
	{
		PRAXIS_DEBUG_ERROR(this, TEXT("SourceAggregatedTags or TargetAggregatedTags is not valid"), bPrintToLog, bPrintToScreen, bDumpStackTrace);
		return;
	}

	FPraxisGameplayEffectContext* EffectContext = UPraxisFunctionLibrary::GetPraxisGameplayEffectContext(OwningSpec);
	check(EffectContext);

	/* Get base damages */
	float BasePhysicalDamage = GetBaseDamageValue(OwningSpec, PraxisTags::Damage_Physical, PraxisTags::SetByCaller_Damage_Physical);

	float BaseFireDamage = GetBaseDamageValue(OwningSpec, PraxisTags::Damage_Fire, PraxisTags::SetByCaller_Damage_Fire);

	float BasePoisonHealthDamage  = GetBaseDamageValue(OwningSpec, PraxisTags::Damage_Poison_Health, PraxisTags::SetByCaller_Damage_Poison_Health);
	float BasePoisonStaminaDamage = GetBaseDamageValue(OwningSpec, PraxisTags::Damage_Poison_Stamina, PraxisTags::SetByCaller_Damage_Poison_Stamina);

	float BaseLightningHealthDamage = GetBaseDamageValue(OwningSpec, PraxisTags::Damage_Lightning, PraxisTags::SetByCaller_Damage_Lightning);
	float BaseLightningShocks       = GetBaseDamageValue(OwningSpec, PraxisTags::Damage_Lightning_Shock, PraxisTags::SetByCaller_Damage_Lightning_Shock);


	/* Apply AttackPower to orginal values */
	FAggregatorEvaluateParameters AggregatorEvaluateParams;
	AggregatorEvaluateParams.SourceTags = SourceAggregatedTags;
	AggregatorEvaluateParams.TargetTags = TargetAggregatedTags;

	float AttackPower = 0.f; 
	ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(PraxisDamageStatics().AttackPowerDef, AggregatorEvaluateParams, AttackPower);
	
	float* const OrginalHealthDamagesBaseValues[]
	{
		&BasePhysicalDamage,
		&BaseFireDamage,
		&BasePoisonHealthDamage,
		&BaseLightningHealthDamage,
	};

	for (float* const Damage : OrginalHealthDamagesBaseValues)
	{
		*Damage *= (1.0f + AttackPower); 
	}

	if (BasePoisonStaminaDamage > 0.f)
	{
		BasePoisonStaminaDamage *= (1.0f + AttackPower);
	}

	// copy the orginal value here because I need to preserve it for later usages so I use a Adjustable Values
	float AdjustablePhysicalDamage = BasePhysicalDamage;
	float AdjustableFireDamage = BaseFireDamage;

	float AdjustablePoisonHealthDamage = BasePoisonHealthDamage;
	float AdjustablePoisonStaminaDamage = BasePoisonStaminaDamage;

	float AdjustableLightningHealthDamage = BaseLightningHealthDamage;
	float AdjustableLightningShocks = BaseLightningShocks;  


	/*** Apply Radial fall off ***/

	EDamageApplication DamageApplication = EffectContext->GetDamageApplication();
	if (DamageApplication == EDamageApplication::RadialWithFalloff)
	{
		const float RadialFalloffMultiplier = OwningSpec.GetSetByCallerMagnitude(PraxisTags::SetByCaller_Damage_RadialFalloffMultiplier);

		float* const DamageRadialFalloff[]
		{
			&AdjustablePhysicalDamage,
			&AdjustableFireDamage,
			&AdjustablePoisonHealthDamage,
			&AdjustablePoisonStaminaDamage,
			&AdjustableLightningHealthDamage
		};

		for (float* Damage : DamageRadialFalloff)
		{
			*Damage *= RadialFalloffMultiplier; // RadialFalloffMultiplier is a value between 1.f and 0.f, if 0 then no damage is applied
		}
	}
	
	/* Apply resistances */
	
	/*** calculate shiled damage for PhysicalDamage ***/
	float CurrentShield = 0.f;
	ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(PraxisDamageStatics().CurrentShieldDef, AggregatorEvaluateParams, CurrentShield);

	float CurrentShieldDamage = 0.f;
	float FinalPhysicalDamage = AdjustablePhysicalDamage;
	if (CurrentShield > 0.f)
	{
		CurrentShieldDamage = FMath::Min(CurrentShield, AdjustablePhysicalDamage);
		FinalPhysicalDamage = AdjustablePhysicalDamage - CurrentShieldDamage;
	}


	float FinalFireDamage = CalculateResistancePercentage(AdjustableFireDamage, PraxisDamageStatics().FireResistanceDef, AggregatorEvaluateParams, ExecutionParams);

	float FinalPoisonHealthDamage = CalculateResistancePercentage(AdjustablePoisonHealthDamage, PraxisDamageStatics().PoisonResistanceDef, AggregatorEvaluateParams, ExecutionParams);
	float FinalPoisonStaminaDamage = CalculateResistancePercentage(AdjustablePoisonStaminaDamage, PraxisDamageStatics().PoisonResistanceDef, AggregatorEvaluateParams, ExecutionParams);

	float FinalLightningHealthDamage = CalculateResistancePercentage(AdjustableLightningHealthDamage, PraxisDamageStatics().LightningResistanceDef, AggregatorEvaluateParams, ExecutionParams);
	int32 FinalLightningShocks = FMath::TruncToInt(CalculateResistancePercentage(AdjustableLightningShocks, PraxisDamageStatics().LightningResistanceDef, AggregatorEvaluateParams, ExecutionParams));


	// caclulate crtical hit chance
	float CriticalHitChance = 0.f;
	ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(PraxisDamageStatics().CriticalHitChanceDef, AggregatorEvaluateParams, CriticalHitChance);

	float* const FinalHealthDamageTypes[]
	{
		&FinalPhysicalDamage,
		&FinalFireDamage,
		&FinalPoisonHealthDamage,
		&FinalLightningHealthDamage
	};

	static const FGameplayTagContainer SkipCrticalHitTags =
		[]
		{
			FGameplayTagContainer Tags;
			Tags.AddTag(PraxisTags::Status_Burning);
			Tags.AddTag(PraxisTags::Status_Poisoned);
			Tags.AddTag(PraxisTags::Status_Shocked);
			return Tags;
		}();

	const bool bApplyCrticalHit = !SourceAggregatedTags->HasAnyExact(SkipCrticalHitTags);
	if (bApplyCrticalHit)
	{
		const float RandomRoll = FMath::FRand();

		if (RandomRoll <= CriticalHitChance)
		{
			EffectContext->SetCriticalHit(true);

			const float CriticalHitDamageBonus = UPraxisGameplayDeveloperSettings::Get()->GetCriticalHitDamageBonus();

			for (float* Damage : FinalHealthDamageTypes)
			{
				*Damage *= (1.0f + CriticalHitDamageBonus);
			}

			if (FinalPoisonStaminaDamage > 0.f)
			{
				FinalPoisonStaminaDamage *= (1.0f + CriticalHitDamageBonus);
			}

			if (FinalLightningShocks > 0.f)
			{
				FinalLightningShocks = FMath::RoundToInt(FinalLightningShocks * (1.0f + CriticalHitDamageBonus));
			}
		}

	}
	
	// calculate status EffectChance
	float FinalHealthDamage = 0.f;
	for (float* Damage : FinalHealthDamageTypes)
	{
		FinalHealthDamage += *Damage;
	}

	float FinalStaminaDamage = FinalPoisonStaminaDamage;
	float FinalDamageAmount = FinalHealthDamage + FinalStaminaDamage;
	float BaseDamageAmount = BasePoisonStaminaDamage;
	for (float* Damage : OrginalHealthDamagesBaseValues)
	{
		BaseDamageAmount += *Damage;
	}

	CheckStatusEffects(EffectContext, BaseDamageAmount, FinalDamageAmount);

	/* apply the damages*/
	OutExecutionOutput.AddOutputModifier(FGameplayModifierEvaluatedData(PraxisDamageStatics().IncomingShieldDamageProperty, EGameplayModOp::Override, CurrentShieldDamage));
	OutExecutionOutput.AddOutputModifier(FGameplayModifierEvaluatedData(PraxisDamageStatics().IncomingHealthDamageProperty, EGameplayModOp::Override, FinalHealthDamage));
	OutExecutionOutput.AddOutputModifier(FGameplayModifierEvaluatedData(PraxisDamageStatics().IncomingStaminaDamageProperty, EGameplayModOp::Override, FinalStaminaDamage));
	OutExecutionOutput.AddOutputModifier(FGameplayModifierEvaluatedData(PraxisDamageStatics().LightningShockProperty, EGameplayModOp::AddBase, FinalLightningShocks));
}

Gameplay Abilities

All abilities derive from UPraxisBaseAbility, which provides shared functionality.

Attack abilities derive from UPraxisBaseAttackAbility, which validates attack and damage data at editor time. IsDataValid() reports invalid configurations directly in the Blueprint editor, before runtime.

UPraxisBaseAttackAbility::IsDataValid()

➕➕
PraxisBaseAttackAbility.cpp
#if WITH_EDITOR
EDataValidationResult UPraxisBaseAttackAbility::IsDataValid(FDataValidationContext& Context) const
{
	EDataValidationResult Result = Super::IsDataValid(Context);

	auto Validate = [&](bool bIsValid, const TCHAR* PropertyName)->bool
		{
			if (!bIsValid)
			{
				Context.AddError(FText::FromString(FString::Printf(TEXT("Ability [%s] has invalid '%s'"),*GetNameSafe(this), PropertyName)));

				Result = EDataValidationResult::Invalid;
				return false;
			}
			return true;
		};

	/* 
	* Main properties validation
	*/
	const TSubclassOf<UGameplayEffect>& DamageClass = AbilityDamageinfo.GetDamageClass();
	const FGameplayTag& DamageTag = AbilityDamageinfo.GetDamageTag();

	Validate(IsValid(DamageClass),TEXT("DamageClass"));
	Validate(DamageTag.IsValid(),TEXT("DamageTag"));


	/*
	* DamageApplication validation
	*/
	EDamageApplication DamageApplication = AbilityDamageinfo.GetDamageApplication();
	if (DamageApplication == EDamageApplication::RadialWithFalloff)
	{
		UCurveFloat* FalloffCurve = AbilityDamageinfo.GetDamageFalloffCurve();
		if (DamageApplication == EDamageApplication::RadialWithFalloff && !IsValid(FalloffCurve))
		{
			Context.AddError(FText::FromString(FString::Printf(TEXT("Ability [%s] DamageApplication is [%s] but FalloffCurve is not set "), *GetNameSafe(this), *StaticEnum<EDamageApplication>()->GetValueAsString(DamageApplication))));
			Result = EDataValidationResult::Invalid;
		}
	}

	/*
	* Status Effect validation
	*/
	EStatusEffectApplication StatusEffectApplication = AbilityDamageinfo.GetStatusEffectApplication();
	
	if (StatusEffectApplication != EStatusEffectApplication::Never)
	{
		const FGameplayTag ApplicableStatusEffect = AbilityDamageinfo.GetApplicableStatusEffect();
		if (!ApplicableStatusEffect.IsValid())
		{
			Context.AddError(FText::FromString(FString::Printf(TEXT("Ability [%s] StatusEffectApplication is [%s] but ApplicableStatusEffect is not set "), *GetNameSafe(this), *StaticEnum<EStatusEffectApplication>()->GetValueAsString(StatusEffectApplication))));
			Result = EDataValidationResult::Invalid;
		}
	}

	/*
	* DamageSource validation
	*/
	const EDamageSource DamageSource = AbilityDamageinfo.GetDamageSource();

	if (DamageSource == EDamageSource::DamageEvent && !AbilityDamageinfo.GetSetByCallerTag().IsValid())
	{
		Context.AddError(FText::FromString(
			FString::Printf(TEXT("Ability [%s] '%s' has Damage source set to [%s] but SetByCallerTag is not valid"),
				*GetNameSafe(this), *GET_MEMBER_NAME_CHECKED(UPraxisBaseAttackAbility, AbilityDamageinfo).ToString(), *StaticEnum<EDamageSource>()->GetValueAsString(DamageSource))));
		Result = EDataValidationResult::Invalid;
	}

	if (DamageSource == EDamageSource::ScalableDamage)
	{
		TArray<FScalableDamage> ScalableDamages;
		constexpr int32 IgnoredLevel = 1; // no need for level inside IsDataValid() for validating if ScalableDamages is empty;
		AbilityDamageinfo.GetScalableDamages(IgnoredLevel, ScalableDamages);

		if (ScalableDamages.IsEmpty())
		{
			Context.AddError(FText::FromString(
				FString::Printf(TEXT("Ability [%s] '%s' has Damage source set to [%s] but ScalableDamages is empty"),
					*GetNameSafe(this), *GET_MEMBER_NAME_CHECKED(UPraxisBaseAttackAbility, AbilityDamageinfo).ToString(), *StaticEnum<EDamageSource>()->GetValueAsString(DamageSource))));
			Result = EDataValidationResult::Invalid;
		}

		for (int32 Index = 0; Index < ScalableDamages.Num(); ++Index)
		{
			if (!ScalableDamages[Index].SetByCallerTag.IsValid())
			{
				Context.AddError(FText::FromString(
					FString::Printf(TEXT("Ability [%s] '%s' has Damage source set to [%s] but SetByCallerTag is not valid at index [%d]"),
						*GetNameSafe(this), *GET_MEMBER_NAME_CHECKED(UPraxisBaseAttackAbility, AbilityDamageinfo).ToString(), *StaticEnum<EDamageSource>()->GetValueAsString(DamageSource), Index)));
				Result = EDataValidationResult::Invalid;
			}
		}
	}

	return Result;
}
#endif WITH_EDITOR

Sword Attack Abilities

The sword attack is a Gameplay Ability and it supports three directional attacks:

  • Left Attack using A + RMB
  • Right Attack using D + RMB
  • Upward Attack using MMB

The attack direction is represented using Gameplay Tags, allowing the ability, weapon, animation system, and damage system to communicate attack state without tightly coupling these systems together.

The upward attack is marked with the Attack_Property_Uncounterable Gameplay Tag, which allow the attack to be eligible for optional elemental damage.

 

Animation Selection

When the ability is activated, the weapon is provided through FGameplayEventData::OptionalObject. This keeps the ability independent of a specific weapon implementation while still allowing the weapon to provide its attack configuration.

➕➕
PraxisSwordAttackAbility.cpp
const AWeapon* Weapon = Cast<AWeapon>(TriggerEventData->OptionalObject);
if (!IsValid(Weapon))
{
	constexpr bool bReplicateEndAbility = true;
	EndAbilityIfActive(bReplicateEndAbility);
	PRAXIS_DEBUG_ERROR(GetAvatarActorFromActorInfo(), TEXT("Weapon is not valid"), true, true, true);
	return;
}

const FWeaponMontageData* MontageData = Weapon->GetBestMatchingMontageData(EWeaponMontageType::Attack, TriggerEventData->InstigatorTags);

if (!MontageData)
{
	constexpr bool bReplicateEndAbility = true;
	EndAbilityIfActive(bReplicateEndAbility);
	PRAXIS_DEBUG_ERROR(GetAvatarActorFromActorInfo(), TEXT("MontageData is not valid"), true, true, true);
	return;
}

The ability selects the best attack montage from the weapon using the attack-related InstigatorTags received in the gameplay event.

Montage Selection

AWeapon::GetBestMatchingMontageData() performs the weapon side lookup and eventually calls UWeaponData::FindBestMatchingMontageData().

The weapon data is therefore responsible for resolving the correct montage from the available attack configurations.

This separates attack execution from animation configuration.

For example, the same sword ability can support different weapons with different animations without modifying the ability itself.

UWeaponData::FindBestMatchingMontageData

➕➕
WeaponData.cpp
const FWeaponMontageData* UWeaponData::FindBestMatchingMontageData(const TArray<FWeaponMontageData>& WeaponMontageDataArray, const FGameplayTagContainer& MontageQueryTags, const FStringView ArrayName) const
{
	constexpr bool bPrintToLog = true, bPrintToScreen = true, bDumpStackTrace = true;
	if (MontageQueryTags.IsEmpty())
	{
		PRAXIS_DEBUG_ERROR(this, TEXT("MontageQueryTags is empty."), bPrintToLog, bPrintToScreen, bDumpStackTrace);
		return nullptr; //returning nullptr here because HasAllExact would return true if MontageQueryTags is empty.
	}

	const FWeaponMontageData* BestMatchMontage = nullptr;
	int32 BestMontageTagCount = -1;

	for (const FWeaponMontageData& MontageData : WeaponMontageDataArray)
	{
		if (MontageData.MontageTags.IsEmpty())
		{
			PRAXIS_DEBUG_ERROR(this, FString::Printf(TEXT("Weapon montage tags are empty in array [%.*s]."), ArrayName.Len(), ArrayName.GetData()), bPrintToLog, bPrintToScreen, bDumpStackTrace);
			return nullptr;
		}

		// Accept only if all entry tags (WeaponMontage.MontageTags) are present in the query (QueryMontageTags)
		if (!MontageQueryTags.HasAllExact(MontageData.MontageTags))
		{
			continue;
		}

		const int32 TagCount = MontageData.MontageTags.Num();

		if (TagCount > BestMontageTagCount)
		{
#if !UE_BUILD_SHIPPING
			if (!MontageData.IsValid())
			{
				const FString MissingMontageMessage = FString::Printf(TEXT("%s: Invalid MontageData in match for query [%s] in %.*s (MontageTags: [%s])"), 
					*GetNameSafe(this), *MontageQueryTags.ToStringSimple(), ArrayName.Len(), ArrayName.GetData(),
					*MontageData.MontageTags.ToStringSimple());

				PRAXIS_DEBUG_ERROR(this, MissingMontageMessage, bPrintToLog, bPrintToScreen, bDumpStackTrace);
				continue;
			}
#endif // !UE_BUILD_SHIPPING
			BestMontageTagCount = TagCount;
			BestMatchMontage = &MontageData;
		}
	}
	return BestMatchMontage;
}

The sword ability only creates its gameplay event listener when running on the server

➕➕
PraxisSwordAttackAbility.cpp
if (OwnerHasAuthority())
{
	FGameplayEventTaskSettings EventTaskSettings;
	EventTaskSettings.EventTag = HitEventTag;
	EventTaskSettings.EventReceived.AddUniqueDynamic(this, &UPraxisSwordAttackAbility::OnHitEventReceived);
	ListenForGameplayEvent(EventTaskSettings);
}

UPraxisBaseAbility::ListenForGameplayEvent()

➕➕
PraxisBaseAttackAbility.cpp
void UPraxisBaseAbility::ListenForGameplayEvent(const FGameplayEventTaskSettings& EventTaskSettings)
{
	constexpr bool bPrintToLog = true, bPrintToScreen = true, bDumpStackTrace = true;
	if (!EventTaskSettings.EventReceived.IsBound())
	{
		PRAXIS_DEBUG_ERROR(GetAvatarActorFromActorInfo(), FString::Printf(TEXT("Ability [%s] cannot create WaitGameplayEvent task because EventTaskSettings.EventReceived is not bound."), *GetNameSafe(this)), bPrintToLog, bPrintToScreen, bDumpStackTrace);
		return;
	}

	if (!EventTaskSettings.EventTag.IsValid())
	{
		PRAXIS_DEBUG_ERROR(GetAvatarActorFromActorInfo(), FString::Printf(TEXT("Ability [%s] cannot create WaitGameplayEvent task because EventTaskSettings.EventTag is not valid."), *GetNameSafe(this)), bPrintToLog, bPrintToScreen, bDumpStackTrace);
		return;
	}

	UAbilityTask_WaitGameplayEvent* WaitGameplayEvent = UAbilityTask_WaitGameplayEvent::WaitGameplayEvent(this, EventTaskSettings.EventTag);
	WaitGameplayEvent->EventReceived = EventTaskSettings.EventReceived;
	WaitGameplayEvent->ReadyForActivation();
}

This keeps the event listening implementation reusable by other abilities instead of requiring every ability to implement its own event task setup.

Server Authoritative Hit Detection

The actual sword traces are performed only on the server.

The ability does not directly perform the trace. Instead, the weapon hit detection performs the trace and reports successful hits back to the ability through a Gameplay Event.

This gives the system a clear separation:

Attack Ability

  1. starts the attack
  2. plays the animation
  3. waits for hit results

Hit Detection

  1. performs server-side traces
  2. determines whether a target was hit
  3. sends a Gameplay Event
Processing the Hit

When the sword ability receives the hit event, damage application is handled through FPraxisDamageApplicator.

The sword’s base damage type is Physical, but upward attacks can additionally apply elemental damage.

Rather than combining all the damage information into a single Gameplay Effect Spec, I use separate Gameplay Effect Specs for the physical and elemental portions.

This is because the physical sword hit and elemental effect have different damage information and damage types.

Keeping them separate makes the damage easier to reason about and allows the elemental portion to evolve independently.

➕➕
PraxisSwordAttackAbility.cpp
void UPraxisSwordAttackAbility::OnHitEventReceived(FGameplayEventData Payload)
{
	const AActor* TargetActor = Payload.Target;
	if (!IsValid(TargetActor))
	{
		AActor* AvatarActor = GetAvatarActorFromActorInfo();
		PRAXIS_DEBUG_WARNING(AvatarActor, FString::Printf(TEXT("Ability [%s] received hit event but Payload.Target is not valid"), *GetNameSafe(this)), true, true, true);
		return;
	}

	ApplyDamageEffect(AbilityDamageinfo, Payload);

	/*only Uncounterable attacks can do elemental Damage*/
	if (Payload.InstigatorTags.HasTagExact(PraxisTags::Attack_Property_Uncounterable) && SwordElementalDamage.IsValid())
	{
		ApplyDamageEffect(SwordElementalDamage, Payload);
	}
}

void UPraxisSwordAttackAbility::ApplyDamageEffect(const FPraxisDamageInfo& DamageInfo, const FGameplayEventData& Payload)
{
	EDamageApplication DamageApplication =  DamageInfo.GetDamageApplication();

	const AActor* Instigator = Payload.Instigator.Get();
	const AActor* Target = Payload.Target.Get();
	UAbilitySystemComponent* InstigatorASC = UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(const_cast<AActor*>(Instigator));
	UAbilitySystemComponent* TargetASC = UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(const_cast<AActor*>(Target));
		
	if (!IsValid(InstigatorASC) || !IsValid(TargetASC))
	{
		PRAXIS_DEBUG_WARNING(GetAvatarActorFromActorInfo(), FString::Printf(TEXT(" [Instigator: %s | InstigatorASC: %s]  or  [Target:%s | TargetASC: %s] has invalid AbilitySystemComponent"), *GetNameSafe(Instigator), *GetNameSafe(InstigatorASC), *GetNameSafe(Target), *GetNameSafe(TargetASC)), true, true, false);
		return;
	}
	FPraxisDamageApplicator::ApplyDamageSingleTarget(DamageInfo, GetAbilityLevel(), InstigatorASC, TargetASC, &Payload, bLogOnApplyDamage, GetName());
}

Sword attack ability showcase

Elemental Attack Abilities

There are three elemental attack abilities, all sharing the same C++ base class, UBaseElementalAttackAbility. The specific variations are implemented as Blueprint classes:

  • GA_FireAttack
  • GA_PoisonAttack
  • GA_LightningAttack

When an elemental ability is activated, it waits for a Gameplay Event that determines when the projectile should be spawned. When the event is received, the ability spawns the configured projectile and passes its FPraxisDamageInfo and ability level to the projectile.

➕➕
UBaseElementalAttackAbility.cpp
void UBaseElementalAttackAbility::OnSpawnEventReceived(FGameplayEventData Payload)
{
	AActor* Owner = GetOwningActorFromActorInfo();
	APawn* Instigator = Cast<APawn>(GetAvatarActorFromActorInfo());

	const AActor* TargetActor = Payload.Target;

	if (!IsValid(Owner) || !IsValid(Instigator) || !IsValid(TargetActor))
	{
		PRAXIS_DEBUG_ERROR(Owner, FString::Printf(TEXT("OnSpawnEventReceived failed: Invalid actor(s). Owner: %s, Instigator: %s, Target: %s"), *GetNameSafe(Owner), *GetNameSafe(Instigator), *GetNameSafe(TargetActor)), true, true, false);
		return;
	}

	const FVector TargetLocation = TargetActor->GetActorLocation();
	const FVector SpawnLocation = TargetLocation + TargetActor->GetActorUpVector() * ProjectileSpawnHeightOffset;
	const FRotator SpawnRotation = (TargetLocation - SpawnLocation).Rotation();

	const FTransform SpawnTransform(SpawnRotation, SpawnLocation);

	APraxisBaseProjectileActor* ProjectileActor = GetWorld()->SpawnActorDeferred<APraxisBaseProjectileActor>(ProjectileClass, SpawnTransform, Owner, Instigator, ESpawnActorCollisionHandlingMethod::AlwaysSpawn);

	if (IsValid(ProjectileActor))
	{
		ProjectileActor->SetProjectileDamageinfo(AbilityDamageinfo, GetAbilityLevel());
		ProjectileActor->SetOwner(Instigator);
		ProjectileActor->FinishSpawning(SpawnTransform);
	}
}

The important architectural point here is that the elemental ability owns the attack configuration, while the projectile is responsible for carrying that configuration into the world and applying it.

When the projectile overlaps an actor, APraxisBaseProjectileActor::OnSphereBeginOverlap() forwards the collision to ApplyProjectileDamage().

The projectile then uses its configured EDamageApplication to determine whether it should apply single-target or radial damage.

➕➕
PraxisBaseProjectileActor.cpp
void APraxisBaseProjectileActor::ApplyProjectileDamage(AActor* OtherActor, const FHitResult& SweepResult)
{
	if (!HasAuthority())
	{
		return;
	}
	UAbilitySystemComponent* ASC = UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(GetOwner());

	constexpr bool bPrintToLog = true, bPrintToScreen = true, bDumpStackTrace = true;
	if (!IsValid(ASC))
	{
		PRAXIS_DEBUG_ERROR(this, FString::Printf(TEXT("Owner: %s has invalid AbilitySystemComponent"), *GetNameSafe(GetOwner())), bPrintToLog, bPrintToScreen, bDumpStackTrace);
		return;
	}

	EDamageApplication DamageApplication = ProjectileDamageinfo.GetDamageApplication();
	if (DamageApplication == EDamageApplication::SingleTarget)
	{
		UAbilitySystemComponent* TargetASC = UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(OtherActor);
		const bool bIsHitableActor = OtherActor->Implements<UHitableActor>();
		if (!IsValid(TargetASC) || !bIsHitableActor)
		{
			return;
		}
		FPraxisDamageApplicator::ApplyDamageSingleTarget(ProjectileDamageinfo, Level, ASC, TargetASC, nullptr, bLogOnApplyDamage, GetName());
	}

	if (DamageApplication == EDamageApplication::Radial || DamageApplication == EDamageApplication::RadialWithFalloff)
	{
		TArray<AActor*> HitActors;
		SphereHitTrace(SweepResult.ImpactPoint, ProjectileDamageinfo.GetDamageRadius(Level), HitActors);
		if (HitActors.IsEmpty())
		{
			return;
		}
		FPraxisDamageApplicator::ApplyRadialDamage(SweepResult.ImpactPoint, ProjectileDamageinfo, Level, ASC, HitActors, nullptr, bLogOnApplyDamage, GetName());
	}
}

Status Effects

Status effect damage is passed through SetByCaller values. This allows different abilities to configure different damage values for the same status effect.

For example, the elemental poison damage applied by a sword attack can be configured to deal less damage than the GA_ PoisonAttack ability without requiring separate status effect implementations.

 

The damage values are configured using FScalableFloat, allowing the values to scale with the ability level.

The struct that contains the damage magnitudes and tags is FScalableDamage.

The FScalableFloat::ScalableMagnitude property is private and can only be accessed by the friend class FPraxisDamageInfo, which provides FPraxisDamageInfo::GetStatusEffectDamages() retrieves the configured damage values used when applying the status effect.

 

Whether a Gameplay Effect should apply a status effect is determined through the custom Gameplay Effect Context using:

  • FPraxisGameplayEffectContext::bApplyStatusEffect
  • FPraxisGameplayEffectContext::ApplicableStatusEffect

This allows the status effect application data to travel with the Gameplay Effect and remain available wherever the effect is processed.

FScalableDamage

➕➕
PraxisDamageInfo.h
USTRUCT()
struct FScalableDamage
{
	GENERATED_BODY()
	friend struct FPraxisDamageInfo;
public:
	FScalableDamage() = default;

	FScalableDamage(const FGameplayTag& InDamageType , const FGameplayTag& InSetByCallerTag, float InMagnitude )
		: DamageType(InDamageType), SetByCallerTag(InSetByCallerTag), Magnitude(InMagnitude)  {}

	UPROPERTY(EditDefaultsOnly, meta = (Categories = "Damage"))
	FGameplayTag DamageType;

	UPROPERTY(EditDefaultsOnly, meta = (Categories = "SetByCaller.Damage"))
	FGameplayTag SetByCallerTag;

	UPROPERTY(Transient)
	float Magnitude = 0.f;

private:
	UPROPERTY(EditDefaultsOnly)
	FScalableFloat ScalableMagnitude;
};

FPraxisDamageInfo::GetStatusEffectDamages() And FPraxisDamageInfo ::GetScalableFloatValue()

➕➕
PraxisDamageInfo.cpp
void FPraxisDamageInfo::GetStatusEffectDamages(int32 Level, TArray<FScalableDamage>& OutStatusEffectDamages) const
{
	if (StatusEffectApplication == EStatusEffectApplication::Never)
	{
		constexpr bool bPrintToLog = true, bPrintToScreen = true, bDumpStackTrace = true;
		const FString ErrorMessage = FString::Printf(TEXT("Called GetStatusEffectDamages() but StatusEffectApplication is EStatusEffectApplication::Never"));
		PRAXIS_DEBUG_ERROR(nullptr, ErrorMessage, bPrintToLog, bPrintToScreen, bDumpStackTrace);
		return;
	}

	if (StatusEffectDamages.IsEmpty())
	{
		return;
	}

	for (const FScalableDamage& StatusDamage : StatusEffectDamages)
	{
		const float Damage = GetScalableFloatValue(StatusDamage.ScalableMagnitude, Level);
		OutStatusEffectDamages.Add({ StatusDamage.DamageType, StatusDamage.SetByCallerTag, Damage });
	}
}

float FPraxisDamageInfo::GetScalableFloatValue(const FScalableFloat& ScalableFloat, int32 Level) const
{
	float ScalableFloatValue = 0.f;
	if (ScalableFloat.IsValid())
	{
		ScalableFloatValue = ScalableFloat.GetValueAtLevel(Level);
	}
	else
	{
		ScalableFloatValue = ScalableFloat.GetValue();
	}
	return ScalableFloatValue;
}

Poisoned status effect (GE_Poisoned)

Poisoned status effect damage configuration in GA_PoisoneAttack

Attribute Sets

The attributes are divided into two attribute sets: Core Attributes and Combat Attributes.

The Core Attribute Set contains Health, Stamina, and Shield, while the Combat Attribute Set contains Resistances, Attack Power, Critical Hit Chance, and the Shocks applied by Lightning attacks.

The primary attributes are:

  • Strength increases Attack Power.
  • Vitality increases Health.
  • Endurance increases Stamina.
  • Luck increases Critical Hit Chance.

The amount that each primary attribute contributes to its derived attribute is defined in Developer Settings, allowing for easy configuration and balancing. The calculation is then handled by a custom MMC (UGameplayModMagnitudeCalculation).

Custom UGameplayModMagnitudeCalculation for MaxHealth

➕➕
MMC_MaxHealth.cpp
float UMMC_MaxHealth::CalculateBaseMagnitude_Implementation(const FGameplayEffectSpec& Spec) const
{
	const FGameplayTagContainer* TargetAggregatedTags = Spec.CapturedTargetTags.GetAggregatedTags();
	if (!TargetAggregatedTags )
	{
		constexpr bool bPrintToLog = true, bPrintToScreen = true, bDumpStackTrace = true;
		PRAXIS_DEBUG_ERROR(this, TEXT("TargetAggregatedTags is not valid"), bPrintToLog, bPrintToScreen, bDumpStackTrace);
		return 0.f;
	}

	FAggregatorEvaluateParameters EvaluateParameters;
	EvaluateParameters.TargetTags = TargetAggregatedTags;

	float Vitality = 0.f;
	GetCapturedAttributeMagnitude(VitalityDef, Spec, EvaluateParameters, Vitality);
	Vitality = FMath::Max(0.f, Vitality);

	const float BaseMaxHealth = UPraxisGameplayDeveloperSettings::Get()->GetBaseMaxHealth();
	const float HealthPerVitality = UPraxisGameplayDeveloperSettings::Get()->GetHealthPerVitality();

	return BaseMaxHealth + (Vitality * HealthPerVitality);
}

Praxis Gameplay Developer Settings in editor

Praxis Gameplay Developer Settings in C++

➕➕
PraxisGameplayDeveloperSettings.h
	/*
	* Base Character max health.
	*/
	UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Character Health", meta = (AllowPrivateAccess = true))
	float BaseMaxHealth = 100.f;

	/*
	* the amount of health added to the character per vitality point.
	*/
	UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Character Health", meta = (AllowPrivateAccess = true))
	float HealthPerVitality = 5.f;

	 /*
	* Base Character max stamina.
	*/
	 UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Character Stamina", meta = (AllowPrivateAccess = true))
	 float BaseMaxStamina = 100.f;

	 /*
	 * the amount of Stamina added to the character per Endurance point.
	 */
	 UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Character Stamina", meta = (AllowPrivateAccess = true))
	 float StaminaPerEndurance = 5.f;

	 /*
	 * Percentage of Max Stamina restored each regeneration interval.
	 */
	 UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Character Stamina", meta = (AllowPrivateAccess = true))
	 float StaminaRegenmultiplier = 0.02f;

	 /*
	 * Additional damage applied to critical hits.
	 * For example, a value of 0.5 increases critical hit damage by 50%.
	*/
	 UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Combat", meta = (AllowPrivateAccess = true))
	 float CriticalHitDamageBonus = 0.5f;


	 /*
	 * Critical hit chance gained per point of Luck.
	 * For example, a value of 0.01 grants +1% critical hit chance for each luck point.
	*/
	 UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Combat", meta = (AllowPrivateAccess = true))
	 float CriticalHitChancePerLuck = 0.01f;

	 /*
	 * Damage multiplier applied to heavy attacks.
	 * A value of 2.0 doubles the damage, 1.5 increases it by 50%, and 1.0 leaves it unchanged.
	*/
	 UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Combat", meta = (AllowPrivateAccess = true))
	 float HeavyAttackDamageMutiplier = 2.f;

	 /*
	 * Attack power gained per point of Strength.
	 * For example, a value of 0.02 grants +2% attack power for each Strength point.
	*/
	 UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Combat", meta = (AllowPrivateAccess = true))
	 float AttackPowerPerStrength = 0.02f;
×

Table of Contents