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:
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.)
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.
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 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.
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.****)
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.