One big question when building an open world game is how saving and loading of states will function. With so many complex systems, ensuring that this works takes a lot of effort and planning. Here’s how I built Ardenfall’s save system so far, and various things I have learned. This is a very technical post, so if you’d rather just catch up on the project, be sure to check out the last devlog!
Static versus Dynamic
In my save system, I have essentially two types of savable scene objects: static and dynamic. Statics are objects that are in the scene on game start, such as containers, doors, and spawners. Dynamics are objects that are spawned during runtime, such as pickups and npcs.
Regardless of save type, every object needs an ID of some sort. This is handled by a GUID component, which is exactly what it sounds like – a component that generates a unique id for an object. Special care has to be taken for various edge cases, such as for prefabs. Unity had a blog post about a similar concept, although it is slightly out of date.
Static objects register themselves with a manager on awake, and a frame later the manager then updates the states, if the game is loading a state. Dynamic objects, on the other hand, are constructed via their own savestate. This is done by having a function called “CreateFromState” in a dynamic object’s save data, which does exactly what it sounds like – recreates itself based on its save data.
Saving states often means digging deep into various container classes: saving a character’s state involves saving its inventory, which saves its stacks, which saves its items. This is done consistently by using a “pattern” of four-five functions:
void Init() : run before any other functions, essentially a constructor
void CreateState(): run when the object is first being created, aka has no state to load
object SaveState(): saves the state to a dataset and returns it
object LoadState(object state): loads the state from the data
object SaveState(object state): this is an additional function that is used in some cases.
The latter function is needed if there are various classes that inherit from each other, and each class has its own respective SaveState class. The function simply creates an empty savestate class. For example:
Class 1: Character
Class 2: NPC: Character
Save Class 1: CharacterState
Save Class 2: NPCState : CharacterState
Once you have all the data you wish to save, it’s time to serialize it into a file. You could simply serialize it into a JSON file using Unity’s built in JsonUtility, or another serialization tool that goes along with whatever engine or language your using. In my case, I’m using Odin Serializer, which is quite powerful and open ended.
Whichever serializer you wish to use, you may end up having some assets (scriptable assets, prefabs) referenced, depending on how you designed things. Simply serializing all of this would result in a lot of extra unneeded data, as well as broken references on reload, and the inability to react correctly to future builds. Odin provides a solution to this, by allowing you to manage how these assets are referenced during serialize and deserialized. In other words, you can serialize an ID string or int that represents every asset referenced in the save data, and then on deserialization, handle the conversion from ID to asset reference.
But this requires a way to consistently ID every asset, something unavailable straight out of the box. There are probably plenty of solutions, but mine is what I call a ReferenceTable. During edit time, the assets ID is simply the instance ID for the asset. Simple. However, during runtime there is no way to get this instance ID. My solution is to pack these assets into a scriptable object when building, alongside their respective ID’s. This acts as a database which can be referenced in a build.
Currently, I determine what assets are packed by having an empty file with a certain name in whatever directories I wish to pack. The build tool then scans these folders, and any folders beyond them. This solution works, but results in bloat due to the fact that it will pack assets that wouldn’t have been included in the build, and thus would have no need to be referenced. Also, when this table asset is loaded into memory, so is every asset attached to the table. Thus, some severe changes will need to be made. Perhaps addressables could be the solution, although this still doesn’t solve the issue of bloat. We’ll see where this adventure goes! Luckily in my case I never need to reference prefabs, just scriptable objects. This fact results in much less bloat and memory issues, to an extent.
Only Save What You Must
One key concept in save systems is saving efficiently: never save what hasn’t changed. The reasoning for this is twofold: if there are future changes to the game, not saving everything will allow most or many changes to still affect saved states, and most importantly being efficient will reduce save file size.
Here’s an example: when a container is first loaded in the game, it generates items. If the player never interacts with it, then there is no reason to save the inventory of the container. Saving the inventory is only done when the player opens the container, and thus has seen the contents. Another example is a data structure I’ve mentioned earlier, which automatically only saves values that have changed.
Another complexity that comes with save systems, and serialization in general, is managing changes. Say you’ve released your game, and push a patch that breaks some part of a save. How do you solve this? The simplest form is to save the version of the save file somewhere in the data, usually in the first byte(s) of the file. This allows this value to be accessible regardless of serialization changes, and then allows you to hook special “update” logic to occur depending on old file versions. One major issue is how serialization works with Odin, JSON, or most if not all serialization tools in C# work – it’s purely automatic, a black box. So trying to update such data can be a huge pain. I haven’t figured out a solution to this yet.
I also pack more data into the “header” of my savefiles – this data includes the player’s name, level, playtime, and a small screenshot during the save. Since it’s packed in at the beginning of the file, the game doesn’t need to read the entire file to load this data, making the loadscreen much faster.
I hope this article was helpful, or at the very least interesting. Most of this information was gathered simply by making mistakes and learning from them, something I am still doing. Building open worlds is hard, but it’s also fascinating how complex systems can be built from “simple” systems. If you have any questions, feel free to add me on Twitter, or join our Discord!