The Architectural Flaw of Montage-Driven Parries
When building hardcore action combat systems—such as our work on Project Ronin here at Outlore Interactives—combat feel hinges on millisecond-accurate arbitration rather than loose montage booleans. The standard blueprint-centric approach to parrying relies heavily on Animation Notifies (AnimNotifies) to toggle boolean states like IsParrying on or off. In practice, this architecture is fundamentally brittle. If frame rates dip, or if network jitter introduces latency in multiplayer environments, AnimNotifies can easily fire late, skip frames entirely, or misalign with the actual incoming hit trace. Read technical details on outlore game studio project ronin. Reference: Why UE5 Live Coding Crashes When You Edit Header Files.
At Outlore, we reject solutions that bind authoritative combat resolution directly to rendering pipelines. When you tie your defensive window to an animation montage, a stuttering frame rate means your parry window literally shifts in time. Instead, we frame the parry window as an explicit, authoritative timestamp state machine housed within a dedicated C++ ActorComponent. This decouples the visual feedback of the animation from the mathematical reality of the combat arbitration. Read technical details on project ronin action combat souls-like.
Designing the Authoritative Parry Component
To achieve sub-frame accuracy without bloating your system with unnecessary overhead, you need a lightweight C++ component that tracks activation time using engine timestamps. Rather than relying on fragile tick-based decrementing flags, we record the exact floating-point timestamp when the parry is initiated via GetWorld()->GetTimeSeconds(). Read technical details on unity c++ engine garbage collection custom engine.
UCLASS(ClassGroup=(Combat), meta=(BlueprintSpawnableComponent))
class UOutloreCombatComponent : public UActorComponent
{
GENERATED_BODY()
public:
UOutloreCombatComponent();
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Combat|Parry")
float ParryWindowDuration = 0.15f;
UFUNCTION(BlueprintCallable, Category = "Combat|Parry")
void TriggerParry();
UFUNCTION(BlueprintPure, Category = "Combat|Parry")
bool IsInParryWindow() const;
bool EvaluateIncomingAttack(AActor* Attacker, const FVector& HitLocation);
private:
double ParryStartTime = -1.0;
};By storing ParryStartTime as a double-precision float, we avoid floating-point drift over long play sessions. Checking whether the defender is actively parrying becomes a simple, deterministic math operation comparing the current world time against the activation threshold. Read technical details on parry mechanics hit-stop input buffering combat design.
bool UOutloreCombatComponent::IsInParryWindow() const
{
if (ParryStartTime < 0.0)
{
return false;
}
const double CurrentTime = GetWorld()->GetTimeSeconds();
return (CurrentTime - ParryStartTime) <= static_cast<double>(ParryWindowDuration);
}A robust souls-like parry system cannot accept attacks from any angle. If a player is facing north, they shouldn't parry an enemy striking them from behind. We enforce spatial validity by calculating the dot product between the defender's normalized forward vector and the directional vector pointing from the defender to the attacker.
bool UOutloreCombatComponent::EvaluateIncomingAttack(AActor* Attacker, const FVector& HitLocation)
{
if (!IsInParryWindow() || !Attacker)
{
return false;
}
AActor* OwnerActor = GetOwner();
if (!OwnerActor)
{
return false;
}
// Calculate normalized direction vector from defender to attacker
FVector DefenderForward = OwnerActor->GetActorForwardVector().GetSafeNormal();
FVector ToAttacker = (Attacker->GetActorLocation() - OwnerActor->GetActorLocation()).GetSafeNormal();
// Dot product: 1.0 = direct hit front, 0.0 = perpendicular, -1.0 = direct rear
float DirectionDot = FVector::DotProduct(DefenderForward, ToAttacker);
// Threshold of 0.5 roughly corresponds to a 60-degree frontal cone
constexpr float FrontalParryThreshold = 0.5f;
if (DirectionDot < FrontalParryThreshold)
{
// Attack is outside the parry cone; process as standard block or hit
return false;
}
// Parry successful: Trigger riposte state or stun attacker
ParryStartTime = -1.0; // Consume the parry window
return true;
}Handling Time Dilation and Hit-Stop Robustness
Hardcore action games rely heavily on hit-stop (brief pauses in time on impact) and custom time dilation to sell the weight of heavy weapon clashes. If your parry component relies on regular tick intervals or standard delta times, hit-stop can cause timers to desynchronize or expire prematurely.
Because GetWorld()->GetTimeSeconds() returns unscaled real-world relative time depending on how you configure your world settings, high-performance systems should evaluate windows using game-thread execution counts or custom delta times scaled via CustomTimeDilation. Keeping your timestamp checks independent of visual animation ticks ensures that even when the game engine enters a heavy hit-stop frame freeze, your parry arbitration remains mathematically sound.
Decoupling Animation from Combat Arbitration
The golden rule of building responsive melee combat in Unreal Engine is that code commands data, and data commands animation—never the reverse. When a player presses the parry input, the input handler immediately calls TriggerParry() on the C++ combat component. Only after the C++ state transitions do we fire an event to play the corresponding animation montage.
If the animation fails to load, stutters, or gets interrupted by network prediction corrections, the underlying combat state machine remains completely intact. This architecture guarantees that player inputs are never swallowed by rendering bottlenecks.