One-Click Save Game System -- Part 3
Requirements, part 3
Our very simple project was now working as expected, but there were a bunch of issues we still had to address:
-
We wanted the user to be able to easily define in the editor whether their Entities should be considered Persistent or not.
-
How should the system deal with our entity assets? What happens if an asset changes between a Save and a Load of the state?
-
What can be done to help users with the Configuration step for their custom components?
-
Can we make it so that it’s the gamestate that generates a unique persistent_id instead of having the user create them?
Implementation, part 3
We decided that the best way to answer those questions was to make one of our actual samples use the Gamestate API. We decided to make the Pong sample “Persistent-ready”, and after quite a while of experimentation we got there, together with the answers we were looking for:
-
We decided to make it possible for the user to toggle persistence on and off for individual assets via the entity tree. That way, the game code doesn’t have to care at all whether entities should be persistent or not, it’s all set up in the asset, via the editor itself.
-
We decided that when an entity is created from an asset, the Gamestate should only record the creation of the root entity of the hierarchy (originally we had it record the creation of all the entities in the asset’s entity tree). In the original design, the entire hierarchy of the asset was “frozen” into the saved game, whereas with the new design, we just save a reference to the asset and on Load, the entity is respawned from the asset. The new approach reduces the amount of data we need to save by a lot. It also changes how the system behaves when an asset changes between Save and Load (for example if the game is patched). With the old design, the asset in the saved game would not be affected by the changes in the patch, since its original state would be fully serialized. With the new design, the serialized asset would get the updates from the patch. You can argue about which is the more “correct” behavior, but I think in most cases, the latter is preferable. If you make changes to a game you want the users to see those changes, even if they are playing from a saved game.
As an example, suppose that an entity is spawned from a prototype Entity Asset. In this case, we won’t store the full state of the entity, because we can re-create it at load time by spawning the same asset. To be able to restore the state, all we need to store are the changes that happened after the asset was spawned. The downside of this is that if the hierarchy of the asset changes in-between saving and loading the Gamestate, and some children of the root entity did actually get some modifications that needed to be saved to the Gamestate, we won’t be able to match those entities with the new asset, as the hierarchy doesn’t match anymore.
-
When the runtime data of a component is a simple POD-struct, instead of using the
serialize()
anddeserialize()
functions, we can just tell the ECS that we want to use the runtime data as the serialized data too, and the ECS can take care of all the work of reflecting the component to the Gamestate. This should work as a convenient default for most components.In the cases where this is not enough (or if the component is not a simple POD) the user can implement their own “change tracking” protocol to push data changes to the Gamestate.
// This may happen in one of your plugins if your component is a simple POD. // If your component it's not a simple POD, or if you want to implement a // "smart" change tracking algorithm, then you will have to specify // serialization/deserialization and the members of the component. struct tm_health_component_t { float health; }; // We were able to leverage the xero-is-initialization concept here. // So by default, a POD component can just pass a `zeroed` persistence and it // will behave as expected. static tm_component_persistence_i *health_component_persistence = &(tm_component_persistence_i){0}; static void create(struct tm_entity_context_o *ctx) { tm_component_i component = { .name = TM_TT_TYPE__HEALTH_COMPONENT, .bytes = sizeof(tm_health_component_t), .persistence = health_component_persistence, }; tm_component_type_t c = tm_entity_api->register_component(ctx, &component); } TM_DLL_EXPORT void tm_load_plugin(struct tm_api_registry_api *reg, bool load) { tm_add_or_remove_implementation(reg, load, TM_ENTITY_CREATE_COMPONENT_INTERFACE_NAME, create); }
Expanding on this a bit, we also allow the user to “override” the serialization behavior for components. For example, if your game only cares about the position of objects (and doesn’t need rotation or scale) you can override the default Transform component serialization so that only positions are saved.
This also lets the user implement serialization for third-party components where the creator of the component neglected to implement serialization support.
-
We really wanted this feature as that would mean not exposing one of the main concepts of the system to the final users, but we were worried that this might not be possible if we wanted to support very big procedurally generated worlds, or more complicated “mixed” worlds in which some entities are procedurally generated, and some aren’t.
The problem with letting the Gamestate generate IDs on its own is that it doesn’t know whether something in the procedural generation algorithm actually changed: this means that if the procedural generation algorithm changes between save and load, the IDs that the Gamestate is holding will potentially point to the wrong entities, and it won’t be possible to correctly restore the world. This means that developers have to make sure that all the asset versions that are referenced from previous versions of the game are still present in newer versions: you cannot just “replace” an asset, as older saved games might point to it, which could potentially lead to inconsistencies in the world.
Conclusions
This concludes our little tour of the design process of our Save Game system. Every game made with The Machinery that doesn’t use “custom” components can now be made instantly ready to be serialized/deserialized to disk by just flagging the root Entity of the game as persistent. (The “World” Entity, basically.) You can quickly do that via the Entity Tree.
If your game has custom components that are not part of the Engine already, you will need to configure the Component’s persistence via the Entity Context. (And setup serialization/deserialization callbacks if the component it’s not a simple POD.)
There are a couple of things that are worth mentioning:
-
I haven’t talked at all about “migration” of saved game data or how “patching” a pre-existing saved game will work. I did that on purpose because… well… we’re still thinking about how to do that in a nice way. If you have any good suggestions or if you know about a system that did that particularly well don’t hesitate to ping us.
-
I skipped a lot of implementation details that some of you may consider quite relevant. I apologize for that but the system took quite some time to design and develop, and I honestly think that the most interesting part is in the evolution of the requirements and the APIs, not the implementation details. Still, if you want to know more let me know and I’d be happy to answer any question you might have.
-
It’s not actually true that the system doesn’t handle asset changes across save-reload. We’re using UUIDs to track Entities in asset hierarchies, and so even if the asset changes the system is able to “match” the entity that is being restored. This means that in most situations you can safely change the Entity assets of your game and you’ll be fine.
With that said, I hope you enjoyed the tour of the new Gamestate system, let us know what you think and how it feels to use it.
See ya!