Gameplay Ability System·Combat Design·Game Design·Unreal Engine·Quod Combat Framework

How Enemies Decide to Dodge

How to implement enemy dodges with GAS and behavior trees: a probability attribute per reaction type, a roll-under check in a decorator, and a stacking gameplay effect that makes enemies harder to hit while you combo them.

When the player dodges it is a reaction, they see the attack coming and press the button. When an enemy dodges, someone had to decide that in code, and this decision can go wrong in two classic ways. If enemies react to your attacks reliably you get the famous input reader: the enemy that sidesteps the exact moment your attack becomes unstoppable ( Elden Ring players have made tons of YouTube videos complaining about this ). If they never dodge, then fighting them feels like hitting a training dummy. So the answer is somewhere in the middle: enemies roll dice, and we load the dice per enemy and per situation.

In this post I will show you how I build that middle ground in my Quod Combat Framework. This has almost nothing to do with the player dodge from the i-frames post. Enemy dodging is a separate and much simpler system, and the interesting part is where you store the numbers.

One thing to make clear before we start: everything here answers a single question, would this enemy evade this attack. Deciding if it is this enemy’s moment to act at all is a different problem, and it belongs to a director layer above the behavior tree: attack tokens, engagement slots, or whatever your game uses so that six enemies surrounding you do not all behave like duelists at the same time. That layer is not in this post. The dice below work the same with or without it, they simply roll less often when another system is deciding which enemies get to act.

Dodge chance is an attribute, not a config float

The most common place to put the dodge chance of an enemy is a float in the character config. My framework puts it 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)
    /* ... */
};

As you can see, you have one attribute per reaction type, so the whole set describes how defensive each enemy is. ( If attribute sets are new territory for you, start with How to Create Attribute Sets Using GAS. )

Why is this useful you ask? Because a config float can only be read, but an attribute can be modified by gameplay effects, with all the stacking and duration features that the Gameplay Ability System ( GAS ) already gives you for free. If a config float needs to change during gameplay, you end up writing a manager class. If an attribute needs to change during gameplay, you write a gameplay effect, and you will see in a moment how cheap that makes the cool stuff.

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 this lives in data next to their health and damage.

The roll lives in a behavior tree decorator

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;
}

There are three details in this function, and each one of them is there because at some point it caused a real bug:

The ceiling is 0.99999 and the comparison is strict. Both ends of the range bite you, and they bite in opposite directions. If the roll could reach 1.0, an enemy with DodgeProbability = 1.0 ( ex: a tutorial boss that must dodge your first attack ) would fail to dodge very occasionally, and you would spend a week trying to reproduce that bug. The other end is sneakier: FRandRange can return exactly its minimum, because FMath::FRand is rand() / (float)RAND_MAX and rand() does return 0 sometimes. So with Chance >= Roll, a zombie configured at DodgeProbability = 0.0 still dodges on the roll that comes back as exactly zero, roughly one in 32768 on Windows. Capping the roll just under 1 and comparing with > means that a probability of 1.0 always dodges and a probability of 0.0 never does, which is what the designer expects when they type those numbers.

The roll is throttled. Decorators get re-evaluated really aggressively ( flow control, observer aborts, tick… ), and a probability that is re-rolled sixty times per second stops being a probability, with enough rolls it will end up passing. The decorator in my framework stores the timestamp of the last evaluation in the node memory and returns the cached result during a configurable timeout window, so a 30% chance to dodge means 30% per decision instead of 30% per frame.

Passing the roll is not enough, the animation has to exist. The montage check may look like too much, but one day a new enemy type will reuse this tree without having a side dodge montage yet, it will pass the roll and it will stand there in idle while the ability fails silently. If the decorator fails instead, the tree simply picks another branch that the character can actually perform.

Pressure: the stacking effect that makes enemies harder to hit

Here is where putting the chance in an attribute pays off. Design wants enemies that become harder to hit while you are combo’ing them, so that mashing the same string into a boss works worse and worse. If your dodge chance is a config float, this becomes a whole subsystem: track the recent hits per enemy, decay the counter, expose a modified chance somewhere, remember to reset it. With GAS, it is one 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 four properties give you the whole behavior:

  • Every hit adds a stack, and every stack adds ( for example ) 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 duration of 1.5 seconds, so the clock measures the time since the last hit instead of the first one.
  • If you stop attacking for 1.5 seconds, the whole thing expires by itself. The baseline is restored, there is nothing to reset and there is no manager class owning state.
  • The magnitude per hit comes from the config of each enemy, and it defaults to zero. The zombie never gets harder to hit, the duelist ramps up quickly. So this stays opt-in per enemy, in data.

And the decorator from the previous section does not even know this effect exists. It keeps reading DodgeProbability like always, the attribute simply happens to be bigger right now. That is the nice part of having the reaction chances in an attribute set: we got enemies that adapt to pressure without writing any new system, just three standard GAS features pointing at the same attribute. ( The duration and stacking behavior I am relying on here is explained in Making Sense of Gameplay Effect Durations. )

The base values load the dice per enemy, this effect loads them per situation.

One design note about fairness, since the probability is there precisely to avoid the input reader: when you roll matters as much as how often you roll. Roll once, the moment the enemy commits to its decision, and do not keep re-rolling while your attack travels. An enemy that keeps re-rolling during your swing becomes an input reader again, sooner or later one of those rolls will pass at the worst possible moment for the player. So we roll once per decision, and the throttle from the decorator is what enforces this.

Executing the dodge without rolling off ledges or into walls

The dodge ability itself is simply a montage player with a movement payload. Most of the work is in the checks around it:

  • The direction is relative to the enemy’s own facing. The behavior tree task picks forward, back, left or right as a plain relative vector. The AI does not need camera math like the player does, “dodge back” means its own back, and the montage set is four directional steps.
  • No walking off ledges. The movement payload forbids falling off ledges during the dodge. If not you can bait enemies into rolling off a cliff, which is funny the first time, but after that it is just a broken encounter.
  • Check for obstacles before committing. An initial sweep in the dodge direction, and if there is a wall the dodge simply does not start. An enemy that dodges into a pillar and slides along it looks really broken, much more than an enemy that just ate the hit.

Nothing here is clever, and it does not need to be, the interesting decision already happened before, in the roll.

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

  • Roll under the attribute in a BT decorator with a strict Chance > FRandRange(0, 0.99999) check, so 1.0 always passes and 0.0 never does. Throttle the re-rolls through node memory so the chance is per decision instead of per frame, and fail the roll when the required montage does not exist, so the tree can pick a branch that the character can actually perform.
  • Put the reaction chances in an attribute set ( DodgeProbability and friends ) instead of in config floats. Attributes can be modified by gameplay effects, and that is what makes everything else in this post cheap to build.
  • Pressure is one stacking effect: HasDuration of 1.5s, AggregateByTarget, additive on DodgeProbability, with the per-hit magnitude coming from the config of each enemy and defaulting to zero. Enemies get harder to hit while you combo them, the ramp decays by itself, and the decorator never needs to know that any of this happened.

Keep reading

Building Souls-Style Input Buffering on Top of GAS

Building Souls-Style Input Buffering on Top of GAS

A buffered press is simply a struct, a gate tag and one drain function that an anim notify state calls.
MMCs and Executions in GAS: Count the Outputs, Not the Inputs

MMCs and Executions in GAS: Count the Outputs, Not the Inputs

If only one number changes you want an MMC, no matter how complex the formula. Only a hit that writes many attributes needs an execution.
Dodge i-Frames with GAS: Anim Notify Windows and Perfect Dodges

Dodge i-Frames with GAS: Anim Notify Windows and Perfect Dodges

The dodge ability grants no invulnerability at all. A notify on the montage does it, and you get perfect dodges almost for free.