A Status Effect Stacking Algorithm

(Cross-posted on my Gamasutra blog, which usually gets more comments)

Status effects are a cool way to add strategic depth to both Tower Defense games and RPG's, and since  our upcoming title Defender's Quest is a hybrid of both, it's chock full of 'em, and unlike some RPG's, we took special care to make sure that they're not totally useless.

In Defender's Quest, each of your party members is a placeable "tower" in the battle system, but also a unique and persistent character who levels up and gains skills over the course of the entire game.  Some of these skills cause the defender's attacks to inflict status effects .

Here's a short list of fun things you can do to monsters:
  • Poison - X damage / second for Y seconds.
  • On Fire - X damage / second for Y seconds, cancelled by ice
  • Chilled - creep slowed by X% for Y seconds, cancelled by fire
  • Frozen - creep stopped for Y seconds, cancelled by fire
  • Stun - creep stopped for Y seconds
  • Bleed - creep takes X% extra damage for Y seconds
  • Confuse - creep walks backwards for X seconds
  • Blind - creep's attacks miss X% of the time for Y seconds
  • ...and many more!
Ice Mage freezing and slowing creeps
The Problem

As you upgrade these skills, both the potency and the duration of the status effect goes up.  Shortly after implementing this system, I ran into a dilemma - what do I do when two status effects hit the same enemy?  This problem was particularly interesting because it was both a technical and a design problem.

Every status effect has two main variables - potency, and duration.  Potency takes on a different meaning depending on the effect - for example, in poison, "potency" means damage per second (dps); for slow, it represents a speed multiplier.  Duration, however, always means how many seconds the effect lasts for.  So, stacking status effects is all about combining potency and duration in a way that:
  1. Is mathematically sound
  2. Doesn't look like a glitch
  3. Doesn't seem "unfair" 
  4. Doesn't lead to weird edge-case exploits
There's several unique cases to consider, and ideally, one algorithm should be able to cover all of them.  Let's go through the cases one by one as I step through my problem-solving methodology and come to a final decision.  

1. Different effect types

This is the easiest case. First, check to see if the new status effect "interacts" with whatever is already applied to the enemy. If there's no interaction, then just apply the second effect - ie, an enemy can be bleeding and poisoned at the same time.  However, if the enemy was on fire, and we just hit them with a chill (slow) spell, then we need to remove the fire first, since one of our rules is that fire/ice spells cancel each other.  If we hit them with a freeze (stop) spell however, we'd cancel the fire, reduce the freeze to chill, and then apply that.  

2. Same effect, identical stats

Let's say an archer with poisoned arrows hits an enemy, and then hits them again, inflicting the same status effect twice at different times.  Let's say the first poison effect (A) is 10 dps for 5 seconds, or (10dps | 5s) for short.  This will deal 50 damage over the life of the effect. One second later, the archer hits the same enemy with an identical poison effect (B), also (10dps | 5s).  In this case, the variables will be the same, so it's pretty easy to combine them, right?

Not quite - the enemy has already been walking for 1 second before the second poisoned shot landed, so (A) is now actually (10dps | 4s).  Even with identical starting stats, duration for any two effects will almost always be different in practice.

In a world where we can expect potencies to remain constant, the natural choice is to add durations together.  Not only is this simple and intuitive to the player, the alternative method of making potency increase while keeping duration constant could make dps effects like poison really difficult to balance.

So, in our above example, we just add another 5 seconds to the poison clock and now our creep is poisoned to the tune of (10dps | 9s).  All is well with the world!

3. Same effect, different stats

This is where it starts getting complicated.  What if two different archers with different poison stats hit the same enemy?  The prior example was easy because potencies matched, but here both numbers are different so simple addition is out the window.

A naive way to "combine" the values is just to average the two effects, but this fails to "conserve" the full power of both. Individually, the first effect will deal 40 damage total, and the second 50, for a total of 90.  Averaging both effects to a single (15 dps | 3.5s) will only yield 52.5 damage, not what we want at all!

So, whatever we do, combining the two needs to result in 90 total damage at the end of the day.  More generally, the PotencyDuration value of the combined effect must equal the sum of the PotencyDuration of each effect taken individually.  This principle, "conservation of PotencyDuration," should work in all cases, no matter what values the status effects have.

Conservation of PotencyDuration

Mathematically, the principle can be stated like this:


where (P: potency, D: duration, A, B: original effects, C: combined effect)

So, the first thing to do is redefine Poison A and Poison B in terms of PD values:

Now, we just have to convert the PD of Poison C back into potency and duration.  There's several approaches we can take here - do we want to match the highest duration of the two effects, or the highest potency, or something else entirely?  All it takes to satisfy equivalence is two values that multiply to make 90, but each approach will have unique subtle effects on gameplay and feedback that we need to consider as well.

1.  Match Highest Duration

We matched the duration of Poison B, the longest-lasting effect, and converted Poison A's PD of 40 into an extra 8 dps. This dilutes Poison A's potency by stretching it out over a longer time.  We're good, right?

Well, maybe not. 

Take a look at this -
here's what the player sees: 

Archer hits a creep

Creep takes 20 damage per second

Second archer hits the same creep
Creep takes 18 damage every second

What????  In the player's eyes, the additional poison shot now causes the creep to take less damage. Sure, everything is still mathematically hunky-dory, and the player isn't really being screwed over - the same total damage will be inflicted, just over a longer time.

However, I'm not sure any of that will be clear - I can easily see players refusing to let two archers have overlapping targets, thinking this is "better" strategically since they've noticed that mixing rangers results in "lower" poison damage.

This is even worse if we were dealing with a slow effect instead of a poison effect.  In this case, mage A casts slow (50% | 2s) on a creep, and then mage B hits the same creep with slow (30% | 10s).
What the player sees is: 

  1. One mage slows the enemy down
  2. Another mage hits it with a second slow spell
  3. The enemy speeds up!
In both cases Potency-Duration is conserved, but the player will be misled because their chief visual feedback is the size of the purple numbers bouncing off of the enemy, or how fast the creep is moving.

2. Match Highest Potency

In this case, we've made the combined effect match the potency of Poison A, the strongest effect, and converted Poison B's PD value into 20 dps for 4.5 seconds.  Instead of diluting Poison A, we're concentrating Poison B, squeezing it into a shorter time-frame so that the potency can match.  With this approach, the player will see no change in the amount of damage happening each turn, but the effect will last for longer thanks to the extra poisoned arrow.  The player will see the same amount of damage bouncing off the poisoned enemy every second, but each extra poisoned hit will keep the effect going a little bit longer.

Final Considerations
Before we conclude things, let's compare our solution to our original goals.

Is it mathematically sound?

Yep! We're conserving Potency-Duration, so everything's good here.

Does it look like a glitch?

Nope! By matching the highest potency, the player won't see enemies speeding up when hit by a slow spell, or poisoned arrows "reducing" poison damage.  Now, if an enemy is hit by a stronger status effect, they will see the enemy either slowing down even more, or taking more poison damage, but this seems like a natural result.

Does it seem unfair?

Generally this approach seems fair. In fact - it might be a gift to the player.  By concentrating weaker effects to match the potency of stronger ones, the player gets more of their effect's strength up-front.  Although the overall damage (in the case of poison) is the same over the effect's life-time, getting it done faster is almost always better in a tower defense game, since killing even one tile earlier can mean the difference between a close call and letting the creep reach the exit.

However, this "gift" to the player varies a bit with the effect.  A bleeding enemy takes more damage when hit.  This effect is more useful the longer it lasts, because there are more opportunities to hit the enemy and score bonus damage.  So, whereas poison becomes more effective with our algorithm, bleed becomes slightly less.  Something like slow isn't really affected - regardless of whether the slow spell is concentrated or stretched out, the enemy will reach the exit at the same time.  Given that the algorithm has a mix of positive, negative, and neutral effects on the various status effects, it seems like a good general approach.  Negatively impacted effects like bleed might need a slight balancing buff to make up the difference.

Does it lead to weird edge-case exploits?

The only exploit I can think of is this: grouping lower-level defenders with a single high-level one, who "primes" the enemy with a high-potency effect, thus allowing all the subsequent defender's effects to match that potency and add time to it.

Honestly, that sounds like a perfectly legit strategy to me, and I don't see it unbalancing the game.
Compared to the alternatives, this approach still seems the best.

Final Thoughts

There's one final approach we briefly considered and dismissed which I want to touch on again - allowing the potency to rise when mixing effects.  I decided against this one for several reasons: first, there's no natural, obvious formula for how much to raise the potency by.  Second, raising potency shortens the lifespan of the effect, which makes for less interesting gameplay.  Third, it's much harder to predict what would happen in game if effects can rise in power just by stacking, so it seems ripe for exploitation / wrecking balance.

I hope you've enjoyed this little exercise.  I found it interesting because it's a good example of a problem where technical and design needs overlap, and I hope it's useful to somebody in their next game.

If you have any ideas of your own, or can think of a better algorithm than the one I laid out here, do share your thoughts in the comments!