Posted On: 2019-07-15
Over the past week, I've been architecting and developing the save system for my main project. Although save systems are typically very project-specific, after reviewing what I've created, I have found that the architecture itself seems to be project-agnostic. In light of that, I will explore the architecture of my save system in some detail, in the hopes that it is useful, or at least interesting.
Before getting into the architecture proper, I will go over a few of the constraints and assumptions that defined how I architected the save system:
To best illustrate how the architecture works, I will walk through an example: consider a character picking up a coin. At the moment the character picks up the coin, the coin should disappear, and the number associated with the character's money should increase. In order to save that the character has picked up the coin, both pieces of information need to be recorded in the save file (the LiteDB database.)
*This feature is only available in certain versions of Unity - I believe it's limited to 2018 or newer.
Each object in the game is associated with a "Persistence" script. This script is responsible for taking a change that happened in the game (ie. disappearing coin) and translating that into data that can be saved. In the case of the coin, the change that must be recorded is the coin being disabled*. To accommodate this, the Persistence script observes the change to the "enabled" field (which changed to "false") and relays this change on to a separate system: the "Scene Change Watcher".
There is only ever one "Scene Change Watcher" in a scene, and it is responsible for collecting information about all the changes that need to be saved. As illustrated in the coin example, the "Scene Change Watcher" doesn't detect changes, rather, it relies on other scripts telling it whenever a change has occurred. It collects that information, and waits for another script (which will be described later) to tell it to save those changes.
One important detail about how the "Scene Change Watcher" and the "Persistence" interact: there are many different types of "Persistence" scripts, and the "Scene Change Watcher" needs to be aware of each kind**, in order to make sure it passes the correct information to the save system. To understand why this is important, consider the character that is picking up the coin: that character has a "money" property which will increase (or possibly decrease) throughout the game. In contrast, the coin has no such property, it only has the enabled/disabled property to track. As such, the "Persistence" for the character and the "Persistence" for the coin are dramatically different.***
The last script related to saving that is in the Game scene**** is the "Auto Save." The details of this script are not particularly important (it will likely change as the project grows) however, the one thing that is important is that it is responsible for notifying the "Scene Change Watcher" whenever it is time to save changes. If this architecture is used in other projects, the "Auto Save" script can be easily swapped out as needed (for example: swapping to a script that allows players to manually save the game.)
Although this describes all the scripts in the Game scene, this does not fully describe the how the game is saved. To better understand that, we will need to step back a bit further, and look at a set of scripts that are loaded when the game starts: the Title scene of the game.
*Disabled objects are not actually removed, but they don't appear on the screen, and characters can't interact with them. This is a common way to "remove" an object in Unity: for example, in the Magic Training Prototype, whenever the player breaks a target, the target is really just disabled. Some games take advantage of this convention to improve the game's performance: disabled objects can be re-used later, for example, when adding a new object they can instead move a disabled one and re-enable it. This is often accomplished using an object pool and is a large enough topic to be a post on its own.
**Technically, the "Scene Change Watcher" doesn't know about the different types of "Persistence" scripts, rather, it knows about the different data types that are possible to be saved. Currently, there is a one-to-one relationship between those two, however, that is not a technical requirement, and it is theoretically possible that a persistence script could track multiple types of data, or that two different Persistence scripts could have a data type in common.
***The Character would have many other properties as well, such as position and orientation in 3D space (since characters can move around). In fact, both the coin and the character have a lot more data associated with them, but for the purposes of these examples, I am trying to focus on just the parts that are relevant for understanding the architecture.
****In Unity, a "Scene" is basically a container for one or more "game objects." A scene is typically summarized as similar to a level in a game, but for my project, I am using one scene for each contiguous space - a "room" if you will - and I am loading the contents of that scene dynamically. The details of how this all works is not necessary to understand this post, so it is probably easiest to just think of the "Game scene" as a single room, with characters moving around inside it.
Visual representation of the scripts in the Game scene. The arrows represent what each script depends upon, and the arrows at the top refer to objects in other scenes.
The Game scene is not the only active scene - the Title scene* is also running scripts at the same time. For the purposes of the save system, the Title scene contains all the scripts associated with actually writing the save data to the database. Those scripts will remain available even when other scenes are loaded, so that the scripts in other scenes (such as the Game scene) can use them.
The "Save Slot" script is the central location for all saving and loading operations. Scripts in other scenes (such as the "Scene Change Watcher") will reference and use the "Save Slot" to save data. Since the "Save Slot" is so highly visible, it actually contains very little logic. All the technical details associated with saving are in a separate class: the "World State". For example, when the "Scene Change Watcher" saves a change, it uses the "Save Slot" to get the "World State", and then tells the "World State" which change to save.**
The "World State" is the closest that any of my code will get to the actual save file on the file system - it relies on LiteDB for the actual file operations. In fact, the vast majority of the code in the "World State" interacts directly with the LiteDB database: for example, when saving a change to a character's total money, the "World State" will look up that character in the Database, set the total money to the correct value, and then tell the database to update the record to match the new information.***
*Despite its name, the Title scene doesn't actually contain the title of the game, it is simply the scene that the game loads when it first opens. The name "Title scene" is really a reference to how many other games start by displaying the game's title or logo. Originally, I planned to name it the "Load scene", since it is related to loading save data, but that quickly became confusing, since it runs before the "Preload scene".
**It may seem that the "Save Slot" is unnecessary, but it actually performs a single, very important role: it manages the life-cycle of the "World State." It will instantiate the "World State" object as well as dispose of that object when the application exits. To put this idea another way, the "Save Slot" protects the rest of the code from the messy details of making the "World State" work - if the "Scene Change Watcher" wants to save a change, it can just do so, and the "Save Slot" will make sure the "World State" is ready.
***Despite how important it is, there's actually not a whole lot to say about the "World State": the code is largely just implementation details related to how to write changes to the database. In fact, since it is so tightly connected to the database implementation, I set it up so that it's implementing an interface: that way, if I ever decide to use a different database (or some other way of accessing the save file) I can create a new implementation for that interface, and the rest of the code should just continue to work.
Visual representation of the scripts in the Title scene. Arrows coming in from the bottom indicate objects in other scenes that depend on this scene.
Between the Title scene and the Game scene, all the scripts related to saving are available. To recap: individual "Persistence" scripts track changes in Unity, and report them to the "Scene Change Watcher". The "Auto Save" tells the "Scene Change Watcher" when to save the outstanding changes, and the "Scene Change Watcher" uses the "Save Slot" to locate the "World State". It then tells the "World State" which changes need to be saved, and the "World State" takes care of coordinating with LiteDB to update the save file.
Saving data is only half the story, however. Next week, I will explore the other half of the save system: loading the save data. It will build upon the concepts laid out in this post to show how the save data is used to populate the Game scene with objects. As something of a teaser, here is the full architecture diagram, including the Preload scene which will be explored in detail next week:
The complete save and load architecture, including the Preload scene, which will be covered in next week's article about loading save data.