Posted On: 2019-07-22
This is part 2 in the two-part series about architecting a save system. You can read part 1, which was focused on the general architecture and saving data. This post will focus on the process of loading data, and will build on the architecture outlined in the previous part, so here it is in visual form:
The complete architecture for the Save and Load system (arrows indicate dependencies.) As a quick refresher on saving: Each game object has a "Persistence" script that detects changes and reports them to the "Scene Change Watcher". An "Auto Save" script runs periodically, which tells the "Scene Change Watcher" to save any pending changes. In order to save, the "Scene Change Watcher" tells the "World State" on the "Save Slot" what changes to record, and the "World State" writes those changes into a LiteDB database file.
When the game first starts, the Title Scene is loaded and displayed. Within the Title Scene there is one (or more) "Save Slot" script(s) - each "Save Slot" has its own "World State", but only one "Save Slot" will have its "World State" active at a time*. When the "Save Slot" tells its "World State" to activate, the "World State" locates the LiteDB database file on the file system, and connects to the database**. If it cannot find the database, then the player is starting for the first time, so it copies a Template database (which includes the starting state of the world.)
*Ordinarily, the player picks which "Save Slot" to load. For my purposes, however, I have an script running that automatically loads the first "Save Slot", since less interaction means I can get to testing the important parts faster.
**Currently, the database connection is an open file handle, and the "Save Slot" is responsible for controlling its lifetime via the "Dispose" method on the "World State". These are implementation details that might change as the project grows in scope, however.
Once there is an active "World State" on the "Save Slot", then the next scene is loaded: the Preload Scene. The purpose of this scene is to bring together all the pieces required to load the gameplay scene, so that they can be quickly located and used. The preloading scene primarily uses asynchronous methods to load the various resources, so the game will remain responsive during this time*.
The primary script of the Preload Scene is the "Preloader." As soon as the Preload scene begins, the "Preloader" connects to the "World State" in the "Save Slot", and requests a list of all the prefabs** that will be used in the game***. The preloader then uses Unity's Addressable Assets System to asynchronously locate each of those assets, and stores the results into a Dictionary so that other game systems can access those prefabs quickly and synchronously. Once all the prefabs are available to be used synchronously, the Preloader initiates loading the Game Scene.
*Currently this doesn't matter much, since it only takes a few milliseconds, but as the game grows, I expect I will need to put some kind of progress bar or other indication that the game is working in the background. In general, I expect a lot of the implementation details of Preloading will change, as preloading is intended to be an abstraction that allows for incremental optimization while minimizing the impact on the rest of the code base.
**For those unfamiliar with the term, a prefab in Unity is something like a recipe for an object. Unity uses prefabs to create multiple individual objects that have many traits in common - such as a series of identical crates.
***Right now, the preloader is looking for everything across the entire game, but it may be possible to focus on only the objects used in the current scene. This is yet another implementation detail that may change depending on how a larger project affects its behavior.
The architecture of the Preload scene, including arrows indicating dependencies with other scenes. (You may have noticed the "Factory" hasn't been described yet - this is intentional, as it is easiest to explain in the context of the Game Scene, where it is actually used.)
Once the game scene is loaded, then the game is (finally) truly loading. Specifically, everything that happens from here on occurs synchronously, so the game will appear frozen until everything completes. This is important, both because it prevents game systems (such as physics) from running during load and also because the user experience can easily be impaired if the game remains frozen for too long. As such, this is an area I plan to incrementally optimize throughout development (likely by offloading work to the Preloading Scene.)
The "Scene Change Watcher" starts up by requesting all objects in the scene from the "World State," focusing on each type of object in turn. The "World State" provides this data, which includes all the information that is specific to that object (such as whether a coin has been collected or the total money of a character.) The "Scene Change Watcher" passes the information for each individual object to the "Factory" script*, which will perform the actual operation of creating the object.
The "Factory" is responsible for turning data into an actual game object, as well as correctly wiring up the Persistence object so that it can track changes to that object. This may sound like a lot of work, but the factory actually doesn't have much logic in it**. The factory uses the Dictionary provided by the "Preloader" to find the correct prefab for the object. To actually create the object, it makes use of Unity's built-in support for instantiating a prefab which takes care of all the actual creation work. Once Unity has created the object, the "Factory" then locates the correct "Persistence" script on that object, and instructs the persistence script to initialize itself. Once that's done, the object is complete, so the factory returns, allowing the "Scene Change Watcher" to request that the "Factory" build the next object.
*The "Factory" is technically in the Preload Scene, but this is the first time it actually does anything. I am keeping it in the Preload Scene as there may be later optimizations available, such as startup activities that can be run during the Preload Scene.
**You may have noticed that I have many middle-manager-like scripts, which are only responsible for telling other scripts to do their jobs. This is likely a product of my personal preference for architectures made up of many small parts, rather than a few large pieces.
The exact behavior of a "Persistence" script as it initializes itself is largely dependent on its type. In general, the "Persistence" will perform any operations that are required in order to make the object match the data that represents it. For most objects that will include moving them to the correct position and orientation in space, however, that may also include type-specific things like setting a character's "Total Money" correctly, or changing its sprite to reflect low health. The "Persistence" is also responsible for making sure that it will configure itself so that future changes can be detected and communicated to the "Scene Change Watcher"*. Through its initialization, the "Persistence" script will ensure that an object is setup to match how it was when it was saved, and also make sure that any future save operations continue to correctly reflect that object.
*This is an aspect that is still quite under-developed. Right now, most "Persistence" scripts over-report on changes, since writing granular detection is tedious work with unclear performance implications. As the project grows, I will need to profile the change reporting, to determine which, if any, could improve performance by being increasing accuracy in their change detection.
Once all the game objects in the scene have loaded, the game will begin running its game logic. (Physics will simulate, player input will be read, etc.) At this point, everything is loaded and the Autosave will kick in, so everything will record its current state on a schedule. As a result of both the load and save operations, a player can exit the game, and return to it later, to find the game world to be in exactly the same place as before they exited. For games that need less granularity in their representation, this same architecture could be used, just with less detail in the "Persistence" scripts (though much of this design may be overkill for games that don't need to track position of objects between saves.)
Beyond saving and loading, there are still more operations to perform using save data. Changing scenes is something I expect will be relatively simple to add in, but I haven't yet implemented it to verify*. Another aspect that still needs to be explored is dealing with versioning and upgrades**. Finally, there is the question of backups and save integrity***. (Of course, there are also the unknown unknowns - but trying to design for them typically only increases complexity without actually helping if/when they do become clear.)
With so much still unexplored, there is plenty of room for further improvement for this architecture. On the flip side, I think this particular approach is flexible enough that it should be resilient to most changes: new scripts may be needed, but overall I think the decomposition of save/load interaction into three interconnected scenes should continue providing value for most, if not all, of this project's lifespan. Overall, this is what I view the goal of architecture to be: to create a set of systems, conventions, and constraints that are able to meet present and anticipated needs, with an understanding that unforeseen needs may convert it into a hindrance rather than a help (at which point the architecture should be changed.****)
*By additively loading the Game Scene, it is possible to unload that scene and then load a new Game Scene, perhaps with a different name.
**I expect I will be able to rely on the non-relational nature of LiteDB to provide a lot of value here, but it's far too early to say for sure. Ideally, whatever migration is required can occur in the Preload scene, but in the worst-case it may be necessary to version bump save files from the title screen.
***I am aware that LiteDB provides built-in recovery methods, but I have yet to really explore this to find its limits and determine what else will be needed on top of this.
****If this topic interests you, I've written a bit more about my opinions on architecture in an earlier blog post.
This concludes the exploration of the architecture that supports making the game world reflect the save data when it first loads. Hopefully this two-part exploration has been useful to you. If you have any questions or have a particular topic you'd like to hear about please let me know.