What up fellow devs, hope all is well, I’d like to share with you an implementation for a sats (aka attributes) system.
Below, you’ll find the tutorial in both video and text formats.
DOWNLOAD THE ASSET (it’s free!):
https://assetstore.unity.com/packages/tools/integration/character-stats-106351
Video version:
Chapter 1
Chapter 2
Chapter 3
Chapter 1:
Final Fantasy stat sheet.
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.
Before starting, gotta give credit where credit is due, my implementation was heavily inspired by this article. It’s a Flash tutorial with all code written in ActionScript, but in any case, the concept is great.
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 stat modifiers:
using System.Collections.Generic;
public class CharacterStat
{
public float BaseValue;
private readonly List<StatModifier> statModifiers;
public CharacterStat(float baseValue)
{
BaseValue = baseValue;
statModifiers = new List<StatModifier>();
}
}
public class StatModifier
{
public readonly float Value;
public StatModifier(float value)
{
Value = value;
}
}
What’s readonly?
When a variable is declared as readonly, it can only be assigned a value when it’s declared, or inside the constructor of its class. The compiler throws an error otherwise.
This prevents us from unintentionally doing things like
statModifiers = new List<StatModifier>();
statModifiers = null;
at some other point in the code.
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 floats instead of ints 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 this to the CharacterStat class:
public float Value { get { return CalculateFinalValue(); } }
public void AddModifier(StatModifier mod)
{
statModifiers.Add(mod);
}
public bool RemoveModifier(StatModifier mod)
{
return statModifiers.Remove(mod);
}
private float CalculateFinalValue()
{
float finalValue = BaseValue;
for (int i = 0; i < statModifiers.Count; i++)
{
finalValue += statModifiers[i].Value;
}
// Rounding gets around dumb float calculation errors (like getting 12.0001f, instead of 12f)
// 4 significant digits is usually precise enough, but feel free to change this to fit your needs
return (float)Math.Round(finalValue, 4);
}
We’ll also need to add this outside the class curly braces (right at the top of the script):
using System;
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:
// Add these variables
private bool isDirty = true;
private float _value;
// Change the Value property to this
public float Value {
get {
if(isDirty) {
_value = CalculateFinalValue();
isDirty = false;
}
return _value;
}
}
// Change the AddModifier method
public void AddModifier(StatModifier mod)
{
isDirty = true;
statModifiers.Add(mod);
}
// And change the RemoveModifier method
public bool RemoveModifier(StatModifier mod)
{
isDirty = true;
return statModifiers.Remove(mod);
}
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:
public enum StatModType
{
Flat,
Percent,
}
You can either put this in a new script or in the same script as the StatModifier class (outside the class curly braces for easier access).
We need to change our StatModifier class to take these types into account:
public class StatModifier
{
public readonly float Value;
public readonly StatModType Type;
public StatModifier(float value, StatModType type)
{
Value = value;
Type = type;
}
}
And change our CalculateFinalValue() method in the CharacterStat class to deal with each type differently:
private float CalculateFinalValue()
{
float finalValue = BaseValue;
for (int i = 0; i < statModifiers.Count; i++)
{
StatModifier mod = statModifiers[i];
if (mod.Type == StatModType.Flat)
{
finalValue += mod.Value;
}
else if (mod.Type == StatModType.Percent)
{
finalValue *= 1 + mod.Value;
}
}
// Rounding gets around dumb float calculation errors (like getting 12.0001f, instead of 12f)
// 4 significant digits is usually precise enough, but feel free to change this to fit your needs
return (float)Math.Round(finalValue, 4);
}
Why is the percentage calculation so weird?
Let’s say we have a value of 20 and we want to add +10% to that.
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%.
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%, if 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:
// Add this variable to the top of the class
public readonly int Order;
// Change the existing constructor to look like this
public StatModifier(float value, StatModType type, int order)
{
Value = value;
Type = type;
Order = order;
}
// Add a new constructor that automatically sets a default Order, in case the user doesn't want to manually define it
public StatModifier(float value, StatModType type) : this(value, type, (int)type) { }
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 modifier to apply a flat value 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.
// Change the AddModifiers method to this
public void AddModifier(StatModifier mod)
{
isDirty = true;
statModifiers.Add(mod);
statModifiers.Sort(CompareModifierOrder);
}
// Add this method to the CharacterStat class
private int CompareModifierOrder(StatModifier a, StatModifier b)
{
if (a.Order < b.Order)
return -1;
else if (a.Order > b.Order)
return 1;
return 0; // if (a.Order == b.Order)
}
How do Sort & 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:
- The first object (a) should come before the second object (b). The function returns -1.
- The first object should come after the second. The function returns 1.
- Both objects are equal in “priority”. The function returns 0.
In our case, the comparison function is CompareModifierOrder().
Chapter 1 ends here, we already have a pretty good basis for a stat system. Feel free to take a break and/or pat yourself on the back for reaching this point
Chapter 2:
Right now our percentage modifiers stack multiplicatively with each other, i.e., if we add two 100% modifiers to a stat, we won’t get 200%, we’ll get 400%. Because the first will double our original value (going from 100% to 200%), and the second will double it again (going from 200% to 400%).
But what if we would like to have certain modifiers stack additively? Meaning that the previous example would result in a 200% bonus instead of 400%.
Let’s add a third type of modifier by changing our StatModType enum to this:
public enum StatModType
{
Flat,
PercentAdd, // Add this new type.
PercentMult, // Change our old Percent type to this.
}
Don’t forget to change Percent to PercentMult in the CalculateFinalValue() method (inside the CharacterStat class). Or just use Visual Studio’s renaming features to do it for you ^^.
Inside the CalculateFinalValue() method, we need to add a couple of things to deal with the new type of modifier. It should now look like this:
private float CalculateFinalValue()
{
float finalValue = BaseValue;
float sumPercentAdd = 0; // This will hold the sum of our "PercentAdd" modifiers
for (int i = 0; i < statModifiers.Count; i++)
{
StatModifier mod = statModifiers[i];
if (mod.Type == StatModType.Flat)
{
finalValue += mod.Value;
}
else if (mod.Type == StatModType.PercentAdd) // When we encounter a "PercentAdd" modifier
{
sumPercentAdd += mod.Value; // Start adding together all modifiers of this type
// If we're at the end of the list OR the next modifer isn't of this type
if (i + 1 >= statModifiers.Count || statModifiers[i + 1].Type != StatModType.PercentAdd)
{
finalValue *= 1 + sumPercentAdd; // Multiply the sum with the "finalValue", like we do for "PercentMult" modifiers
sumPercentAdd = 0; // Reset the sum back to 0
}
}
else if (mod.Type == StatModType.PercentMult) // Percent renamed to PercentMult
{
finalValue *= 1 + mod.Value;
}
}
return (float)Math.Round(finalValue, 4);
}
This time the calculation gets pretty weird. Basically, every time we encounter a PercentAdd modifier, we start adding it together with all modifiers of the same type, until we encounter a modifier of a different type or we reach the end of the list. At that point, we grab the sum of all the PercentAdd modifiers and multiply it with finalValue, just like we do with PercentMult modifiers.
For the next bit, let’s add a Source variable to our StatModifier class. This way, later on when we actually have stuff in our game that adds modifiers (like items and spells), we’ll be able to tell where each modifier came from.
This could be useful both for debugging and also to provide more information to the players, allowing them to see exactly what is providing each modifier.
The StatModifier class should look like this:
public readonly float Value;
public readonly StatModType Type;
public readonly int Order;
public readonly object Source; // Added this variable
// "Main" constructor. Requires all variables.
public StatModifier(float value, StatModType type, int order, object source) // Added "source" input parameter
{
Value = value;
Type = type;
Order = order;
Source = source; // Assign Source to our new input parameter
}
// Requires Value and Type. Calls the "Main" constructor and sets Order and Source to their default values: (int)type and null, respectively.
public StatModifier(float value, StatModType type) : this(value, type, (int)type, null) { }
// Requires Value, Type and Order. Sets Source to its default value: null
public StatModifier(float value, StatModType type, int order) : this(value, type, order, null) { }
// Requires Value, Type and Source. Sets Order to its default value: (int)Type
public StatModifier(float value, StatModType type, object source) : this(value, type, (int)type, source) { }
Let’s say we equipped an item that grants both a flat +10 and also a +10% bonus to Strength. The way the system works currently, we have to do something like this:
public class Item // Hypothetical item class
{
public void Equip(Character c)
{
// We need to store our modifiers in variables before adding them to the stat.
mod1 = new StatModifier(10, StatModType.Flat);
mod2 = new StatModifier(0.1, StatModType.Percent);
c.Strength.AddModifier(mod1);
c.Strength.AddModifier(mod2);
}
public void Unequip(Character c)
{
// Here we need to use the stored modifiers in order to remove them.
// Otherwise they would be "lost" in the stat forever.
c.Strength.RemoveModifier(mod1);
c.Strength.RemoveModifier(mod2);
}
}
But now that our modifiers have a Source, we can do something useful in CharacterStat - we can remove all modifiers that have been applied by a certain Source at once. Let’s add a method for that:
public bool RemoveAllModifiersFromSource(object source)
{
bool didRemove = false;
for (int i = statModifiers.Count - 1; i >= 0; i--)
{
if (statModifiers[i].Source == source)
{
isDirty = true;
didRemove = true;
statModifiers.RemoveAt(i);
}
}
return didRemove;
}
Why is the “for” loop in reverse?
To explain this let’s look at what happens when we remove the first object from a list vs when we remove the last object:
Let’s say we have a list with 10 objects, when we remove the first one, the remaining 9 objects will be shifted up. That happens because index 0 is now empty, so the object at index 1 will move to index 0, the object at index 2 will move to index 1, and so on. As you can imagine, this is quite inefficient.
However, if we remove the last object, then nothing has to be shifted. We just remove the object at index 9 and everything else stays the same.
This is why we do the removal in reverse. Even if the objects that we need to remove are in the middle of the list (where shifts are inevitable), it’s still a good idea to traverse the list in reverse (unless your specific use case demands otherwise). Any time more than one object is removed, doing it from last to first always results in less shifts.
And now we can add and remove our (hypothetical) item’s modifiers like so:
public class Item
{
public void Equip(Character c)
{
// Create the modifiers and set the Source to "this"
// Note that we don't need to store the modifiers in variables anymore
c.Strength.AddModifier(new StatModifier(10, StatModType.Flat, this));
c.Strength.AddModifier(new StatModifier(0.1, StatModType.Percent, this));
}
public void Unequip(Character c)
{
// Remove all modifiers applied by "this" Item
c.Strength.RemoveAllModifiersFromSource(this);
}
}
Since we’re talking about removing modifiers, let’s also “fix” our original RemoveModifier() method. We really don’t need to set isDirty every single time, just when something is actually removed.
public bool RemoveModifier(StatModifier mod)
{
if (statModifiers.Remove(mod))
{
isDirty = true;
return true;
}
return false;
}
We also talked about letting players see the modifiers, but the statModifiers list is private. We don’t want to change that because the only way to safely modify it is definitely through the CharacterStat class. Fortunately, C# has a very useful data type for these situations: ReadOnlyCollection.
Make the following changes to CharacterStat:
using System.Collections.ObjectModel; // Add this using statement
public readonly ReadOnlyCollection<StatModifier> StatModifiers; // Add this variable
public CharacterStat(float baseValue)
{
BaseValue = baseValue;
statModifiers = new List<StatModifier>();
StatModifiers = statModifiers.AsReadOnly(); // Add this line to the constructor
}
The ReadOnlyCollection stores a reference to the original List and prohibits changing it. However, if you modify the original statModifiers (lowercase s), then the StatModifiers (uppercase S) will also change.
To finish up chapter 2, I’d like to add just two more things. We left the BaseValue as public, but if we change its value it won’t cause the Value property to recalculate. Let’s fix that.
// Add this variable
private float lastBaseValue = float.MinValue;
// Change Value
public float Value {
get {
if(isDirty || lastBaseValue != BaseValue) {
lastBaseValue = BaseValue;
_value = CalculateFinalValue();
isDirty = false;
}
return _value;
}
}
The other thing is in the StatModType enum. Let’s override the default “indexes” like so:
public enum StatModType
{
Flat = 100,
PercentAdd = 200,
PercentMult = 300,
}
The reason for doing this is simple - if someone wants to add a custom Order value for some modifiers to sit in the middle of the default ones, this allows a lot more flexibility.
If we want to add a Flat modifier that applies between PercentAdd and PercentMult, we can just assign an Order anywhere between 201 and 299. Before we made this change, we’d have to assign custom Order values to all PercentAdd and PercentMult modifiers too.
Chapter 3:
This is gonna be a short one, the only thing left to do is make the classes more easily extendable. To do that, we’ll change all private variables, properties and methods to protected, in addition to making all properties and methods virtual.
Only the CharacterStat script needs changes, and I’m showing only the lines that need to be changed:
protected bool isDirty = true;
protected float lastBaseValue;
protected float _value;
public virtual float Value {}
protected readonly List<StatModifier> statModifiers;
public virtual void AddModifier(StatModifier mod);
public virtual bool RemoveModifier(StatModifier mod);
public virtual bool RemoveAllModifiersFromSource(object source);
protected virtual int CompareModifierOrder(StatModifier a, StatModifier b);
protected virtual float CalculateFinalValue();
Let’s also mark the CharacterStat class as [Serializable], so that we can actually edit it from the Unity inspector.
[Serializable]
public class CharacterStat
And on that note, we also need to implement a parameterless constructor for CharacterStat, otherwise we’re gonna get a null reference exception due to statModifiers not being initialized.
Let’s change our original constructor and add the new one:
public CharacterStat()
{
statModifiers = new List<StatModifier>();
StatModifiers = statModifiers.AsReadOnly();
}
public CharacterStat(float baseValue) : this()
{
BaseValue = baseValue;
}
Other than that, I’ve also put both classes inside a namespace:
namespace Kryz.CharacterStats
{
[Serializable]
public class CharacterStat
{
//...
}
}
namespace Kryz.CharacterStats
{
public enum StatModType
{
//...
}
public class StatModifier
{
//...
}
}
You don’t need to do this if you don’t want to, but I like it for organization reasons.
I will be reading and replying to every comment in this thread, so don’t hesitate to drop a comment if you have any questions, suggestions or feedback. I hope this has been helpful, and goodbye for now!