Sylva Targeting System Technical Highlights
Sylva Targeting System is part of the Sylva Game Frameworks, a collection of gameplay systems and tools I’m independently developing for Unreal Engine.
It is a modular Unreal Engine targeting system plugin designed to provide flexible and reusable target acquisition.
The plugin is actively developed and expanded as new gameplay requirements arise. It currently supports multiple targeting trace shapes, Gameplay Tag-based target filtering, multiplayer prediction, and server-side validation, with an architecture designed to make additional targeting features easy to integrate as needed.
Targeting Traces
The system currently supports three trace shapes:
- Sphere
- Capsule
- Box
Each trace can be configured with its own dimensions and debug visualization settings. Trace settings also define the start/end locations, object types to query, and actors to ignore.
FTargetingTraceSetting provides a common interface for configuring different trace shapes while keeping the shape specific settings separate.
The active trace settings can be retrieved using FTargetingTraceSetting::GetActiveShapeTraceSettings(). This returns an FInstancedStruct containing the settings for the currently selected trace shape, which can then be accessed by casting it to the selected trace settings type.
FTargetingTraceSetting
USTRUCT(BlueprintType)
struct FTargetingTraceSetting
{
GENERATED_BODY()
FTargetingTraceSetting() = default;
FTargetingTraceSetting(ETargetingTraceShape InTraceShape) : TraceShape(InTraceShape) {}
UPROPERTY(Transient, BlueprintReadWrite) TArray<TEnumAsByte<EObjectTypeQuery>> TargetObjectTypes;
UPROPERTY(Transient, BlueprintReadWrite) TArray<AActor*> ActorsToIgnore;
UPROPERTY(Transient, BlueprintReadWrite) FVector TraceStartLocation = FVector::ZeroVector;
UPROPERTY(Transient, BlueprintReadWrite) FVector TraceEndLocation = FVector::ZeroVector;
UPROPERTY(Transient, BlueprintReadWrite, meta = (AllowPrivateAccess = true))
FSphereTraceSetting SphereTraceSetting;
UPROPERTY(Transient, BlueprintReadWrite, meta = (AllowPrivateAccess = true))
FBoxTraceSetting BoxTraceSetting;
UPROPERTY(Transient, BlueprintReadWrite, meta = (AllowPrivateAccess = true))
FCapsuleTraceSetting CapsuleTraceSetting;
/*
* Returns an FInstancedStruct containing the active shape trace settings.
*/
FInstancedStruct GetActiveShapeTraceSettings() const
{
switch (TraceShape)
{
case ETargetingTraceShape::Sphere:
return FInstancedStruct::Make(SphereTraceSetting);
case ETargetingTraceShape::Capsule:
return FInstancedStruct::Make(CapsuleTraceSetting);
case ETargetingTraceShape::Box:
return FInstancedStruct::Make(BoxTraceSetting);
default:
return FInstancedStruct();
}
}
void SetActiveTraceShape(ETargetingTraceShape InTraceShape)
{
TraceShape = InTraceShape;
}
bool IsValidTraceShape() const
{
return TraceShape != ETargetingTraceShape::None;
}
private:
UPROPERTY(Transient, BlueprintReadWrite, meta = (AllowPrivateAccess = true))
ETargetingTraceShape TraceShape = ETargetingTraceShape::None;
};
USylvaTargetingComponent::PerformTargetingTrace()
FOutcome USylvaTargetingComponent::PerformTargetingTrace(const FTargetingTraceSetting& TargetingTraceSetting, TArray<FHitResult>& OutHitResults)
{
if (!TargetingTraceSetting.IsValidTraceShape())
{
return FOutcome::Failure(FString::Printf(TEXT("TraceShape is invalid. Targeting trace failed for actor [%s]."), *GetNameSafe(GetOwner())));
}
FInstancedStruct TraceTypeSettings = TargetingTraceSetting.GetActiveShapeTraceSettings();
if (!TraceTypeSettings.IsValid())
{
return FOutcome::Failure(FString::Printf(TEXT("Invalid TraceTypeSettings. Targeting trace failed for actor [%s]"), *GetNameSafe(GetOwner())));
}
if (TargetingTraceSetting.TargetObjectTypes.IsEmpty())
{
return FOutcome::Failure(FString::Printf(TEXT("No ObjectTypes provided. Targeting trace failed for actor [%s]"), *GetNameSafe(GetOwner())));
}
constexpr bool bTraceComplex = false;
constexpr bool bIgnoreSelf = true;
bool bPerformedTrace = false;
const FVector& TraceStartLocation = TargetingTraceSetting.TraceStartLocation;
const FVector& TraceEndLocation = TargetingTraceSetting.TraceEndLocation;
const TArray<TEnumAsByte<EObjectTypeQuery>>& ObjectTypes = TargetingTraceSetting.TargetObjectTypes;
const TArray<AActor*>& ActorsToIgnore = TargetingTraceSetting.ActorsToIgnore;
if (const FSphereTraceSetting* SphereTraceSetting = TraceTypeSettings.GetPtr<FSphereTraceSetting>())
{
const float Radius = SphereTraceSetting->SphereRadius;
EDrawDebugTrace::Type DebugTrace = GetTargetingTraceDebugResolved(SphereTraceSetting->TraceDebugSetting.TraceDebugDrawMode);
const FLinearColor& TraceColour = SphereTraceSetting->TraceDebugSetting.TraceColour;
const FLinearColor& TraceHitColour = SphereTraceSetting->TraceDebugSetting.TraceHitColour;
const float Duration = SphereTraceSetting->TraceDebugSetting.Duration;
UKismetSystemLibrary::SphereTraceMultiForObjects(this, TraceStartLocation, TraceEndLocation, Radius, ObjectTypes,
bTraceComplex, ActorsToIgnore, DebugTrace, OutHitResults, bIgnoreSelf, TraceColour, TraceHitColour, Duration);
bPerformedTrace = true;
}
else if (const FCapsuleTraceSetting* CapsuleTraceSetting = TraceTypeSettings.GetPtr<FCapsuleTraceSetting>())
{
const float Radius = CapsuleTraceSetting->CapsuleRadius;
const float HalfHeight = CapsuleTraceSetting->CapsuleHalfHeight;
EDrawDebugTrace::Type DebugTrace = GetTargetingTraceDebugResolved(CapsuleTraceSetting->TraceDebugSetting.TraceDebugDrawMode);
const FLinearColor& TraceColour = CapsuleTraceSetting->TraceDebugSetting.TraceColour;
const FLinearColor& TraceHitColour = CapsuleTraceSetting->TraceDebugSetting.TraceHitColour;
const float Duration = CapsuleTraceSetting->TraceDebugSetting.Duration;
UKismetSystemLibrary::CapsuleTraceMultiForObjects(this, TraceStartLocation, TraceEndLocation, Radius, HalfHeight, ObjectTypes,
bTraceComplex, ActorsToIgnore, DebugTrace, OutHitResults, bIgnoreSelf, TraceColour, TraceHitColour, Duration);
bPerformedTrace = true;
}
else if (const FBoxTraceSetting* BoxTraceSetting = TraceTypeSettings.GetPtr<FBoxTraceSetting>())
{
const FVector& BoxExtent = BoxTraceSetting->BoxExtent;
const FRotator& Orientation = BoxTraceSetting->BoxOrientation;
EDrawDebugTrace::Type DebugTrace = GetTargetingTraceDebugResolved(BoxTraceSetting->TraceDebugSetting.TraceDebugDrawMode);
const FLinearColor& TraceColour = BoxTraceSetting->TraceDebugSetting.TraceColour;
const FLinearColor& TraceHitColour = BoxTraceSetting->TraceDebugSetting.TraceHitColour;
const float Duration = BoxTraceSetting->TraceDebugSetting.Duration;
UKismetSystemLibrary::BoxTraceMultiForObjects(this, TraceStartLocation, TraceEndLocation, BoxExtent, Orientation, ObjectTypes,
bTraceComplex, ActorsToIgnore, DebugTrace, OutHitResults, bIgnoreSelf, TraceColour, TraceHitColour, Duration);
bPerformedTrace = true;
}
return bPerformedTrace ? FOutcome::Success () : FOutcome::Failure(TEXT("Failed to perform targeting trace."));
}
The Target() function handles the complete target acquisition. It first validates the requested target group, performs the configured targeting trace, filters the resulting hits using the requested Gameplay Tag, and selects the nearest valid target.
When called by a client, the targeting request is also sent to the server through the Server_Target() Server RPC. The server validates the trace settings before performing its own targeting trace and independently determining the authoritative target.
Once the server has determined the result, it sends the target information back to the client through the Client_ProcessTargetResult() Client RPC. This keeps the targeting result server-authoritative while allowing the client to perform the initial targeting locally for responsive gameplay.
Once a valid target has been acquired, the OnTargetingStarted delegate is broadcast with the FTargetableActorInfo for the selected target.
This allows the system’s caller to react to targeting without coupling those behaviours to the targeting component. For example, a gameplay system can use the event to trigger camera behaviour, UI updates, target indicators, animation changes, or other gameplay effects.
The OnTargetingStopped delegate is also available when targeting ends.
Both C++ delegates and Blueprint assignable events are provided, allowing the targeting system to be integrated with either C++ or Blueprint gameplay code.
Target Acquisition & Multiplayer Validation
Gameplay Tags are used to filter targets by target type.
For example, a game may have multiple types of hostile enemies:
- Target.Hostile.Melee
- Target.Hostile.Ranged
- Target.Hostile.Boss
When targeting, you can specify which target group the system should search for. This allows the same targeting system to target, for example, only hostile melee enemies.
This keeps target selection data-driven and allows new target types to be introduced through Gameplay Tags without changing the core targeting logic.
FOutcome USylvaTargetingComponent::Target(const FGameplayTag& TargetGroup, const FTargetingTraceSetting& TargetingTraceSetting)
{
if (!TargetGroup.IsValid())
{
constexpr bool bPrintToLog = true, bPrintToScreen = true, bDumpStackTrace = true;
SYLVA_TARGETING_DEBUG_ERROR(this, TEXT("Target called but TargetGroup is not valid"), bPrintToLog, bPrintToScreen, bDumpStackTrace);
return FOutcome::Failure(TEXT("Targeting failed: TargetGroup is not valid."));
}
TArray<FHitResult> HitResults;
const FOutcome TraceResult = PerformTargetingTrace(TargetingTraceSetting, HitResults);
if (TraceResult.IsFailure())
{
SYLVA_TARGETING_LOG_OUTCOME(this, TraceResult, true, true, true);
return TraceResult;
}
TArray<AActor*> CandidateActors;
GetTargetableActorsFromHitResults(HitResults, TargetGroup, CandidateActors);
float Distance = 0.f;
AActor* NearestActor = UGameplayStatics::FindNearestActor(GetOwner()->GetActorLocation(), CandidateActors, Distance);
CurrentTargetedActorInfo = MakeTargetableActorInfoFromActor(NearestActor);
if (!OwnerHasAuthority())
{
Server_Target(TargetGroup, TargetingTraceSetting);
}
if (!CurrentTargetedActorInfo)
{
return FOutcome::Failure(FString::Printf(TEXT("[%s] failed to find a valid targetable actor."), *GetNameSafe(GetOwner())));
}
BroadcastTargetStarted(CurrentTargetedActorInfo);
return FOutcome::Success();
}
void USylvaTargetingComponent::Server_Target_Implementation(const FGameplayTag& TargetGroup, const FTargetingTraceSetting& TargetingTraceSetting)
{
if (!TargetGroup.IsValid())
{
constexpr bool bPrintToLog = true, bPrintToScreen = true, bDumpStackTrace = true;
SYLVA_TARGETING_DEBUG_ERROR(this, TEXT("Target called but TargetGroup is not valid"), bPrintToLog, bPrintToScreen, bDumpStackTrace);
return;
}
TArray<FHitResult> HitResults;
const FOutcome TraceResult = PerformTargetingTrace(TargetingTraceSetting, HitResults);
if (TraceResult.IsFailure())
{
SYLVA_TARGETING_LOG_OUTCOME(this, TraceResult, true, true, true);
return;
}
TArray<AActor*> CandidateActors;
GetTargetableActorsFromHitResults(HitResults, TargetGroup, CandidateActors);
float Distance = 0.f;
AActor* NearestActor = UGameplayStatics::FindNearestActor(GetOwner()->GetActorLocation(), CandidateActors, Distance);
CurrentTargetedActorInfo = MakeTargetableActorInfoFromActor(NearestActor);
Client_ProcessTargetResult(CurrentTargetedActorInfo);
if (!CurrentTargetedActorInfo)
{
return;
}
BroadcastTargetStarted(CurrentTargetedActorInfo);
}
bool USylvaTargetingComponent::Server_Target_Validate(const FGameplayTag& TargetGroup, const FTargetingTraceSetting& TargetingTraceSetting)
{
const FOutcome TraceSettingValidationResult = ValidateTraceSetting(TargetingTraceSetting);
if (TraceSettingValidationResult.IsFailure())
{
constexpr bool bPrintToLog = true, bPrintToScreen = true, bDumpStackTrace = true;
SYLVA_TARGETING_DEBUG_ERROR(this, TraceSettingValidationResult.GetMessage(), bPrintToLog, bPrintToScreen, bDumpStackTrace);
return false;
}
return true;
}
Because the client provides the targeting request, the server validates the trace settings before performing the authoritative targeting trace.
ValidateTraceSetting() verifies that:
- The validation is performed on the server.
- The trace shape is valid.
- Trace locations do not contain invalid NaN values.
- The requested trace distance does not exceed the configured maximum.
- Shape dimensions fall within the configured minimum and maximum limits.
- The supplied dimensions are valid for the selected trace shape.
The validation limits are configurable through FTraceValidationRules, allowing the acceptable targeting parameters to be adjusted without changing the validation logic.
FOutcome USylvaTargetingComponent::ValidateTraceSetting(const FTargetingTraceSetting& TargetingTraceSetting) const
{
if (!OwnerHasAuthority())
{
return FOutcome::Failure(TEXT("ValidateTraceSetting is a server only function and must only be called on the server"));
}
if (!TargetingTraceSetting.IsValidTraceShape())
{
return FOutcome::Failure(TEXT("Trace shape is invalid"));
}
const FVector& TraceStartLocation = TargetingTraceSetting.TraceStartLocation;
const FVector& TraceEndLocation = TargetingTraceSetting.TraceEndLocation;
if (TraceStartLocation.ContainsNaN())
{
return FOutcome::Failure(FString::Printf(TEXT("[%s] Trace start contains NaN | TraceStartLocation: %s"), *GetNameSafe(GetOwner()), *TraceStartLocation.ToString()));
}
if (TraceEndLocation.ContainsNaN())
{
return FOutcome::Failure(FString::Printf(TEXT("[%s] Trace end contains NaN | TraceStartLocation: %s"), *GetNameSafe(GetOwner()), *TraceEndLocation.ToString()));
}
const float MaxTraceDistance = TraceValidationRules.MaxTraceDistance;
const float TraceDistanceSq = FVector::DistSquared(TraceStartLocation, TraceEndLocation);
if (TraceDistanceSq > FMath::Square(MaxTraceDistance))
{
return FOutcome::Failure(FString::Printf(TEXT("[%s] Trace distance exceeds maximum allowed distance [used: %f] [max allowed: %f]"),
*GetNameSafe(GetOwner()), FMath::Sqrt(TraceDistanceSq), MaxTraceDistance));
}
const FInstancedStruct ActiveShapeTraceSettings = TargetingTraceSetting.GetActiveShapeTraceSettings();
const float MaxShapeDimension = TraceValidationRules.MaxShapeDimension;
const float MinShapeDimension = TraceValidationRules.MinShapeDimension;
const FString OwnerName = GetNameSafe(GetOwner());
auto MakeDimensionFailureMessage =
[OwnerName, MinShapeDimension, MaxShapeDimension] (const FString& Shape, const FString& ShapeInfo = TEXT("")) -> FString
{
return FString::Printf(TEXT("[%s] Trace dimensions are invalid based on 'TraceValidationRules' defined in the CDO (Rules: Min = %.2f Max = %.2f) | Shape: %s | %s"),
*OwnerName, MinShapeDimension, MaxShapeDimension, *Shape, *ShapeInfo);
};
FString DimensionFailureMessage{};
bool bIsDimensionValid = false;
bool bFoundShape = false;
if (const FSphereTraceSetting* SphereTraceSetting = ActiveShapeTraceSettings.GetPtr<FSphereTraceSetting>())
{
bFoundShape = true;
bIsDimensionValid = SphereTraceSetting->SphereRadius >= MinShapeDimension && SphereTraceSetting->SphereRadius <= MaxShapeDimension;
DimensionFailureMessage = MakeDimensionFailureMessage(TEXT("Sphere"), FString::Printf(TEXT("used radius = %.2f"),SphereTraceSetting->SphereRadius));
}
else if (const FCapsuleTraceSetting* CapsuleTraceSetting = ActiveShapeTraceSettings.GetPtr<FCapsuleTraceSetting>())
{
bFoundShape = true;
const bool bIsRadiusValid = CapsuleTraceSetting->CapsuleRadius <= MaxShapeDimension && CapsuleTraceSetting->CapsuleRadius >= MinShapeDimension;
const bool bIsHalfHeightValid = CapsuleTraceSetting->CapsuleHalfHeight <= MaxShapeDimension && CapsuleTraceSetting->CapsuleHalfHeight >= MinShapeDimension;
bIsDimensionValid = bIsRadiusValid && bIsHalfHeightValid;
DimensionFailureMessage = MakeDimensionFailureMessage(TEXT("Capsule"), FString::Printf(TEXT("Uesed Values: Radius = %.2f | HalfHeight = %.2f"),CapsuleTraceSetting->CapsuleRadius, CapsuleTraceSetting->CapsuleHalfHeight));
}
else if (const FBoxTraceSetting* BoxTraceSetting = ActiveShapeTraceSettings.GetPtr<FBoxTraceSetting>())
{
bFoundShape = true;
bIsDimensionValid = BoxTraceSetting->BoxExtent.GetMax() <= MaxShapeDimension && BoxTraceSetting->BoxExtent.GetMin() >= MinShapeDimension;
const FVector Extent = BoxTraceSetting->BoxExtent;
DimensionFailureMessage = MakeDimensionFailureMessage(TEXT("Box"),FString::Printf(TEXT("Used extent: X = %.2f, Y = %.2f, Z = %.2f"),Extent.X,Extent.Y,Extent.Z));
}
if (!bFoundShape)
{
return FOutcome::Failure(TEXT("No valid trace shape was found in the instanced settings"));
}
return bIsDimensionValid ? FOutcome::Success() : FOutcome::Failure(DimensionFailureMessage);
}