One-Click Save Game System -- Part 2
Part-1: https://ourmachinery.com/post/save-game-system-part-1/
With the basic prototype up and running and a clearer view of the requirements, the next challenge was to integrate the system with the engine, specifically with our Entity Component System (ECS).
Requirements, part 2
The ECS is the core system that runs the runtime simulation. For many games, the “state” of the game is exactly the state of the ECS (what entities exist and what the values of their component structs are). Therefore, how the Gamestate interoperates with the ECS is really important.
(Note though, that this is not true for all games. For example, the State of a chess game might be represented by the sequence of moves that brought the board to its current state, rather than by the positions of the Entities that represent the chess pieces in the 3D world. )
We went back to the drawing board and brainstormed the use cases for the ECS:
-
It would be nice if the ECS could automatically store its state to and restore it from the Gamestate. That way, any game that used the ECS would get Save Game support “for free”. A game that wasn’t designed with Save Game support in mind, could just “turn on” the feature without having to make any big changes to the game code.
-
Since our ECS is extensible and allows for user-defined Components, we need a way for the developer of a component to tell the ECS how the component should be saved and restored. (Note that in some cases this might be as simple as just saving and restoring the raw bytes of the component’s runtime data.) The ECS needs to provide a configuration API to specify how a component can be persisted:
typedef struct tm_component_persistence_i { // Name that the component will have in the Persistent Gamestate. const char *name; // Size that the component will have in the Persistent Gamestate. uint32_t size; uint32_t member_count; struct tm_gamestate_member_t *members; // Callback to serialize the specified component to `buffer`. void (*serialize)(struct tm_entity_context_o *ctx, tm_entity_t e, tm_component_type_t component, void *buffer, uint32_t buffer_size); // Callback to deserialize the specified buffer to the component. void (*deserialize)(struct tm_entity_context_o *ctx, tm_entity_t e, tm_component_type_t component, void *buffer, uint32_t buffer_size); } tm_component_persistence_i;
-
The Game should be able to decide whether an Entity should be considered Persistent or not. (Non-persistent entities might be used for special effects, static objects, or other things that do not need to be saved.)
-
Entity Assets in The Machinery are really entity hierarchies — they can contain child entities and the child entities may themselves have children. So we need to consider how the entire hierarchy works with persistence. We decided that you cannot mix and match persistence for individual child entities in the hierarchy — the entire entity asset is either completely persistent or completely non-persistent (we may change this in the future).
Implementation, part 2
The time had come. I created a project with some simple entities with the goal of being able to Save and Load this project via the Gamestate.
Once again, I started by sketching out what the ECS API for serialization should look like (note that the three main phases we talked about in part one are still clearly defined):
// Reminder: a `tm_entity_t` is basically how Entities are identified in our Entity Context.
// Configuration
void (*configure_component_persistence)(tm_entity_context_o *ctx, tm_component_type_t c, tm_tt_component_persistence_aspect_i *persistence);
// Runtime
void (*gamestate_float_member_changed)(tm_entity_context_o *ctx, tm_entity_t e, tm_component_type_t c, const char *member, float value);
// The Entity Context will automatically do the two lookups for you! you don't need to worry about anything "persistence-related".
void (*gamestate_reference_member_changed)(tm_entity_context_o *ctx, tm_entity_t e, tm_component_type_t c, const char *member_name, tm_entity_t ref);
// ... Other types here...
// One-time actions
uint8_t *(*dump_persistent_gamestate)(tm_entity_context_o *ctx, uint32_t *written, struct tm_allocator_i *a);
void (*load_persistent_gamestate)(tm_entity_context_o *ctx, uint8_t *state, uint32_t size);
And proceeded to implement them:
At this point, I made a key realization about the system. It should be able to both:
-
Keep track of changes that happen to a specific piece of data, without maintaining a copy of it (it will just keep the change you pushed, without needing a full copy of the structure).
-
Keep a copy of the entire data structure, if that’s what the user wants.
To better illustrate the point, think about an entity that was part of a world procedurally generated from a seed: we don’t need to store the full state, because we will be able to generate the same state starting from the same seed. All we need to store is whatever changes happened after the procedural generation step. For static entities, that never change and can’t be destroyed, we don’t need to store anything, apart from the seed that is used to generate them, of course.
On the other hand, if the user creates an entity “manually” — by spawning a completely empty entity and then manually adding components to it, we need to store the full state of that entity. Or, in another way of looking at it, in this case, the “initial state” is an empty entity, so all the components that were added should be considered changes from this initial state.
In the procedural world depicted in the image above, there’s no need to store anything about the trees, but we have to store the entire state of the Horse, as we wouldn’t able to recreate this world exactly as it is otherwise. (We would be able to recreate the trees thanks to the seed, but we wouldn’t have all the data about the horse.)
After implementing this (I won’t bother with the actual implementation steps, feel free to ping me if you want to know more), our system was ready to handle both procedurally generated entities and entities generated “from scratch” at runtime,q using the same API: it keeps track of just the changes for the former while keeping a full copy of the state for the latter. And thanks to the ECS, this all happens automatically “behind the scenes” without any user intervention.
That’s everything for now, rest yourself as next time we’ll take a look at the third (and last) iteration of the system.