An enemy that dodges every attack can feel like it is reading your inputs. It sidesteps just when you can no longer stop the attack. Elden Ring players have made tons of videos about this. But if the enemy does not dodge at all, it can feel like a training dummy.
The player decides when to dodge by watching the attack and pressing a button. For the enemy we need to make that decision in code. I use a probability that changes per enemy and per situation, and this post goes over where I store it and when the AI rolls against it.
This is a separate, much simpler system than the player dodge from the i-frames post.
We are deciding whether this enemy would evade this attack. Deciding whether it should act at all belongs to a director layer above the behavior tree, using things like attack tokens or engagement slots. That keeps six enemies around you from all behaving like duelists at once. I am leaving that layer for another article; the probability checks below work with or without it.
Dodge chance as an attribute
The most common place to put the dodge chance of an enemy is a float in the character config. In my Quod Combat Framework it lives in an attribute set instead:
UCLASS()
class UQuodReactionsSet : public UAttributeSet
{
GENERATED_BODY()
public:
// 0..1 chance to dodge an incoming melee attack when the BT asks.
UPROPERTY(BlueprintReadOnly, ReplicatedUsing = OnRep_DodgeProbability)
FGameplayAttributeData DodgeProbability;
// Same pattern for the other reactions:
// BlockProbability, ParryProbability, ProjectileDodgeProbability...
ATTRIBUTE_ACCESSORS(UQuodReactionsSet, DodgeProbability)
/* ... */
};
You have one attribute per reaction type, so the whole set describes how defensive each enemy is. If attribute sets are new to you, start with How to Create Attribute Sets Using GAS.
A config float gives us a value to read. With an attribute, we can modify it through gameplay effects and use the stacking and duration features of the Gameplay Ability System ( GAS ). For changes during gameplay, this lets us write an effect instead of a manager class around the config value.
The base values come from the stat definition of the enemy, like any other attribute. So an agile duelist can ship with DodgeProbability = 0.4 and a zombie with 0.0, and all of it lives in data next to their health and damage.
The roll in the behavior tree
The dodge branch of the behavior tree is gated by a decorator that rolls against the attribute:
bool UBTDecorator_CanReactWithProbability::CalculateRawConditionValue(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const
{
const UAbilitySystemComponent* ASC = GetOwnerASC(OwnerComp);
if (!ASC)
{
return false;
}
const float Chance = ASC->GetNumericAttribute(
UQuodReactionsSet::GetDodgeProbabilityAttribute());
const float Roll = FMath::FRandRange(0.f, 0.99999f);
bool bPass = Chance > Roll;
if (bPass && RequiredMontageTag.IsValid())
{
// No dodge animation mapped for this character? Then this branch
// must fail so the tree picks something it can actually perform.
bPass = ::IsValid(GetMontageByTag(OwnerComp, RequiredMontageTag));
}
return bPass;
}
The 0.99999 ceiling works together with the strict comparison. If the roll could reach 1.0, an enemy with DodgeProbability = 1.0 could occasionally fail to dodge. Ex: a tutorial boss that has to dodge your first attack. That would be difficult to reproduce.
There is a problem at the other end too. FRandRange can return its minimum, as FMath::FRand is rand() / (float)RAND_MAX and rand() sometimes returns 0. With Chance >= Roll, a zombie with DodgeProbability = 0.0 would still dodge on a zero roll, roughly one in 32768 on Windows. Capping the roll below 1 and using > gives the expected behavior at both ends: 1.0 always dodges and 0.0 never does.
The full decorator also throttles the rolls. Decorators can be re-evaluated on tick, flow control or observer aborts. If we keep rolling sixty times per second, eventually a roll will pass. I store the last evaluation timestamp in node memory and return the cached result during a configurable timeout. That makes a 30% chance apply per decision rather than per frame.
The montage check handles an enemy reusing this tree before it has a side dodge montage. Without the check it could pass the roll, fail the ability and stand idle. Failing the decorator lets the tree choose another branch.
Pressure
We want enemies to become harder to hit during a combo, so repeatedly mashing the same string into a boss becomes less effective. With a config float we would need to track recent hits, decay the counter and reset it. Since the chance is an attribute, we can use a gameplay effect:
UGE_PressureDodgeBoost::UGE_PressureDodgeBoost()
{
DurationPolicy = EGameplayEffectDurationType::HasDuration;
DurationMagnitude = FGameplayEffectModifierMagnitude(FScalableFloat(1.5f));
StackingType = EGameplayEffectStackingType::AggregateByTarget;
FGameplayModifierInfo& Mod = Modifiers.AddDefaulted_GetRef();
Mod.Attribute = UQuodReactionsSet::GetDodgeProbabilityAttribute();
Mod.ModifierOp = EGameplayModOp::Additive;
// Per-hit bump. The framework reads this from the enemy's AI config
// through a mod magnitude calculation; it defaults to 0, so the ramp
// is opt-in per enemy.
Mod.ModifierMagnitude = FScalableFloat(0.05f);
}
The damage pipeline applies one stack of this to the victim on every hit it takes, and the behavior comes out of the effect properties:
- Every hit adds a stack, and every stack adds 0.05 to DodgeProbability. Five hits into your combo and the enemy is rolling 0.25 above its baseline.
- Each stack is a fresh application, which refreshes the 1.5 seconds duration, so the clock measures the time since the last hit.
- If you stop attacking for 1.5 seconds the effect expires by itself. The baseline is back and there is nothing to reset.
- The magnitude per hit comes from the config of each enemy and it defaults to zero, so the zombie does not get harder to hit and the duelist ramps up quickly. This stays opt-in per enemy, in data.
The decorator keeps reading DodgeProbability as before, so no changes are needed there. I explain this duration and stacking behavior in Making Sense of Gameplay Effect Durations.
About fairness, when you roll matters as much as how often. Roll once, when the enemy commits to its decision, and do not keep re-rolling while your attack travels, if not one of those rolls will pass at the worst moment for the player and you are back to the input reader. The throttle from the decorator is what enforces this.
Executing the dodge
The dodge ability itself is a montage player with a movement payload, most of the work is in the checks around it.
The direction is relative to the facing of the enemy. The behavior tree task picks forward, back, left or right as a plain relative vector, no camera math like the player needs, and the montage set is four directional steps.
The movement payload forbids falling off ledges during the dodge. Otherwise you can bait enemies into rolling off a cliff, which can break the encounter.
Before committing we also sweep in the dodge direction. If there is a wall, the dodge does not start. I prefer the enemy taking the hit to dodging into a pillar and sliding along it.
If you want to go deeper into decision architectures for combat AI, and into the director layer I mentioned at the start, the free chapters at gameaipro.com are the best library I know.
Recap
- Put the reaction chances in an attribute set instead of config floats, so gameplay effects can modify them.
- Roll under the attribute in a BT decorator with
Chance > FRandRange(0, 0.99999), throttle the re-rolls through node memory, and fail the roll if the montage does not exist. - Pressure is one stacking effect: HasDuration 1.5s, AggregateByTarget, additive on DodgeProbability, with the per-hit magnitude coming from each enemy’s config and defaulting to zero.


