Character Stats/Attributes in Unity (pt. 1) — Keeping Track of Stat Bonuses

Kryzarel
5 min readDec 1, 2017

--

Stats like Strength, Intelligence, Dexterity, have become staples in many games. And when we’re getting +5 Strength from an item, -10% from a debuff and +15% from a talent, it’s nice to have a system that keeps track of all those modifiers in an organized fashion.

Final Fantasy stat sheet

Before starting, gotta give credit where credit is due, my implementation was heavily inspired by this post. It’s a Flash tutorial with all code written in ActionScript, but in any case, the concept is great.

Video version of this tutorial

For the basics of this system we need a class that represents a Stat, with a variable for the Base Stat Value, and a list to store all the individual Modifiers that have been applied to that Stat:

We also need a class to represent the Stat Modifiers:

What is readonly?
When a variable is declared as readonly, you can only assign a value to it during its declaration or inside the constructor of its class. The compiler will throw an error otherwise. This prevents us from unintentionally doing things like:
statModifiers = null;

statModifiers = new List<StatModifier>();
If at any moment we need a fresh empty list, we can always just call statModifiers.Clear().

Note how none of these classes derive from MonoBehaviour. We won’t be attaching them to game objects.

Even though stats are usually whole numbers, I’m using float instead of int because when applying percentage bonuses, we can easily end up with decimal numbers. This way, the stat exposes the value more accurately, and then we can do whatever rounding we see fit. Besides, if we actually do want a stat that is not a whole number, it’s already covered.

Now we need to be able to add and remove modifiers to/from stats, calculate the final value of the stat (taking into account all those modifiers) and also fetch the final value. Let's add the following to the CharacterStat class:

If you’re like me, the fact that we’re calling CalculateFinalValue() every single time we need the stat value is probably bugging you. Let’s avoid that by making the following changes:

Great, we can add “flat” modifiers to our stats, but what about percentages? Ok, so that means there’s at least 2 types of stat modifiers, let’s create an enum to define those types:

You can create a new file for this or put it in the StatModifier.cs file (you can actually put it anywhere you’d like, but that’s where it makes most sense, imo)

We need to change our StatModifier class to take these types into account:

And change our CalculateFinaValue() method in the CharacterStat class to deal with each type differently:

Why is the percentage calculation so weird?
Let’s say we have a value of 20 and we want to add +10% to that.
We can first figure out much 10% is by multiplying by 0.1:
20 * 0.1 = 2
Then we add the result of that to the original value:
20 + 2 = 22

With this in mind, that weird line of code could be written like this:
finalValue += finalValue * mod.Value;

However, since the original value is always 100%, and we want to add 10% to that, it’s easy to see that would make it 110%.
That means we can add the percentages together first:
1 + 0.1 = 1.1
And then multiply the original value by the result:
20 * 1.1 = 22

This works for negative numbers too, if we want to modify by -10%, it means we’ll be left with 90%, so we multiply by 0.9.

Now we can deal with percentages, but our modifiers always apply in the order they are added to the list. If we have a skill or talent that increases our Strength by 15%, and we then equip an item with +20 Strength (after gaining that skill), those +15% won’t apply to the item we just equipped.

That’s probably not what we want. We need a way to tell the stat the order in which modifiers take effect.

Let’s do that by making the following changes to the StatModifier class:

What the hell is up with that constructor?
In C#, to call a constructor from another constructor, you essentially “extend” the constructor you want to call.
In this case we defined a constructor that needs only the value and the type, it then calls the constructor that also needs the order, but passes the int representation of type as the default order.

How does (int)type work?
In C#, every enum element is automatically assigned an index. By default, the first element is 0, the second is 1, etc. You can assign a custom index if you want, but we don’t need to do that…yet. If you hover your mouse over an enum element, you can see the index of that element in the tooltip (at least in Visual Studio).

In order to retrieve the index of an enum element, we just cast it to int.

With these changes, we can set the order for each modifier, but if we don’t, flat modifiers will apply before percentage modifiers. So by default we get the most common behavior, but we can also do other things, like forcing a special flat modifier to apply after everything else.

Now we need a way to apply modifiers according to their order when calculating the final stat value. The easiest way to do this is to sort the statModifiers list whenever we add a new modifier. This way we don’t need to change the CalculateFinalValue() method because everything will already be in the correct order.

How do Sort() and CompareModifierOrder() work?
Sort() is a C# method for all lists that, as the name implies, sorts the list. The criteria it uses to sort the list should be supplied by us, in the form of a comparison function. If we don’t supply a comparison function, it uses the default comparer (whatever that does).

The comparison function will be used by the Sort() method to compare pairs of objects in the list. For each pair of objects there’s 3 possible situations:

1. The first object a should come before the second object b. The function returns a negative value (-1).
2. The first object a should come after the second object b. The function returns a positive value (1).
3. Both objects are equal in “priority”. The function returns 0.

Part 1 ends here, I hope this has been helpful. You can move on to part 2 here:

I will be reading and replying to every comment, so don’t hesitate to drop a comment if you have any questions, suggestions or feedback.
Goodbye for now!

--

--