Stats modifier system

I need character/item stats system with ability to modify stats during game.
For instance imagine Bow item with stats like Range, Damage, Reload Speed.
Those attributes can be modified during game with bonuses like: +20% more damage, +5 range.

So far my implementation looked like this:

public class FloatStat
    {
        protected float baseValue;
        protected float flat = 0f;
        protected float additive = 1f;
        protected float multiplicative = 1f;
        protected float value;

        // [...]

        public void AddFlat(float modifier)
        {
            flat += modifier;
            InvokeOnChanged();
        }

        public void RemoveFlat(float modifier)
        {
            flat -= modifier;
            InvokeOnChanged();
        }

        public void AddAdditive(float modifier)
        {
            additive += modifier;
            InvokeOnChanged();
        }

        public void RemoveAdditive(float modifier)
        {
            additive -= modifier;
            InvokeOnChanged();
        }

        public void AddMultiplicative(float modifier)
        {
            multiplicative *= modifier;
            InvokeOnChanged();
        }

        public void RemoveMultiplicative(float modifier)
        {
            multiplicative /= modifier;
            InvokeOnChanged();
        }

        public float CalculateValue(float v)
        {
            return (v + flat) * additive * multiplicative;
        }
    }

My plan is to add 3 dictionaries to keep modifiers with their owners, so they can be recalculated from scratch, plus to make debugging more easy.
However my question is - what is disadvantage of the current system, do you have experience with this kind of implementation?
For sure it is simple and fast, but it requires all modifiers to be removed like events (they must be removed anyway actually?).
So the only potential problem is that after adding and multiplying those numbers could lose precision, but is that actually the case? I think game would need to run quite long.

I personally would never implement this in a destructive fashion.

Occasionally you want the modifiers to exist as a stack, firstly because it’s a non-destructive way to add/remove each modifier and recalculate (& cache) the actual stat on the fly, secondly because sometimes we do care about non-stackable modifiers.

For stacking, you actually want each modifier to have a unique category, then you can set a rule to only consider the highest modifier of a certain category.

For example you have three items and a magic necklace. One of the items and a magic necklace affect the characters crit chance. The item does +5% and the necklace does +10%. These should not stack to +15% by design, because it’s usually futile to try and balance such behavior. The way this is usually constrained is to introduce a non-stackable category and only the best contribution is used.

This stat is evaluated as max(0.05, 0.15) = 15\%

Another example is having a limiting modifier, that doesn’t add or multiply, it simply guarantees that whatever happens to a category (or stat) doesn’t go below or above a certain threshold. So you can introduce a level-based modifier that prevents the player from crossing some design goal.

For example your level is 15 and this means your maximum attack chance is 24% (or whatever). You introduce a max modifier that is in the same category, but allow various items in the same category to stack freely (6 items each giving +2%, +6% for the character’s perk, +10% from the short sword and so on, amounting to 28%).

This stat is evaluated as min(6\times0.02+0.06+0.1, 0.24) = 24\%

Finally, stacks can combine in various ways. For example multipliers might come as a stack of their own, whereas additive modifiers are always applied first. Obviously this all depends on your actual game design, but let’s say you have a damage output stat which can be increased via flat improvements for having installed certain spaceship modules (+5 and +10), and you also get +3 because you’re using some special ammo at the moment, but then you also have a flat out +10% multiplier coming off some temporary power-up.

(5+10+3)\times1.1 = 19.8

Here the exact order of purchase should not matter. If you switch ammo to something else (that gives penalty) the outcome should be

(5+10-3)\times1.1 = 13.2

And if you now get another (non-stackable) flatout multiplier, the outcome should be

(5+10-3)\times max(1.1, 1.5)= 18

Next, you haggle in some orbital canteen to lower your prices for a new better module, and for this you roll dice to try and obtain a better price discount. The price should never go as low so that you earn money, but also mustn’t be so good that it exceeds a 50% discount. Again, highlighting the need for setting limits.

Now whether these examples fit your particular game is another topic, but hopefully I’ve illustrated why a non-destructive approach to modifiers can be better in a vast amount of cases.

Not to mention that you completely sidestep the issue with computational errors getting accumulated.

2 Likes

There are a million ideas how stats should be combined and what limits, if any, should exists but I one-hundred percent agree with the non-destructive comment made by orionsyndrome. That’s exactly what I do in my system. I do have a cache at the end of the chain so that I can just pull the final resulting value if nothing has changed (which turned out to not even be that necessary, but hey, goes to show sometimes I still optimize too early) but it’s very important that I can work my way up the whole stack chain. It also means you don’t have weird issues when you try to dynamically add and remove effects in different orders. Some of those operations can get convoluted quickly.

2 Likes

At first glance, I wouldn’t use floats as the type for modifiers, as this is a form of primitive obsession Instead, I would create a Modifier type that includes both the value and the type of the modifier.

Also I wouldn’t have the modifiers kept calculated in a value, but instead I would keep each one inside the stat, because eventually each modifier could have other fields except from its value like a reference to the object it came from.

However, while theory is one thing, implementation is a whole different beast. You can check out an excellent tutorial by @Kryzarel here https://discussions.unity.com/t/tutorial-character-stats-aka-attributes-system/682458 and he also offers a free asset on the Unity Asset Store with the code: https://assetstore.unity.com/packages/tools/integration/character-stats-106351

I’ve also created an extendable Stat system that includes basic modifier types and allows you to add custom types (e.g., only the largest modifier applies). You can explore the code in my GitHub repository: https://github.com/meredoth/Stat-System

Additionally, I’ve written blog posts explaining my thought process during its implementation and the subsequent refactoring to make it more extendable:
https://giannisakritidis.com/blog/Stat-System-Part1/ and
https://giannisakritidis.com/blog/Stat-System-Part2/

Although some aspects of the system have evolved since then, the codebase can be a good source of inspiration for your implementation of a stat system.

1 Like

This is the thing I don’t like in that system, plus to update the stat I need to remove the previous modifier, then add another one. With list/dictionary or similar thing I can just remove it and I don’t even need to know what number was that.
About limits I actually have min/max for them, but as @Sluggy1 said there are a lot of methods to calculate/stack stats. In any case good post, someone might find it useful in the future.

Well, addition and subtraction can be done in any order and I have constant formula to calculate stuff, but if I wanted to introduce some kind of order/priority, then it would be problem for sure.

Yeah I have seen that, however it’s actually overcomplicated as I don’t need to change priorities in this way, plus I try to reduce garbage. On top of that I stick to single formula, so I try to not overengineer :smiley:

In any case I got my answer - so far nobody sticks to such a simple system as there are various issues.
Thank you for input!