We have another technical post for you, this time about saving the game state and how we do it.
Even though most today’s games prefer checkpoint save system where you can only continue from predefined points in the game, we decided to go for a full-state save system for Vaporum. Not only is it pleasant for players to be able to save anywhere, anytime, but it’s also the staple in the genre.
But before we delve in, know this:
Implementing a full-state save system is a nightmare! If you can get away with doing checkpoints, do checkpoints.
What to Save
The very first problem is to determine what objects and data to save when you press that lovely QuickSave button. For this, all GameObjects that we need to save have a component called
Vap is a prefix for everything we write. In the hindsight, I’d use namespaces for this, but hey, whatever suits you.) So we just find all GOs with this component and we have our first batch of suspects. Because some prefabs are complex and contain multiple child GOs with their own components, we also have a
VapSaveChild component that does the same thing as Save but also remembers what parent it belongs to.
When writing components, we mark fields and props we need to save by our own attribute:
[VapSave]. Then, using reflection, we simply go through the list of GOs and their components, looking for fields and props marked with the attribute, and we save those. Unity built-in components are a special case (Transform, RigidBody, Light, and some of these typical bad guys you find in every project). We have extension methods for them (Save and Load) where we manually save position, rotation, light color & intensity, etc.
To make it as pleasant to use as possible, we made some of the most used .NET classes like Lists and Dictionaries also saveable, not just basic types. It’s just a matter of more programming at the beginning, but this saves you tons of time when the only thing you need to do to save your 20 dictionaries is to mark them with a single attribute.
Totally worth the time!
How to Save
We tried the .NET BinaryFormatter (BF), but it proved unworthy of our cause. Saving Unity’s Vector3 and some other classes was problematic. We solved it by some interface shenanigans, but it was ugly and laborious. Another thing. When you push an object into the BF, and that object has a reference to another object, and that other object has a reference to yet another object, the BF serializes everything by value. And then when you read it back via BinaryReader, you read it, as you might have guessed it, by value again, so it basically creates new instances of those referenced objects. Which is clearly not the way to a brighter future.
So we created our own writer and reader which gives us all the control we need. We assign an identifier to every GO and component (incrementally counted), and so to save a reference to another object, we just save the id of that GO / comp. BTW, we don’t use Unity’s
instanceId as that changes on every run.
In some special cases (animation data sets, for example), we manually create an array of pointers which always contains the same number of pointers to the same instances, but every time you run the game, the pointers will point to different memory addresses. So we just save the index to this array. So any time you load a game, the array gets recreated with valid pointers, and then via the index, we get the working reference to the instance we need.
To save a reference to any other class (usually pure non-Unity classes), we use a custom id system. I think if you’ve got to this point without tearing your hair out, you can figure something out on your own.
95% Resist to Change Damage
To make this resistant to data changes (you may need to add or remove a component from a prefab as you iterate on your design), we save a list of all components a GO has, and put the name of the component at the start of the data block for that component. So if during loading we realize that the component is no longer there, we skip the whole block and nothing breaks. Or when there is a new component, we have no save data for it, but because everything is marked, we’re not pushing data into something that will most likely not be compatible with it. Again, nothing breaks.
Something very similar goes for fields in the components. For every type of object, we first write a reflection data block which tells the loader what fields & props to load by name. Simply reading via reflection would prove prone to data changes in the components. Imagine you add or remove a field and then you try to load an older save. Crash course inferno would happen because the order of data you have in your save file is now different from your updated game. But because we provide reflection with the “guidelines”, it only reads what it’s supposed to. And if it’s not there, it will just ignore it and continue.
Why 95% resist and not 100%? Because if we change the name of a field in a component, all older saves will break. Don’t see a way around this. At some point, you just have to live with the names of your fields, no matter how stupid they are. And believe me, some of them are pretty stupid! 🙂
Now, if saving looks like a daunting task, wait for what’s in store for you next.
What to Load
Loading. Oh boy…
Saving the full game state is a breeze compared to this.
So at first, again, we need to determine what objects and data to load and recreate. We get all GOs with the
VapSave component on them, currently in the scene. We identify the GOs saved in the file with the currently present ones in the scene by name and for some objects by position (like doors that never move). We fill them with the data from the save file.
We can get away with this brute-force approach because we have a strong naming convention for every asset. The loader will not make mistakes thanks to this. So if the loader finds two objects in the scene, both with the name of “DroneEnemy”, it does not matter which is which. Because in the end, if you fill them with sequential data from the save file, they will behave just like they did when you made the save. Sure, the instances might get swapped, but it just does not matter.
Note: Actually, we regret this approach as there were quite some issues when instances were getting swapped, and we had to solve them. It was painful. 🙁 For the next project, we will definitely always identify objects individually, not by type.
How to Load (and not go insane)
If we bump into a save record that doesn’t correspond to any currently present Save component in the scene, we instantiate a prefab by the name, and then fill it with data. After all that, if there are Save components in the scene, but no records for them in the save file, we destroy those GOs.
I can see your confusion so let’s try the same in a gameplay language:
If there was an enemy behind that door when you saved, but he is not there right now at runtime, loading will respawn that enemy behind that door and fill it with all the data that was saved. And quite the opposite, if there was no one in the room when you saved, but there is somebody right now because some nasty level designer spawned them there at your back, loading will destroy that somebody to leave you alone in the room, just like when you did make the save.
After every object has been recreated / destroyed and filled with all the data, we need to sort out the references. We cannot connect references mid-loading, as some of the referenced objects might not have been (re)created yet. That’s why we have 2 phases. So now that everything is nice and dandy, we just connect the references via the saved identifiers. Remember? All GOs and components (and pure classes too) have unique identifiers. So if I have something like this:
private GameObject attackTarget;
That GameObject has a unique id and this
attackTarget field knows that id so it simply gets the reference to the GO via a table of references.
There are and always will be special cases that you just have to handle in the most sane way you can come up with. For this, every component (BTW all our components derive from our class that derives from
UnityEngine.MonoBehaviour) has a Save and Load methods so you can inject and retrieve any kind of custom data into and from the stream the component needs.
I said it was a nightmare and that loading and reconstructing the state was difficult, but the text above doesn’t make it look so. Well, the trouble is that there are many hidden bugs which you just cannot predict and which you rarely catch even by constant play-testing when saving the full state. The amount of things to save & load is just too great for you to wrap your head around and think about every bit of data. Another issue is that when you’re writing level scripts or basically any feature, you constantly have to juggle with how this or that will be saved and loaded, will it break anything, will I be able to reliably recreate the state, etc. It also takes significant time to think in this way. That’s not to say checkpoint systems are super-easy or anything. They have their own share of issues you have to sort out, but from my experience, they’re more pleasant to work with, and give you more freedom in scripting levels and writing features. On the other hand, many people hate them! However, very few people hate classical saving. Choices…
The player (and their GO) is a special case. When moving from level to level, and thus saving the state of the current level and loading the state of the level we’re moving into, identifiers of the objects get reused. But that’s not a problem because nothing moves from level A to level B and vice-versa. Except the player. Would be kinda boring to spend the whole game in the first level, hm?
For that, the player’s GO, components, and refs have reserved identifiers that never change and no one else is allowed to use them. Also, player GO does not get destroyed during the transition.
To save space, the save file has a table of strings that are used multiple times throughout the data set. Then the individual points where a string is needed simply save an index to that table.
The same goes for vectors and some other structures. Because our game is grid-based and you (and other objects) can only turn by 90 degrees, there are many vectors and quaternions with the same values. So they are also indexed in a table which saves more space.
This shrinks the file by roughly 33%.
When you press that QuickLoad button and you last saved in the same level, we do not load the whole scene and then populate it with the saved data. We simply stay where we are, but remove, spawn, and move all the stuff to match what’s in the save file. This results in load times around 0.3 seconds.
When you’re loading into another level, then we load that scene first, and then apply all the craziness. Takes a lot more time, but these loads are quite rare. You don’t often load one ore two levels back, right? Right?!
Did you ever make that mistake and quick-saved while falling to an abyss, or while being a millisecond from that fireball that means certain death? And basically had to start the whole game over?
And so Vaporum keeps several quick-saves for this very reason. Even if you save in the worst time possible, you can always go back one or several saves. Just remember to save often! 🙂
Personally, it’s mind-boggling to me when modern games don’t keep multiple saves. So easy to do.
In the article, by referring to “we”, I mean our low-level hardcore programmer, Peter “Dark” Uliciansky. We drafted the system together, but he did all the hard work.
Special thanks to the guys who stormed our table and requested this post at Game Access 2017 in Brno. 🙂