Stats in Unity - The Way I Do it
/The goal of this post is to show how I did stats and hopefully give you ideas or at least a starting point to build your own system - your needs are probably different than mine.
All games need stats in some shape or form. Maybe it’s just the speed of the player or maybe it’s full-blown RPG levels stats for players, enemies, NPCs, weapons, and armor.
For my current personal project, I knew I was going to need stats on the buildings as well as the enemy units.
A good way to do this is to create a non-monobehaviour stats class that has a public field for every stat. Then all classes that need stats can have a public stats field and you’re good to go. For a lot of uses, this might be more than enough.
Making the stats class a non-monobehavior class it can help enforce some good composition over inheritance type of structure.
While this is good, I wanted something more. The goal of this post is to show how I did stats and hopefully give you ideas or at least a starting point to build your own system - it’s likely that your needs are a bit different than mine.
I wanted something generic. I wanted different units to have different types of stats. I wanted a quick and easy way to get values of stats - without creating all kinds of functions or properties to access individual stats. And lastly, I wanted a stat system that could work with an upgrade system with similar ease and adaptability.
My implementation of an upgrade system will get shared in a follow-up post.
Stats in a Collection
Having my stats in a collection (a dictionary in my case, but lists also work) means I can easily add stat types and adjust stat values for a given type of unit. While I very much have a love-hate relationship with assigning values in the inspector - this is a win in my book all day every day.
This was a crucial piece of the puzzle given that not all units will have the same types of stats - farmer and defensive towers have very different functions and so they need different stats!
Using Scriptable Objects
I choose to use a scriptable object (SO) as the container for my stats. This keeps my data separate from the logic that uses the data. Which I like.
It also means that each stats container is an asset and can be shared with any object that needs access to it. It works project-wide.
For example, every “tower” has access to the same stats object. If the UI needs to display stats - they access the same SO. This reduces the need to duplicate information and more importantly reduces possible errors or needs to keep “everything up to date” - the UI and units always have the same values.
Upgrades can also be easy. Apply an upgrade to the stats object and every tower gets it. No need to hunt through the scene for objects of a certain type to apply the upgrade. Additionally, if I apply an upgrade in Level 1, that upgrade can easily transfer over to Level 2 as it can be applied to the SO. Pretty handy.
Important Note: Scriptable objects can be used to transfer data from one scene to another. BUT! They are not a save system and changes in an SO will not persist when leaving play mode - just like changes to a monobehaviour.
As a slight tangent, I also like that the SO, at some level defines the characteristics of the object. For example, in my project I want players to be able to choose a leader at the start of a new game or even at the start of a new level - the leaders effectively act as a global upgrade. By having each leader’s stats on a SO, I can simply drop the leader’s SO into whatever script is handling the leader’s logic and the effect is a change in leadership - a strategy pattern-like effect. The same effect can be had with stats - depending on your exact implementation.
Quick Stat Look-Up
Putting my stats in a dictionary with an enum for the key and the value holding the stat value makes for a quick and easy method to access a given stat. No need to create properties. All I need is one public function that takes in the stat type and returns the value. This continues to work if I add or remove a stat type making my system a little less brittle.
This approach does mean that a stat could be requested and that it isn’t in the dictionary. If this is the case, I return zero and send an error to the console. Ideally, this isn’t happening, but with this implementation, nothing breaks, and as the developer, I get a message letting me know that I either asked for the wrong stat or I haven’t created a stat type for a given unit. Again, nice and clean.
Changing Stat Values
Similarly, if you need to change a stat on the fly - a potential path for an upgrade system - a single public function can again be used. Once again remembering that changes to the SO won’t persist out of play mode.
In this case, I chose to return a negative value if the stat couldn’t be found… Would zero have been better? Maybe. Depends on your use case. I chose negative as things like hit points can be zero without something being wrong.
Potential Issues
For those who are paying attention, there are at least two potential issues with this system.
The Dictionary
You may have noticed that my scriptable object is actually a “SerializedScriptableObject” which isn’t a class built into Unity. Instead, it’s part of Odin Inspector and it allows the serialization and display of dictionaries in the Unity inspector. Without this class, you can’t see the dictionary in the inspector and you can’t add stats in the inspector… It’s a potential problem. There are at least two workarounds - short of buying Odin.
Fix #1
Use a list instead of a dictionary. You would need to create a custom class that has fields for the stat type and the stat value. Then you would need to alter the GetStat() and ChangeStat() functions to iterate through the list and find the result.
A bit messier, but not too bad. If you are concerned about the performance of the list vs a dictionary, while there is definitely a difference, the extra time to iterate through a list of 5, 10, or 20 stat types is marginal at best for most use cases.
Fix #2
But if you insist on using a dictionary, the second fix would be to use your list to populate a dictionary at runtime and then use that dictionary during play mode. This could be done in an initialized function or the first time that a stat is requested. A bit messier, but definitely doable.
The Scriptable Object
Having every unit of a type share stats is a good thing. Unless of course, a stat needs to be for the individual instance. Something like hit points or health. In those cases, we have a problem and need to work around it. So let me propose a couple of solutions.
Fix #1
Have each unit instantiate a copy of the SO when the unit is created. This makes the original SO just a template and each object will have its own copy. This breaks the “every unit of a type shares the same stats” idea, but it means that every unit of a type starts with the same stats.
This effectively means that the SO tracks max values or starting values while the object itself tracks the current value of the stat.
This is the method I have used in my project to prevent all units of a type from sharing health, but unfortunately, it will likely break my upgrade system moving forward. So….
Fix #2
Or you could create an additional dictionary (or list) of stats on the SO that should be copied onto the instance. Then functions such as a DoDamage() that change the value of a local or instance stat simply change the local value instead of changing the value on the SO.
This is likely my preferred solution moving forward as the SO still defines all stats for the object while individual objects have control of their instanced stats.