A Stat System for Unity part 1

Posted by : on

Category : Unity

Introduction

In this post and the next, I will create an extendable stat system for the Unity game engine. Instead of writing the final code and explaining it, I have decided to describe my thinking, from the initial conception to the final architecture of the system. In this post, I will create the relevant classes and implement the appropriate algorithms, so that the system works. In the next post, I will refactor the whole code, to make the system easily extendable and less rigid.

Before going to the theoretical part, let me first try to explain what I mean by saying a stat system. In most games, the player character or even other entities of the game, have certain stats. This is more apparent in role playing games with the characters having stats like strength, dexterity, intelligence etc., but stats don’t have to be known to the player. In a platformer game for example, how high the main character jumps, can be a stat that is not known to the player as a value, but its effect is shown visually.

A stat system, is a system that allows the creation of stats and their manipulation through different modifiers. These modifiers, although they have a value, this value represents a different thing depending on the type of the modifier.

For example, we can have a modifier with a value of 10, added to a stat with a value of 50. Depending on the type of the modifier, the stat will mutate differently. If we suppose that this stat represents the strength of a character, then when we add this modifier to the stat, the strength can become 60 if the modifier represent a flat value, or 55 if the modifier represents a percentage.

There are two ways that different modifiers can be calculated. A calculation that is added to the current value of a stat, but is based on the initial (before any other modifiers) value of that stat and a calculation that is also added to the current value of a stat and is based on that current value. For example if we had the above two stats (the flat 10 modifier and the percentage 10 modifier) the final value could be calculated like (50 + 10) + 0.1 x 50 = 65, or (50+10) + 0.1 x (50+10) = 66. In the first case we have an additive percentage modifier and in the second case a multiplicative percentage modifier. These two modifiers along with the flat one, compose the basic modifiers that are more ususal in a game.

When we create our system, we want it to be able to handle those three different cases, but also to be easily extensible so that anyone can add his own modifiers.

The Theory

From the above, we can understand that we need to implement at least two types. A stat type and a modifier type.

What is a stat

What exactly is a stat in the context of a video game? Someone could reply that it is just a number, and he would be wrong. If you have read my post about The Liskov Substitution Principle a type for an outside observer is defined from its behaviors and not from its data. Besides that, from the previous paragraph, we can see that a stat has two numbers: Its base value and its current value.

Note that a stat is different from a resource, for example the health of a character that also has two values its current and its max value. A resource has in fact more values than two values. Two values that represent a range, for example the health of a character that can be between 0 and 100, and a value that is clamped between those two. Someone could see the resource as a combination of two or more stats, but this is a different subject.

Let’s try to define the stat from its behavior. We can get and set its base value, which is mandatory for a stat and that means it has to be set at its creation. We can add and remove modifiers from that stat, as we can also get the modifiers that have been applied to it. Finally, we can get its current value which is the base value modified by the current modifiers that have been applied. How all those things are going to be represented is an implementation detail that we will see in this post.

What is a modifier

A modifier is simpler, we can set a type for the modifier as we can set a value. We can also get the modifier’s type and value. A modifier doesn’t have to be mutable, we can create it by setting its initial value and type and then use it at different places in our code.

Finally, a modifier can have a source. Because it can be used from different places, it would be convenient to be able to know from where a modifier was applied. This is because by knowing the source, we can group certain modifiers together and remove them all at the same time. For example, if a character equips a sword that has a flat 10 modifier to strength and a 10 percent additive modifier to strength, it would be convenient to be able with one statement remove all those modifiers when the sword is unequipped.

Implementing the modifier

Let’s start with the modifier that is simpler. We can try two different ways on how to implement it, with inheritance or composition. Inheritance is the wrong choice here. Because we want to be able to know the type of the modifier, at different parts of our code, checking the type of the class will be slower and messier. Also, we want to be able to easily create new types of modifiers by adding the type directly instead of creating a whole new class.

The simplest way we can do that, is by creating an enum that will have all the different types

public enum ModifierType
{
   Flat,
   Additive,
   Multiplicative
}

Now we can create our modifier. Because it will be a small class, that has a small amount of data, no behavior other than getting that data and adding that data at its creation, it will be immutable. For these reasons a struct is more appropriate.

As a bonus because each stat will have a data structure containing many modifiers, with the struct implementation we can leverage data locality, even though it is still early to be thinking about any performance gains.

public readonly struct Modifier
{
   public ModifierType Type { get; }
   public object Source { get; }

   private readonly float _value;

   public Modifier(float value, ModifierType modifierType, object source = null)
   {
      _value = value;
      Type = modifierType;
      Source = source;
   }
   
   public override string ToString() => $"Value:{_value.ToString(CultureInfo.InvariantCulture)} Type:{Type}";

   public static implicit operator float(Modifier modifier) => modifier._value;
}

This will do for now. As a general rule it is a good habit to overwrite the equals and the equality operator for such a struct, but let’s keep it simple for this example. The only other things I have done here, is that instead of having a property with a getter for the value, I have overriden the implicit operator, so we can save a few strokes when we want to get its value.

Note that our struct is readonly. This makes it immutable, a modifier doesn’t need to change and if a change is needed we are better off creating a new one and replacing the old modifier. As a value type, a copy is created inside the collection that holds our modifiers so that there won’t be any hopping in memory when we travel that collection for our calculations.

Implementing the stat

Our modifiers will have to be calculated in a certain order. The reason is that the modifiers that their calculation is based on the current stat value, will give a different result based on when they are calculated.

For example a multiplicative percentage modifier of 10% on a stat with a base value of 50 that also has a flat modifier of 10 will give a result of 65 if the multiplicative modifier is calculated first (50 + 0.1x50 + 10) and will give a result of 66 if the modifier is calculated last ((50 + 10) + 0.1x(50 + 10)).

The order of the modifiers of the same type doesn’t matter, but the order of each group of different types does. For this reason, we will have a list for each type of modifier that we have.

private readonly List<Modifier> _flatModifiers;
private readonly List<Modifier> _additivePercentageModifiers;
private readonly List<Modifier> _multiplicativePercentageModifiers;

Now let’s create the method that adds those modifiers to the appropriate list:

public void AddModifier(Modifier modifier)
{
   IsDirty = true;
   
   switch (modifier.Type)
   {
      case ModifierType.Flat:
         CheckListCapacity(_flatModifiers);
         _flatModifiers.Add(modifier);
         break;
      case ModifierType.Additive:
         CheckListCapacity(_additivePercentageModifiers);
         _additivePercentageModifiers.Add(modifier);
         break;
      case ModifierType.Multiplicative:
         CheckListCapacity(_multiplicativePercentageModifiers);
         _multiplicativePercentageModifiers.Add(modifier);
         break;
      default:
         throw new ArgumentOutOfRangeException();
   }
}

There are two more things here. The first is the boolean flag IsDirty. This flag signals our value getter that a recalculation is needed because a new modifier was added. The second is not that important right now, it is the CheckListCapacity method.

Because we use lists that are backed by arrays in C#, we want to be able to initialize our lists with a number so that we can avoid the garbage collector. For more information about that you can check my Initializing Lists in C# post.

For brevity here is the implementation of the CheckListCapacity method.

   [Conditional("UNITY_EDITOR")]
   private static void CheckListCapacity(List<Modifier> modifiersList, [CallerArgumentExpression("modifiersList")] string name = null)
   {
#if UNITY_EDITOR
      if(modifiersList.Count == modifiersList.Capacity)
         Debug.LogWarning($"Resize of {name} List! Consider initializing the list with higher capacity.");
#endif
   }

For the CallerArgumentExpression attribute to be usable in Unity, you will also need to implement the following:

namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)]
internal sealed class CallerArgumentExpressionAttribute : Attribute
{
   public CallerArgumentExpressionAttribute(string parameterName) => ParameterName = parameterName;

   public string ParameterName { get; }
}
}

But this won’t be needed after the refactoring as we will see in the next post.

Let’s create now our properties:

   public float Value
   {
      get
      {
         if(IsDirty)
         {
            _currentValue = CalculateModifiedValue(_digitAccuracy);
            OnValueChanged();
         }

         return _currentValue;
      }
   }

   public float BaseValue 
   {
      get => _baseValue;
      set
      {
         _baseValue = value;
         _currentValue = CalculateModifiedValue(_digitAccuracy);
         OnValueChanged();
      }
   }

   public (IReadOnlyList<Modifier> FlatModifiers, IReadOnlyList<Modifier> AdditiveModifiers, IReadOnlyList<Modifier>
      MultiplicativeModifiers) Modifiers =>
      (_flatModifiers.AsReadOnly(), _additivePercentageModifiers.AsReadOnly(), _multiplicativePercentageModifiers.AsReadOnly());

   private bool IsDirty
   {
      get => _isDirty;
      set
      {
         _isDirty = value;
         if(_isDirty)
            OnModifiersChanged();
      }
   }

In the Value property we check the flag and if it is true we call the CalculateModifiedValue method which is responsible for the calculation of the modifiers. We also call the OnValueChanged method that invokes an event that signals that the value has changed (duh!)

public event Action ValueChanged;

private void OnValueChanged() => ValueChanged?.Invoke();

In the BaseValue property, we do the same things, only this time in the setter. If the user wants to change the base value of his stat then a new calculation is needed.

In the private IsDirty property we call a method that invokes the ModifiersChanged event:

public event Action ModifiersChanged;

private void OnModifiersChanged() => ModifiersChanged?.Invoke();

Finally, the Modifiers property returns all the modifiers as a tuple that contains readonly lists that are also immutable so that the user can see the modifiers applied, but will not be able to change them through those lists.

After the creation of our properties and adding modifiers to our stat, we need a way to remove modifiers:

   public bool TryRemoveModifier(Modifier modifier)
   {
      return modifier.Type switch
      {
         ModifierType.Flat => TryRemoveModifierFromList(modifier, _flatModifiers),
         ModifierType.Additive => TryRemoveModifierFromList(modifier, _additivePercentageModifiers),
         ModifierType.Multiplicative => TryRemoveModifierFromList(modifier,
            _multiplicativePercentageModifiers),
         _ => throw new ArgumentOutOfRangeException()
      };
   }

Another switch statement, but the method is self-explanatory. The TryRemoveModifierFromList is implemented like this:

   private bool TryRemoveModifierFromList(Modifier modifier, List<Modifier> listOfModifiers)
   {
      if (listOfModifiers.Remove(modifier))
      {
         IsDirty = true;
         return true;
      }
      
      return false;
   }

We remove the modifier from the list and set the IsDirty flag to true to signal that a recalculation is needed.

After that we also need the method that removes all the modifiers from a source:

   public bool TryRemoveAllModifiersOf(object source)
   {
      var removed = TryRemoveAllModifiersOfSourceFromList(source, _flatModifiers);
       removed = TryRemoveAllModifiersOfSourceFromList(source, _additivePercentageModifiers) || removed;
       removed = TryRemoveAllModifiersOfSourceFromList(source, _multiplicativePercentageModifiers) || removed;

       return removed;
   }

Another simple method, but with the possibility of a bug. If we had made the calculation in a different order, like this for example:

removed = removed || TryRemoveAllModifiersOfSourceFromList(source, _additivePercentageModifiers);

because we use the short circuit or operator, the TryRemoveAllModifiersOfSourceFromList method would never run if the removed boolean was true from before. Either we have to use the method first or we could have used the | operator. Check the How the boolean logical operators are used and get overloaded in C# post for an in depth discussion about the logical operators.

The TryRemoveAllModifiersOfSourceFromList is implemented like this:

   private bool TryRemoveAllModifiersOfSourceFromList(object source, List<Modifier> listOfModifiers)
   {
      bool isModifierRemoved = false;
      
      for (var i = listOfModifiers.Count - 1; i >= 0; i--)
      {
         if (ReferenceEquals(source, listOfModifiers[i].Source))
         {
            listOfModifiers.RemoveAt(i);
            IsDirty = true;
            isModifierRemoved = true;
         }
      }

      return isModifierRemoved;
   }

Same as before, we change the IsDirty flag to true, but there is another thing to notice here. We travel the list in reverse order. This is needed because if we travel the list from beginning to end, when we remove an element, all the elements after it go back one place. For example, if we have the list [1,2,3,4] and we remove the 2, the next iteration would check the 3rd element, but now the list would be [1,3,4] so the 3rd element would be the 4. There are some other reasons, but this is not the purpose of this post.

Anyway, the only thing that’s left are the actual calculations. We start by creating a method that will call methods in the desired order for the calculation:

   private float CalculateModifiedValue(int roundDigits)
   {
      roundDigits = Math.Clamp(roundDigits, 0, MAXIMUM_ROUND_DIGITS);
      
      float flatModsValue = CalculateFlatModsValue(_baseValue);
      float additiveModsValue = CalculateAdditiveModsValue(_baseValue);
      float finalValue = CalculateMultiplicativeModsValue(flatModsValue + additiveModsValue);

      IsDirty = false;

      return (float)Math.Round(finalValue, roundDigits);
   }

We round our calculated value to our desired precision to avoid getting numbers with too many digits, but most importantly to avoid floating point precision errors. The order that we call the methods is the order of the desired calculation. Here are the three methods:

   private float CalculateFlatModsValue(float startingValue)
   {
      float calculatedValue = startingValue;
      float flatModifiersSum = 0f;
      
      for (var i = 0; i < _flatModifiers.Count; i++)
         flatModifiersSum += _flatModifiers[i];

      calculatedValue += flatModifiersSum;

      return calculatedValue;
   }

Flat modifiers are simple, we add them together and in the end we add the result to the starting value.

   private float CalculateAdditiveModsValue(float startingValue)
   {
      float calculatedValue = 0;
      float additiveModifiersSum = 0f;
      
      for (var i = 0; i < _additivePercentageModifiers.Count; i++)
         additiveModifiersSum += _additivePercentageModifiers[i];

      calculatedValue += _baseValue * additiveModifiersSum;
      
      return calculatedValue;
   }

Percentage additive calculation is about the same. We add the modifiers and then we multiply the result with the base value.

   private float CalculateMultiplicativeModsValue(float startingValue)
   {
      float calculatedValue = startingValue;
      
      for (var i = 0; i < _multiplicativePercentageModifiers.Count; i++)
         calculatedValue *= 1 + _multiplicativePercentageModifiers[i];

      return calculatedValue;
   }

Multiplicative calculation is even simpler, we multiply each modifier with the current value, and we return the result.

The whole stat class, that you should NOT use

Here is the whole stat class, until now:

public sealed class Stat
{
   public float Value
   {
      get
      {
         if(IsDirty)
         {
            _currentValue = CalculateModifiedValue(_digitAccuracy);
            OnValueChanged();
         }

         return _currentValue;
      }
   }

   public float BaseValue 
   {
      get => _baseValue;
      set
      {
         _baseValue = value;
         _currentValue = CalculateModifiedValue(_digitAccuracy);
         OnValueChanged();
      }
   }

   public (IReadOnlyList<Modifier> FlatModifiers, IReadOnlyList<Modifier> AdditiveModifiers, IReadOnlyList<Modifier>
      MultiplicativeModifiers) Modifiers =>
      (_flatModifiers.AsReadOnly(), _additivePercentageModifiers.AsReadOnly(), _multiplicativePercentageModifiers.AsReadOnly());

   private bool IsDirty
   {
      get => _isDirty;
      set
      {
         _isDirty = value;
         if(_isDirty)
            OnModifiersChanged();
      }
   }
   
   public event Action ValueChanged;
   public event Action ModifiersChanged;
   
   private const int MAXIMUM_ROUND_DIGITS = 8;

   private readonly List<Modifier> _flatModifiers;
   private readonly List<Modifier> _additivePercentageModifiers;
   private readonly List<Modifier> _multiplicativePercentageModifiers;

   private float _baseValue;
   private float _currentValue;
   private bool _isDirty;
   private readonly int _digitAccuracy;
   
   public Stat(float baseValue, int digitAccuracy, int flatModsMaxCapacity, int additiveModsMaxCapacity, int multiplicativeModsMaxCapacity)
   {
      _baseValue = baseValue;
      _currentValue = baseValue;
      _digitAccuracy = digitAccuracy;
      
      _flatModifiers = new List<Modifier>(flatModsMaxCapacity);
      _additivePercentageModifiers = new List<Modifier>(additiveModsMaxCapacity);
      _multiplicativePercentageModifiers = new List<Modifier>(multiplicativeModsMaxCapacity);
   }
   public Stat(float baseValue) : this(baseValue, 4, 4, 4, 4) { }
   public Stat(float baseValue, int digitAccuracy) : this(baseValue, digitAccuracy, 4, 4, 4) { }

   public void AddModifier(Modifier modifier)
   {
      IsDirty = true;
      
      switch (modifier.Type)
      {
         case ModifierType.Flat:
            CheckListCapacity(_flatModifiers);
            _flatModifiers.Add(modifier);
            break;
         case ModifierType.Additive:
            CheckListCapacity(_additivePercentageModifiers);
            _additivePercentageModifiers.Add(modifier);
            break;
         case ModifierType.Multiplicative:
            CheckListCapacity(_multiplicativePercentageModifiers);
            _multiplicativePercentageModifiers.Add(modifier);
            break;
         default:
            throw new ArgumentOutOfRangeException();
      }
   }

   public bool TryRemoveModifier(Modifier modifier)
   {
      return modifier.Type switch
      {
         ModifierType.Flat => TryRemoveModifierFromList(modifier, _flatModifiers),
         ModifierType.Additive => TryRemoveModifierFromList(modifier, _additivePercentageModifiers),
         ModifierType.Multiplicative => TryRemoveModifierFromList(modifier,
            _multiplicativePercentageModifiers),
         _ => throw new ArgumentOutOfRangeException()
      };
   }

   public bool TryRemoveAllModifiersOf(object source)
   {
      var removed = TryRemoveAllModifiersOfSourceFromList(source, _flatModifiers);
       removed = TryRemoveAllModifiersOfSourceFromList(source, _additivePercentageModifiers) || removed;
       removed = TryRemoveAllModifiersOfSourceFromList(source, _multiplicativePercentageModifiers) || removed;

       return removed;
   }

   public static implicit operator float(Stat stat) => stat.Value;
   
   private float CalculateModifiedValue(int roundDigits)
   {
      roundDigits = Math.Clamp(roundDigits, 0, MAXIMUM_ROUND_DIGITS);
      
      float flatModsValue = CalculateFlatModsValue(_baseValue);
      float additiveModsValue = CalculateAdditiveModsValue(_baseValue);
      float finalValue = CalculateMultiplicativeModsValue(flatModsValue + additiveModsValue);

      IsDirty = false;

      return (float)Math.Round(finalValue, roundDigits);
   }

   private float CalculateFlatModsValue(float startingValue)
   {
      float calculatedValue = startingValue;
      float flatModifiersSum = 0f;
      
      for (var i = 0; i < _flatModifiers.Count; i++)
         flatModifiersSum += _flatModifiers[i];

      calculatedValue += flatModifiersSum;

      return calculatedValue;
   }

   private float CalculateAdditiveModsValue(float startingValue)
   {
      float calculatedValue = 0;
      float additiveModifiersSum = 0f;
      
      for (var i = 0; i < _additivePercentageModifiers.Count; i++)
         additiveModifiersSum += _additivePercentageModifiers[i];

      calculatedValue += _baseValue * additiveModifiersSum;
      
      return calculatedValue;
   }

   private float CalculateMultiplicativeModsValue(float startingValue)
   {
      float calculatedValue = startingValue;
      
      for (var i = 0; i < _multiplicativePercentageModifiers.Count; i++)
         calculatedValue *= 1 + _multiplicativePercentageModifiers[i];

      return calculatedValue;
   }

   private bool TryRemoveModifierFromList(Modifier modifier, List<Modifier> listOfModifiers)
   {
      if (listOfModifiers.Remove(modifier))
      {
         IsDirty = true;
         return true;
      }
      
      return false;
   }
   
   private bool TryRemoveAllModifiersOfSourceFromList(object source, List<Modifier> listOfModifiers)
   {
      bool isModifierRemoved = false;
      
      for (var i = listOfModifiers.Count - 1; i >= 0; i--)
      {
         if (ReferenceEquals(source, listOfModifiers[i].Source))
         {
            listOfModifiers.RemoveAt(i);
            IsDirty = true;
            isModifierRemoved = true;
         }
      }

      return isModifierRemoved;
   }

   private void OnValueChanged() => ValueChanged?.Invoke();
   private void OnModifiersChanged() => ModifiersChanged?.Invoke();

   [Conditional("UNITY_EDITOR")]
   private static void CheckListCapacity(List<Modifier> modifiersList, [CallerArgumentExpression("modifiersList")] string name = null)
   {
#if UNITY_EDITOR
      if(modifiersList.Count == modifiersList.Capacity)
         Debug.LogWarning($"Resize of {name} List! Consider initializing the list with higher capacity.");
#endif
   }
}

This works and gives the correct result, but let’s face it. This code is horrible. The stat class does too many things:

  • Is responsible for calculating the different modifiers
  • Is responsible for adding/removing them to the relevant data structure
  • Is responsible for the order of the calculation

Besides that, the stat class is also very hard to change. Those switch statements will keep getting bigger and bigger for every new type of modifier we want to add. We also need to remember to add a different list for every new type of modifier, as we also need to provide the method for its calculation somewhere in the class, while remembering to call that method in the right order.

This code is rigid, very brittle, prone to bugs in every change and hard to maintain. The open closed principle is non-existent, the single responsibility principle is ignored and imagine trying to describe to someone who doesn’t know the code, what she has to do, to add a new modifier type. Good luck trying to describe that.

To Be Continued… Next Week

That this code has so many problems, doesn’t mean that we shouldn’t have written it. First we make something work, then we make it pretty. The important thing here is that after we write a piece of code that works, we are only half way there. After we make something that works, refactoring is in order.

Next week, I will refactor this code to fix all those architectural problems. Instead of writing the final code here, I decided to write this post, so that I can show the whole procedure of creating the stat system. For this reason, I won’t provide a github repository for this code. Next week after the refactoring, I will give a link to a repository with the final version.

The final code will be easily extendable by creating new classes and without the need to change code that has already been written or even modify the Stat class. Adding new functionality to a system, without touching existing code is what I believe every programmer should strive to do.

You can read part 2 here

This post got a little bigger than usual. Until next week, thank you for reading, and as always, if you have any questions or comments you can use the comments section, or contact me directly via the contact form or by email. Also if you don’t want to miss any of the new blog posts, you can always subscribe to my newsletter or the RSS feed.


About Giannis Akritidis

Hi, I am Giannis Akritidis. Programmer and Unity developer.

Follow @meredoth
Follow me: