Save Systems for large games


The 1.1 update for CINIS (named Quality of Life and Death) introduced something truly important: A save system. I realized it would be best to get it done early, as it only gets harder to implement later, and missing a save system is a big annoyance to players. While the game is only 1-2 hours currently, playing 1-2 hours every update or every crash quickly gets annoying... So, the save system is absolutely needed!

In this post, I want to detail the process and considerations of making it. What I considered on a conceptual level, a technical one, and what I've implemented in case of future updates.

Initial Considerations

First thing I thought of as I was developing the game, was that I needed a system which could  save and load data from sub-levels, and all the data in a sub-level would be loaded correctly again. The world of CINIS is built up not just as one world, but one persistent world, and many sub-levels which are streamed into the game. Additionally, in the future, there may be multiple persistent worlds too, so all need to save and load accurately.

Next, I also considered the size of the file, and load speeds. CINIS can be quite fast-paced, and doesn't make use of loading screens, but streaming of assets and levels. So the data has to be ready on demand. On top of this, specific pieces of data have to be retrievable very quickly, for example the world data is very time-sensitive. When a sub-level is loaded, each saved object (doors, chests, and key items) need to know what their state needs to be, and searching huge lists of data would be quite poor here as it may be milliseconds in the worst cases before data is found / not found, leading to hitching caused by the save system.

With performance in mind, I started mapping out how to build the save system first, before getting into the more in-depth optimizations.

Design process

So, need to know what to save and how. For this, I created a list of all the things I knew the player wanted to have saved and loaded (Weapons, currency, active Rift, NPC states etc.) and what data type these variables were. I.e. "Gold - Integer, Kinetic Weapon - TSubclassof<'AWeapon'>".

Then, I made a table which maps out how to structure all structs and data types that would be used to create the save files. The table had columns for the name, type and "usage". Two of the columns are explanatory I should think, but the usage column would tell me when to save, how to load the data as well. For some, no specific instructions were needed - Just load the data and apply it. For others, it may have been that they needed to be loaded when the object itself starts it's life-time (Begin Play, in Unreal)

Lastly, there was a section called "flow". This was simply just an outline for which order things would happen in. I.e. - "Game Begins, player data is loaded. When entering a level, all saved objects loads their state and sets themselves up correctly" etc. 

This made it much easier when developing the system, as it was all planned out already. What data to save, how to save it, and even an outline for what order things happen in. A thorough design made it much more clear what to do when in the process of developing it, and I would recommend designing your save system in a similar way too, while designing and developing your features.

Technical Decisions / solutions

Speed

So now we know what data to save, how to save it, and how to load it. But what about file size and reading speed? Well, for speed, one easy solution was to use TMaps (A c++ "hashmap" or in C# a "dictionary"). For those unfamiliar, they consist of unique keys matched with a certain value. The benefit of using these over lists is that looking up a key is instant, whereas looping through a list and making comparisons can be costly, if the amount of data is large.

(Side note: When doing research some said that a C++ list of 1 million entries could still be searched through with no felt impact on gameplay, so perhaps not the worst solution either, but I haven't personally verified this. Most seem to agree that Unreal's built-in find method is quite fast.)

What downside a TMap does have, is that we need keys, and those keys need to be unique, and therefore the system will have to be designed with this in mind. Solution here was to make a GUID system for the objects that need saving, and using their GUID as the key. Most frameworks (Or even programming languages) have a technique for generating GUIDs, so if needed simply look up your tool and see how they handle GUIDs.

There are a few things which are not currently saved using a hashmap, as at most they would have a key, but not a value. One example is the "Event System", which is what keeps track of the players progress through quests and NPC states. This currently only needs a single FName which is saved and loaded, and tacking on some value may just be bloat for the file. But who knows, perhaps in the future I'll change how the Event System works, and then it can be hashed too.

Game Instance & Multiple Saves

One other thing to solve was how to load the correct saves. Since the game supports multiple save files / characters, it was important that these were loaded correctly as well. Had there only been 1 save, this would be easier, as we just look for the one save slot and load the game from that. But, the game theoretically supports infinite saves. So, how do we handle it?

First thing to figure out was how to load the chosen save. Since the main menu is a different level from the persistent world of the RPG. We need to get the save slot name, so when we open the RPG Persistent world, the save data is loaded from the save slot chosen in the main menu. This was actually sorta easy - The save slot chosen stores it's slot name in the Game Instance.

(For those who may be unfamiliar with Unreal, the Game Instance is an object which is created when the game starts, and destroyed when it ends. It lives for the entire run-time, that is. So storing something which needs to be persistent between levels is a great way to do it.)

Save slots for each character are stored in a separate save file called SaveProfiles.sav. This file simply contains an array of a struct, which has the relevant metadata for a save character. This contains the name of the character, the last played date, and most importantly, the Saveslot name. The saveslot ID is equal to NAME + ID. The name is the chosen character name by the player, and the ID is a four digit number. The ID is by default 0000, and is increased with 1 if the name is already chosen. So create a character named John, and then make another named John, and then you'll end up with 'John0000' and 'John0001' as save slots. This is what is moved from the SaveProfiles metadata file into the game instance, then loaded when the game starts. Easy enough!

One benefit of splitting the profile metadata from the character save file, is that the game always knows where to look for profiles. It is also quite fast to load, and we don't need to bother loading all the character and world data for a save we're not loading into the game. In the menu we only care about the profiles, and in the game we only care about the one file we loaded. This also helps cut down a bit of size, and helps the goal of speed as well!

Futureproofing

Now, I already mentioned that one part of the save system (The Event System) may change. This could be a catastrophe, because people will loose their progress! If I change what is saved and loaded, it will become unknown data and disappear on the next save. But there is a fix here too.

In each save file is the current game version as well. It's just a simple string (currently with the value of "1.1") but this can be used in any major updates that changes how data is saved, to actually load and re-make the same data.

Let's say version 1.2 comes out and now the Event system uses a map. When we load a save, we check the version, and if it's "1.1" then we simply run a save migration first. This migration will take the currently saved data, attempt to find the updated version of that data, if a match is found, then load and save this new version. And just like that, the save will be saved! A concrete example of what this could look like:

System looks for Event called "CityFound" (Given when the player has found the Dead City in the current prototype) It grabs a list of key and values, and looks for Value or Key with "CityFound" (Depending on if the event name is the key or not). If a match is found, add this to the TMap of Events. Now the system knows that the player has found the Dead City, and the game can continue as normal. We may also wish to make a back-up of the save file at some point in this process, before migrating the data.

In closing

So, this is most of the relevant stuff about how I made the save system for CINIS - RPG. The only thing I wish I had done differently, was to do it earlier.

(Also, a secret side note to UE Devs - There's a getter called "Get Actor GUID" with a small cog on the BP node... Hover over the cog and see that it's Editor only! This caused me a lot of confusion when I made my save system and it didn't work in new builds)

Had I started development of the save system earlier, before adding any new mechanics, it would have saved me time in development and testing especially. I really had to test the game 3 times each time I had made a change to the system, to figure out for real if it worked or not. That ended up being testing every single saveable element of the game 3 times back to back in the end, and I wish that had been spread out more to make it more bearable.

But that's it for this article, thanks for reading! If you have any questions feel free to ask and I will do my best to answer!

Get CINIS - RPG

Download NowName your own price

Leave a comment

Log in with itch.io to leave a comment.