Posted On: 2019-10-28
One of the fascinating properties of programming is how a single problem can appear insurmountable, easy, and nuanced - each depending on how one looks at the problem. The save system has, at different times, appeared to match all three.
Over the past few weeks, the challenges of the missing constraints of the save system had all but convinced me it was an impossible problem - until I was able to dramatically shift my perspective and approach it completely differently. Once I looked at it in that new way, all the old problems disappeared, and it seemed so simple that I couldn't help but laugh at myself for all the difficulty it caused me. Yet, as I write this post to explore the properties of the new solution in detail, I am beginning to see the cracks in it - the subtle nuances that will distract or disrupt future efforts that depend upon the save system gliding along smoothly.
As described in The Missing Constraints: the primary problem that needed to be solved was the challenge of saving all information about the game's state, with minimal coding effort. At the time, I was maintaining multiple different persistence scripts, one for each category of entity that could be saved (actor, terrain, interactable, etc). Unfortunately, this led to numerous mistakes, as each property that needed to be saved or restored had to be coded in that script, and some problems (such as synchronizing the physics when loading objects) had its solution duplicated across multiple scripts, often conditionally (ie. some interactables were affected by gravity, others were not.)
After several failed attempts to collapse all the persistence scripts together into a single script- one compatible with every kind of entity - I realized that the fundamental problem that I was facing was an architectural flaw. So long as the persistence script was responsible for locating the data that it needed to save, it would always require updates as new feature are added: locating the affected component(s) and mapping their properties onto the DTO used for saving could not be simplified, as the location of each component (not to mention its data layout) would vary according to the needs of the individual feature. If, however, the persistence script was able to delegate the save logic to each individual feature, then the design would be simplified: the persistence script would remain identical, no matter how complex the feature was; instead, the new feature would be responsible for implementing its own save logic. Importantly, this is implemented through the use of an interface: thus, the compiler will enforce that any feature that should be saved (and therefore implements the interface) must have an implementation.
This architecture, a central system delegating work to other systems, is nothing new. Nor is applying this pattern to the save system new. After all, this is (roughly) the pattern that I used when designing the Scene Change Watcher: most of its work is delegated to persistence scripts in order to simplify the implementation of the watcher. Having the persistence delegate to individual features is simply the extension of that same idea.
This approach, of course, has some potential pitfalls - every solution does. Yet, enumerating these pitfalls is an essential practice: understanding the limits and weaknesses of a solution can inform which aspects one must remain vigilant about. Further still, if the set of pitfalls is too great, they can define new criteria as one seeks further solutions.
The first pitfall is the simple risk of human error. If I implement a feature and forget/choose not to implement the interface, that would likely introduce bugs. Similarly, if I am modifying an existing component and I forget to also modify the save/load for that component, that would also produce errors. While many of the possible human errors are mitigated by keeping the save logic close to the implementation logic, this is still fundamentally an "opt-in" approach, which means more opportunities for mistakes than I would like.*
The second potential pitfall is related to interacting with third-party code. Many systems, such as the physics or animation, rely on state information that is maintained and managed by closed-source components. Since I do not control the components' code, using an interface-based approach will be difficult (if not impossible.) It may be possible to work around these limitations with additional code, but the complexity will vary based on how malleable the third-party component is - thus making the introduction of new third-party carry even more uncertain risk than it already does.
The last potential pitfall is versioning the save system: at present, I have given little thought to how to migrate old save data into newer versions of the code. In general, I am assuming that the more centralized the save and load process, the easier it will be to author migration scripts (ie. taking an older save file and making changes so that it is compatible with the newer version of the game.) As such, delegating the save/load code so that it is alongside the feature implementation makes the system less centralized and potentially more difficult to accurately migrate. On the other hand, there are still several centralized parts of the save systems architecture (such as the Save Slot and Preloader) so it is possible that migration logic may be connected to those parts instead.
As you can see, a single problem can appear radically different depending on how one is thinking about the problem. Things that appear insurmountable may give way to simple solutions with the right mindset, and things that seem easy turn out to be more nuanced under closer examination.
While this is most directly applicable to the development cycle of software, it is, I think, also applicable to many non-software systems. Whether it's coordinating workers to keep a train on schedule, or organizing a bookshelf, one can find seemingly simple solutions by changing their perspective (using self-driving trains to reduce staff requirments or removing the bookshelf's backing so twice as many books fit). Just the same, those solutions will have nuances and pitfalls that were not apparent when first changing perspectives (dealing with foreign objects on the tracks or the bookshelf becoming more susceptible to ground tremors).