Upgrade System (Stats Part 2)
/Upgrades! Who doesn’t like a good upgrade when enemies are getting tough and you need a little boost?
Implementing a system can be easy if it’s small. An upgrade system is no different. Need to boost the speed of a spaceship? No problem make a variable a bit bigger. But when the number of stats, the number of units, and the number of possible upgrades grows the need for a system becomes more and more obvious.
With 20, 50, or 100 different upgrades you can’t write a new class for each upgrade and have any realistic hope of maintaining that code base let alone debugging each and every upgrade.
So just like I did with my stats system I wanted to share how I created my upgrade system (which is closely tied to my stats system) in hopes that it might spark ideas for you to create your own upgrade system that matches the needs for your project.
Important Details
Just like my Stats system, I wanted my upgrade system to be based on scriptable objects - they provide asset-level data packages that allow for an easy workflow, and frankly I just like them. Just like with my stats system, I’ve continued to make use of Odin Inspector to allow the serialization of dictionaries and access to those dictionaries in the inspector. If you don’t have Odin Inspector, you can use the same workarounds from the stats system to use lists instead of dictionaries.
Base Class
I don’t always love using inheritance, but in this case, I’m using it as I have several varieties of upgrades in my project - including leader upgrades, global upgrades, and building unlock upgrades.
For this post, I’ll show the inheritance, but stay focused on implementing basic upgrades for altering unit stats. If you don’t need different types of upgrades, then I’d suggest you use a single non-abstract Upgrade class.
The base upgrade class is an abstract class because (for me) each type of upgrade will need to implement DoUpgrade slightly differently and I don’t want any instances of the base Upgrade class in the project. The class defines some basic properties such as a name, a description, cost, and an icon for the UI.
Stats Upgrade
All the real functionality comes in the StatsUpgrade subclass. Here I define two important collections.
The first is a list of the units that this upgrade is to be applied to. Notice that it’s actually a list of stats objects (which are themselves scriptable objects) and not the prefabs of the unit objects. I simply drag in the stats scriptable object for any unit that I want to apply this upgrade to.
The second collection is a dictionary, but could easily be a list, of the individual stats that are affected by this upgrade and the amount that the stat is altered. Again, I’m manually adding items to define the upgrade.
Then the real functionality comes from the DoUgprade function, but even that is pretty simple. All that happens is we iterate through all the stats to upgrade and call UnlockUpgrade and pass in the upgrade itself.
That’s it. That’s all the stats upgrade does.
To make this useful, we need to make some modifications to the stats class that we built in the last post (or video) to handle the upgrades.
Modifying the Stats Class
If you read the post or watched the video on my stats system you may also notice that I’ve implemented my suggestion of an “instance stats” dictionary to separate stats like “health” or “hit points” that will belong not to the type of object but to the instance of an object.
To work with the upgrade system the stats class needs a new list to track the upgrades and it’ll need a handful of new functions as well.
First, we need to define the UnlockUpgrade function that was called in the StatsUpgrade class. This function simply needs to check if the upgrade is already contained in the applied upgrade list and if not add it to the list.
Next, we need to modify our GetStats function to take into account any potential upgrades. To do this we first check if the desired stat is in either the instance stats dictionary or in the stats dictionary. If we find it in either dictionary we get the base value for that stat and pass it into the GetUpgradedValue function.
Inside this new function, we loop through all the applied stat upgrades and check if any of those stat upgrades apply to the current stat type - by looking for that stat type in the dictionary of upgrades to apply.
If we find an upgrade, we apply the value to the stat. If the upgrade was a percent upgrade the math is a bit different but the idea is the same. When we’re done looping through all the possible upgrades we return the value to the GetStats function.
I like this approach as the entire upgrade path is dealt with by the StatsUpgrade and Stats classes. Nothing outside these classes needs to know or even cares what’s going on - keeping things nice and tidy.
Fixing Loose Ends
#1 There is one problem in the current system which comes from using scriptable objects and modifying them in the Unity editor. When applying an upgrade by adding it to a list during play mode that upgrade will still be part of the list when you leave play mode. Meaning you could accidentally ship your game with an upgrade pre-applied which is less than awesome. This is the same reason, I choose to recalculate the upgraded stat value each time the value is requested rather than simply setting the value.
The solution to this is pretty simple. We just need to clear the list of applied upgrades when we enter or exit play mode.
There are no “lifetime” functions like OnEnable or OnDisable for scriptable objects so where and how this function gets called is really up to you. I haven’t come up with a particularly clever solution, if I do I’ll post it here, but for now, my implementation is to simply have a Stats manager that will call the reset function on each stat object when going into play mode. This same stats manager could also tie into a future save system so that applied upgrades can be saved and restored in standalone builds.
If you come up with a more clever solution I’d love to hear about it in the comments below or on the OWS discord.
Reminder: Scriptable objects are not suitable for a save system. So don’t try and turn this “problem” into a janky-ass save system. It just won’t work.
#2 There is an edge case where an upgrade may be applied to one of the “instance stats.” For example, let’s say we get an upgrade that adds 20 hit points to our tanks. With no further modification, any tank that is added AFTER the upgrade is applied should see the extra 20 hit points, but tanks that are already in the scene won’t. Maybe that’s okay or even desired, but in my game not so much.
So here is my suggested fix. It’s not the tidiest, but it works. I’m going to add a public non-static event to the Stats class that will get invoked when an upgrade is applied. Any class that might care about an upgrade can then be notified - which could be useful for functionality such as SFX or VFX or other player feedback to let them know an upgrade has been applied to a particular unit.