Hi folks! I finished coding something the other day which I'd never had to do before:
letting the player save the game anywhere, at any time. That's obviously not a giant unsolved riddle that's had computer scientists stumped - many games do it. But every game I've made before has always used checkpoints, so I didn't have to worry about loading game objects into *exactly* the state they were they were saved. With checkpoints, it was usually enough to say: 1) Load level X, 2) Warp the player to point Y, 3) Okay, go.
Since it was clear that I needed to write a proper Save-Anywhere system for UnEarth, I decided my first step would be to take all of the game's internal data and conceptually break it into two pieces: The things that really *need* to be saved, and the things that can be either be inferred or just aren't important enough to save. For a creature in the game, that first group is generally just "where am I, what is my goal at this moment", the second group contains things like "what frame of animation am I on right now, what particle effects am I handling, what sounds am I playing," etc. When we load a save and discover that a creature was in the air, jumping, we can put it into its jump animation. If there was a particle dust cloud from its jump? Fine, we can just ignore that.
Another big conceptual decision in designing the save system was choosing where complexity is going to happen: Games like UnEarth have a heap of unique creatures and events. Each of these has code behind it and data that's going to need to be saved.
I decided that if I can make it simpler to write AI code at the expense of making it harder to write the save system, I'm going to do that. Because the save system is small, and the AI code is vast. In short: The first step of saving anywhere was mentally collecting an idea of what information needed to be saved, and what didn't. The next step was designing a system to save all that data that puts as little work as possible on the game objects themselves.
Okee. From here I'm going to get a little more specific. UnEarth is being made in Unity and I'm using C#. Specifically, I'm using C#'s reflection features. If you're coding in a language that doesn't support reflection, you may need to approach this implementation very differently.
Saving begins with an Attribute - a tag, essentially, that you can apply to any classes members:
using System;
[AttributeUsage(AttributeTargets.All)]
public class NeedsSaving : System.Attribute
{
public readonly string Name;
public NeedsSaving(string name)
{
this.Name = name;
}
public NeedsSaving()
{
this.Name = null;
}
}
With this defined, we can begin marking-up variables inside game objects -- tagging the things that we know we'll want to save.
Like so:
public class Door : MonoBehaviour
{
[NeedsSaving]
bool m_IsLocked;
[NeedsSaving]
bool m_IsOpen;
...
}
C# gives us the ability to take any arbitrary object and walk through its members, checking the attributes of each one. So in a nutshell, that's what we'll do when we save the game - we write down a list of all the game objects in the world, then peruse each object for members that are flagged as [NeedsSaving] and write down whatever we see there.
As I mentioned early, this makes life super easy for writing AI code. You just drop [NeedsSaving] on to a variable you think you'll need to hold on to, and you're done. It's pretty hard for the save/load code itself, though, because that code needs to understand how to take literally any kind of member data and save it to a file. But like I said - better to solve a hard problem once, then to have to do a minor headache a hundred times.
Wait! You say, writing booleans and integers to a file doesn't sound hard. But one of the most relevant things a AI needs to save is references to other AIs in the level. How do you store that?
To store references to other game objects, the save/load process has to take two steps: First, creating a list of all the relevant game objects, and only then saving all of their member data once that list is complete. Why? Because one creature's reference to another creature is saved as an index into that complete list of game objects. So, for example, if this HostileAlien class wants to save a reference to who it's shooting at:
class HostileAlien : MonoBehaviour
{
[NeedsSaving]
GameObject m_OurTarget;
...
}
...what the save system is actually going to write down for the variable 'm_OurTarget' is a number. Specifically, the place in that big list of game objects where the target resides. Then we when load everything back, we begin by creating each of those game objects. When we come back on a second pass to fill in member data, it's trivial to read that 'm_OurTarget' was object number 17 and fill in that connection from the list.
Also of note: backwards compatibility. You really don't want to break all your old saves anytime you add a new variable that needs saving, and that means that when you load a file, the system needs a way to tell what member a particular block of data is meant to fill in to. To make this work, member data is stored in file this way:
An integer hash, based off of the member's name.
An integer holding the length of the data used to describe this member.
The actual member data.
When the system reads an object's member data, it starts with that initial hash, and checks if it can find any member in the current object that matches. If it can't, then it knows it's dealing with a member that used to exist in an earlier version of the code, but was removed. It continues to the next line - the length of the data stored for this member - and uses that to skip ahead to the next piece of member data in the file.
On the other hand, if it can find a match then it gets the type of the member using C# reflection, and uses that type as a guide for how to parse the data stored in the file.
Two rare things can go wrong with this. There's a one in four billion chance that an object contains two members that generate the same hash code. Currently I get around this by having the save system check that no two members in any object have the same hash. If they do, it simply throws an error and I rename something. (This has never happened yet, except when I forced it to as a test.)
There's also the possibility that a person might mark a member as needing to be saved, but latter change its type without renaming it. Since the save system uses a member's type to determine how to parse the data stored in the file, this could get ugly. The fact that the length of the data is also written in the file gives you a small measure of protection from this: The system will generate an error if the data is the wrong size. But the case where you changed an int to a float (both having the same size when saved), and then load an old save will result in some improper behavior. For now, I simply remember to avoid doing this.
There's actually a whole lot more I could write about how this system works - especially on how arrays, lists and enums are handled inside the system. But for now I'll leave it at this high level overview. If anyone is interested in the gritty details of serializing arbitrary member data, let me know. I might start a thread on the tech forum for it. =)