Bots, Abilities and Balancing

Published by

on


I am thoroughly enjoying building and tweaking this combat system. I wanted to share some updates as I continue to work through getting this simulation to a point where I can start investigating genetic algorithms.

First up, Behavior Trees are Finicky

I mean, I knew this, but combined with a complex ability system? I had no idea how fragile these things can get. Part of this is obviously my greenness to the field but it made me appreciate quite a few things.

First, it’s insanely impressive how many micro-decisions one takes in a PvP battle (or even fighting NPCs). Let’s walk through some of them.

  1. How far am I from the enemy? Do I need to move closer for the ability I want?
  2. What abilities are available to me right now? Do I need to buff first?
  3. Who is targeting me, what is the team composition like that I am fighting, do I need to change my play style? Get closer? Move back?
  4. How much health do I have left?
  5. How much health does the enemy have? Is there an enemy with less health closer to me?
  6. How much mana do I have left, do I have enough for the spell I want to use? Is that spell in range, do I need to use a different spell and move closer/farther away?
  7. Is a teammate in trouble? Do I need to go rescue/save/heal them?
  8. Is there a weaker player (melee dps) that I should be targeting?
  9. Is their an AoE on the ground I need to avoid? Do I need to go into it for healing?
  10. And most importantly, for anyone who has done team pvp, WHY IS NO ONE TARGETING THE HEALER?

Again those are just some examples of behaviors I am trying to codify, here’s the ones I have:

Actions:

  • CastAbility – Cast an ability from a slot
  • MoveToTarget – Move toward the current target
  • MoveAwayFromTarget – Move away from target (kiting)
  • MaintainDistance – Keep a specific distance from target
  • Wait – Wait for a duration
  • SelectTarget – Select a target based on strategy
  • SelectAbility – Select an ability based on strategy
  • MoveForward – Move toward map center (patrol)

Conditions:

  • HasTarget – Check if there’s a valid target
  • IsInRange – Check if target is within range
  • CheckIfAbilityInRange – Check if ability can reach target
  • CheckResource – Check if owner has enough of a resource (Health, Shield, Mana, Stamina)
  • CheckTargetHealth – Check target’s health percentage
  • CheckBuffStatus – Check for buff/debuff presence
  • CheckCooldown – Check if ability slot is on/off cooldown
  • CheckCasting – Check if entity is currently casting
  • CheckEntityCount – Count entities matching filter in range
  • CheckTeamHealthAverage – Check average team health in radius
  • CheckThreatLevel – Check if owner is threatened (low health + enemies nearby)
  • CheckLineOfSight – Check line of sight to target
  • CheckDistanceToLocation – Check distance to a location
  • CompareSelfToTarget – Compare owner’s resource to target’s
  • CheckInZone – Check if standing in a ground zone
  • CheckObservedRole – Check if current target matches an observed role (Healer, MeleeDPS, Caster, RangedDPS, Utility, Tank)
  • SelectTargetByObservedRole – Select the best target matching a specific observed role

Using the combination of Actions & Conditions we can try to replicate various scenarios your average PvP player would find themselves in. What I didn’t really plan for was how a lot of those decisions are in direct conflict with one another.

Those last two I just added during this blog post but I haven’t implemented them into my bots yet.

Some bugs and how to debug them

Before I get into some silly BT bugs I had, first I can not recommend enough creating an observer to observe all actions the bots take. This really allowed me and Claude to dive into exactly why certain bots were failing.

Pasted image 20260118163525.png

This observer view is amazing because I can filter on different aspects of the bots, CC, Abilities, Projectiles etc.

One bug that took a bit of noodling was how I am creating the bots. I forgot that prefabs, when instantiated basically share the same entities. So when spawning two Tank bots, they’d end up sharing the same Brain and NodeState. This is obviously not good because now individual bots states are all intertwined and were causing all sorts of weird behaviors. The fix was two fold:

  1. Set NodeState to auto_override so NodeStates are unique instances
  2. When instantiating the brain, reference the brain type as a string and look it up to instantiate a unique instance. For some reason I couldn’t get auto_override to work with the brain, I’d always get the same flecs entity.
auto BrainPrefabEntity = World.lookup(Tree.BrainPrefab);
if (BrainPrefabEntity.is_valid())
{
Tree.Root = World.entity().is_a(BrainPrefabEntity);
}

OK the bugs

The first major bug was my bots just not attacking each other. This was due to spell abilities requiring a certain range, but bots like my ranger bot try to have a MaintainDistance:

// Maintain optimal bow range (15-25 units)
// Moves away if too close, moves toward if too far
prefab RB_MaintainRangeAction {
ActionNode: {Name: "MaintainDistance"}
MoveToTargetData: {MinDistance: 15.0, MaxDistance: 25.0}
auto_override | NodeState
}

Some abilities required a MaxDistance of < 15, so my bot would get stuck and not use any of their other abilities even if they were on cooldown. The fix was to add a new sequence to first see if they should use a close range attack, then move into the other parts of the tree. I also made all attack sequences check if the ability was in range first, causing the bot to move into range before casting.

// Combat options selector
prefab RB_RangerOptions {
Selector
auto_override | NodeState
// Priority 1: Use close-range/escape abilities when enemy is too close
CloseResponse: RB_CloseRangeResponse {}
Multi: RB_TryMultishot {}
Single: RB_SingleTargetShot {}
Efficient: RB_ConserveStamina {}
Position: RB_SafeMaintainRange {}
}

Dead bugs

This was an annoying one. I noticed my bots would fight, and then at the end of the match two bots on opposing teams would just sit there doing nothing. Checking my replay logs, I noticed many [Condition] HasTarget FAILED: Target is dead messages.

It turns out my bots were only running the ‘select target’ leaf once, then if they died they didn’t update because they were stuck in their Repeat sequence of checking abilities in range/attacking… a corpse.

Default target type == All

This one was just funny, I noticed melee tanks would always end up killing each other so both teams lost, which seemed suspect. Turns out this was due to me incorrectly setting the TargetType to All by default, meaning all attacks were if not specified set to hit everyone. With radial attacks, that means the casters would end up killing the enemy and themselves.

Not using all their abilities

I’m actually still encountering this one, and this is by far the most complex part of the BT implementation, it’s the abilities strategies:

enum class AbilitySelectionStrategy : uint8_t
{
FirstAvailable, // Pick first ability that's off cooldown and has resources
HighestDamage, // Pick ability with highest damage output
MostTargets, // Pick ability that can hit the most enemies (AoE)
HealingPriority, // Prioritize healing when allies are low
ExecuteThreshold, // Use execute abilities on low health targets
ResourceEfficient, // Pick ability with best damage per resource cost
Utility, // Prioritize utility/control abilities
LowestCooldown, // Pick ability with shortest cooldown
FastestCast, // Pick ability with shortest cast time
Defensive, // Prioritize self-shields, heals, and defensive abilities
DOTPriority, // Prioritize damage-over-time abilities
LongestRange, // Pick ability with longest range
Interrupt, // Prioritize stuns and interrupts
BuffPriority, // Prioritize beneficial buff abilities
Cleanse, // Prioritize debuff removal abilities
};

Each one of these strategies can be a bit complex as we also pass in contextual information:

// Cast highest damage spell
prefab Bot_CastHighestDamage {
ActionNode: {Name: "CastAbility"}
CastAbilityData: {
AbilitySlot: UseAbilityStrategy
Strategy: HighestDamage
ConsiderCooldowns: true
ConsiderResources: true
ConsiderRange: true
}
auto_override | NodeState
}

For example here’s the utility check:

case actions::AbilitySelectionStrategy::Utility:
if (AbilityEntity.has<combat::CachedAbilityScores>())
{
Score = AbilityEntity.get<combat::CachedAbilityScores>().UtilityScore;
}
else if (AbilityEntity.has<combat::OnHit>())
{
const auto& OnHit = AbilityEntity.get<combat::OnHit>();
for (uint8_t i = 0; i < OnHit.ModifierCount; ++i)
{
const auto& Mod = OnHit.Modifiers[i];
if (Mod.Modify == combat::ModifierType::Consciousness ||
Mod.Modify == combat::ModifierType::Speed ||
Mod.Modify == combat::ModifierType::MeleeMovement ||
Mod.Modify == combat::ModifierType::CastMovement ||
Mod.Modify == combat::ModifierType::Stealth)
{
Score += 100.0f;
}
else if (Mod.Modify == combat::ModifierType::PositionForward ||
Mod.Modify == combat::ModifierType::PositionToward ||
Mod.Modify == combat::ModifierType::PositionX)
{
Score += 50.0f;
}
}
}
break;

There’s lots of places for this to go wrong, or the tree to just not select a good spell, I’m still trying to figure out better ability selection so this part of the code is constantly changing as I review combat logs and match information:
Pasted image 20260118174025.png

Pasted image 20260118173953.png

Fun new conditions

While creating this post I realized I wanted to add the concept of bots observing an enemy player’s role. It will be important for bots to determine who they are attacking by watching what types of abilities they use:

// 1. Healer: Beneficial + positive health modifiers
if (OnHit.Meta.Category == combat::EffectCategory::Beneficial &&
HasPositiveHealthModifiers(OnHit))
{
IsHealer = true;
}
// 2. Tank: Has defensive modifiers (shields, immunities, damage reduction)
// Note: Self-targeted check removed since all roles may use self-buffs
else if (HasDefensiveModifiers(OnHit))
{
IsTank = true;
}
// 3. Utility: Has utility score (CC modifiers present)
else if (Scores && Scores->UtilityScore > 0)
{
IsUtility = true;
}
// 4-6. Damage dealers based on class and range
else if (Scores && Scores->TotalDamage > 0)
{
combat::DamageClass DmgClass = GetPrimaryDamageClass(OnHit);
float MaxRange = Scores ? Scores->MaxRange : 0.0f;
bool HasProjectile = HasShapes && HasProjectileShape(*Shapes);
// MeleeDPS: Damage + MaxRange <= 3.0 + physical
if (MaxRange <= 3.0f && IsPhysicalDamageClass(DmgClass))
{
IsMeleeDPS = true;
}
// Caster: Damage + magical class
else if (IsMagicalDamageClass(DmgClass))
{
IsCaster = true;
}
// RangedDPS: Damage + (MaxRange > 3.0 OR PROJECTILE) + physical
else if ((MaxRange > 3.0f || HasProjectile) && IsPhysicalDamageClass(DmgClass))
{
IsRangedDPS = true;
}
}
// ... //
// Now update observations outside the iteration
for (auto Observer : Observers)
{
auto& Data = Observer.ensure<player::CastObservation>(Caster);
if (IsHealer) Data.HealingCasts++;
if (IsTank) Data.TankCasts++;
if (IsUtility) Data.UtilityCasts++;
if (IsMeleeDPS) Data.MeleeDamageCasts++;
if (IsCaster) Data.CasterCasts++;
if (IsRangedDPS) Data.RangedDamageCasts++;
}

This allows us to monitor various information about what is being casted by visible enemies. Later we use this in a dead simple scoring algorithm to determine class type:

float GetRoleScore(const player::CastObservation& Data, ObservedRole Role)
{
uint32_t TotalCasts = Data.HealingCasts + Data.MeleeDamageCasts + Data.CasterCasts + Data.RangedDamageCasts + Data.UtilityCasts + Data.TankCasts;
if (TotalCasts == 0)
{
return 0.0f;
}
float RoleCasts = 0.0f;
switch (Role)
{
case ObservedRole::Healer:
RoleCasts = static_cast<float>(Data.HealingCasts);
break;
case ObservedRole::MeleeDPS:
RoleCasts = static_cast<float>(Data.MeleeDamageCasts);
break;
case ObservedRole::Caster:
RoleCasts = static_cast<float>(Data.CasterCasts);
break;
case ObservedRole::RangedDPS:
RoleCasts = static_cast<float>(Data.RangedDamageCasts);
break;
case ObservedRole::Utility:
RoleCasts = static_cast<float>(Data.UtilityCasts);
break;
case ObservedRole::Tank:
RoleCasts = static_cast<float>(Data.TankCasts);
break;
case ObservedRole::Any:
return 1.0f; // Any role always matches
}
return RoleCasts / static_cast<float>(TotalCasts);
}
// ... our BT Condition //
Status CheckObservedRole(const TickContext& Ctx, flecs::entity Node)
{
if (!Ctx.Owner.has<player::CastObservation>(Target))
{
Ctx.Observer.Add(Ctx.Owner.id(), "Condition", "CheckObservedRole FAILED",
std::format("No observations for target {}", Target.id()));
return Status::Failure;
}
const auto& Observation = Ctx.Owner.get<player::CastObservation>(Target);
float Score = GetRoleScore(Observation, Data.Role);
if (Score >= Data.MinScore)
{
return Status::Success;
}
// ... handle not the observed role ... //
}

Now when we create our bots BT trees we can add more intelligence to them to have them prefer targeting specific players (like healers, always target healers… unless DPS is low health…).

I’m still working on the best signals for determining if a bot is a tank, so that scoring is kind of broken at the moment.

One thing I haven’t added yet is the concept of a leader/follower configuration. One bot would be a dedicated team leader with some consensus protocol for takeover if they died. They’d then call targets, call out healing/retreat etc.

On to the balancing

By running the simulation with different group compositions, I’ve already seen some balancing problems, here’s the output of 5 different group comps, summarized:

============================================================
AGGREGATE SUMMARY
============================================================
By Archetype:
------------------------------------------------------------
Control Mage (n=6):
Avg Damage: 331.3 | Avg Healing: 0.0 | Avg Deaths: 0.67
Total Damage: 1988 | Total Healing: 0
Ability Usage:
FrostBolt: 35 total (5.8 avg/player)
IceShard: 34 total (5.7 avg/player)
GenericCombat (n=10):
Avg Damage: 399.4 | Avg Healing: 18.5 | Avg Deaths: 0.80
Total Damage: 3994 | Total Healing: 185
Ability Usage:
IceShard: 22 total (2.2 avg/player)
FrostBolt: 15 total (1.5 avg/player)
LifeDrain: 12 total (1.2 avg/player)
FlashHeal: 11 total (1.1 avg/player)
Impale: 11 total (1.1 avg/player)
MagicMissile: 11 total (1.1 avg/player)
LightningBolt: 8 total (0.8 avg/player)
Backstab: 8 total (0.8 avg/player)
RemoveNewest: 7 total (0.7 avg/player)
ThunderClap: 7 total (0.7 avg/player)
ShadowBolt: 7 total (0.7 avg/player)
BlessingOfSpeed: 6 total (0.6 avg/player)
VenomStrike: 6 total (0.6 avg/player)
DrainLife: 5 total (0.5 avg/player)
HealOverTime: 5 total (0.5 avg/player)
FrostNova: 4 total (0.4 avg/player)
Retreat: 4 total (0.4 avg/player)
Smite: 4 total (0.4 avg/player)
Shockwave: 4 total (0.4 avg/player)
SpellSteal: 3 total (0.3 avg/player)
FirestormZone: 2 total (0.2 avg/player)
IceSpear: 2 total (0.2 avg/player)
GroupHaste: 2 total (0.2 avg/player)
Apocalypse: 1 total (0.1 avg/player)
DivineHeal: 1 total (0.1 avg/player)
ExecuteStrike: 1 total (0.1 avg/player)
Resurrection: 1 total (0.1 avg/player)
Healer (n=10):
Avg Damage: 266.3 | Avg Healing: 90.2 | Avg Deaths: 0.50
Total Damage: 2663 | Total Healing: 902
Ability Usage:
HolyBolt: 104 total (10.4 avg/player)
FlashHeal: 102 total (10.2 avg/player)
ChainHeal: 52 total (5.2 avg/player)
HealingCircle: 2 total (0.2 avg/player)
Melee DPS (n=14):
Avg Damage: 330.8 | Avg Healing: 0.0 | Avg Deaths: 0.79
Total Damage: 4632 | Total Healing: 0
Ability Usage:
Cleave: 37 total (2.6 avg/player)
Impale: 31 total (2.2 avg/player)
BleedingStrike: 26 total (1.9 avg/player)
ExecuteStrike: 10 total (0.7 avg/player)
Ranged Mage (n=8):
Avg Damage: 707.8 | Avg Healing: 0.0 | Avg Deaths: 0.75
Total Damage: 5662 | Total Healing: 0
Ability Usage:
MagicMissile: 93 total (11.6 avg/player)
Fireball: 26 total (3.2 avg/player)
LightningBolt: 7 total (0.9 avg/player)
Ranger (n=2):
Avg Damage: 578.8 | Avg Healing: 0.0 | Avg Deaths: 0.50
Total Damage: 1158 | Total Healing: 0
Ability Usage:
ArrowShot: 21 total (10.5 avg/player)
PoisonDart: 12 total (6.0 avg/player)
Multishot: 7 total (3.5 avg/player)
Tank (n=14):
Avg Damage: 424.0 | Avg Healing: 0.0 | Avg Deaths: 0.86
Total Damage: 5936 | Total Healing: 0
Ability Usage:
HammerCrush: 72 total (5.1 avg/player)
GroundSmash: 63 total (4.5 avg/player)
Charge: 61 total (4.4 avg/player)
ThunderClap: 45 total (3.2 avg/player)
------------------------------------------------------------
By Strategy:
------------------------------------------------------------
random:
Total DMG: 3994 | Total HEAL: 185 | Deaths: 8
Top Abilities: IceShard:22, FrostBolt:15, LifeDrain:12, FlashHeal:11, Impale:11
classbased:
Total DMG: 3730 | Total HEAL: 261 | Deaths: 6
Top Abilities: MagicMissile:39, FlashHeal:22, HolyBolt:21, ChainHeal:12, Fireball:10
alltanks:
Total DMG: 4806 | Total HEAL: 0 | Deaths: 8
Top Abilities: HammerCrush:58, Charge:51, GroundSmash:51, ThunderClap:36
allhealers:
Total DMG: 2061 | Total HEAL: 610 | Deaths: 2
Top Abilities: FlashHeal:77, HolyBolt:77, ChainHeal:38
allmeleedps:
Total DMG: 3798 | Total HEAL: 0 | Deaths: 8
Top Abilities: Cleave:31, Impale:27, BleedingStrike:23, ExecuteStrike:9
allrangeddps:
Total DMG: 3546 | Total HEAL: 0 | Deaths: 7
Top Abilities: MagicMissile:33, ArrowShot:21, FrostBolt:16, IceShard:12, PoisonDart:12
balanced:
Total DMG: 4098 | Total HEAL: 30 | Deaths: 8
Top Abilities: IceShard:22, MagicMissile:21, FrostBolt:19, HolyBolt:6, Cleave:6

As you can see, our MeleeDPS class basically sucks, they have the worst damage output 330.8, and highest death rate. Given that melee dps is one of the most risky/dangerous characters to play, their DPS needs to match or exceed the highest damage output character (right now our ranged mage with 707.8 damage). Also all healers comp might be too strong. I think I need to implement a matrix of team comps into my running of strategies, that way we can see which comp is the strongest instead of just pitting the same comp against itself.

It is super cool to see this system already paying off and I still have tons of additional logic i need to add to my bots. I need to mostly focus on updating ability strategies to utilize more spell loadouts. Right now they still seem to prefer only 2-4 spells out of the 8 I assign them. I’m also not doing enough with buffing before combat and so forth. So many features to add!