This post is a Celestial Combat devlog and also be found here.
Celestial Combat is a game large in ambition and scope. Battles often span multiple solar systems with hundreds of spacecraft and planets with caches of materials for war production.
As the gameplay solidified, finding a way to persist player progress grew more important. Saving score and progress within a battle means players are likely to come back to the game.
Serialization
At the heart of saved games is a computer science concept called serialization. When you serialize the state of a program, you are turning the things represented in memory into a series of bytes. They can be a certain format: e.g. JSON or a predefined binary structure.
The ECS architecture of Celestial Combat makes serialization easy.
Game entities are all very basic classes that inherit a set of
properties such as position (x,y) and a type
field from the LivingEntity
base class,
then add their own properties to them, mostly fields relating to Components within the ECS pattern.
The vast majority of these can be serialized via JSON.stringify(...)
but what about
fields that serve as references to other game entities? For instance, a planet that is
occupied by a faction has references to its planetary structures:
class Planet extends LivingEntity {
// Simple serialization
canOrbitStar = true;
canStoreMaterial = true;
materialsRaw = 0;
materialsFinished = 0;
hp = 2000;
mass = 180;
...
// JSON.stringify(planetInstance) will fail here
star?: Star;
pbase?: PBase;
plab?: PLab;
pcolony?: PColony;
pcomm?: PComm;
spacedock?: SpaceDock;
sensorarray?: SensorArray;
spaceport?: SpacePort;
...
}
Handling references was not immediately straightforward and to implement saving games, we would need to solve this problem and preserve those references somehow.
Creation IDs
If we can recognize that a game state is essentially an instance of a relational database, then we have a simple solution for this: use auto-incrementing primary keys or IDs to uniquely map references from one entity to another. In Celestial Combat, an ID is a number.
These changes were done in a few steps:
-
Within the
create()
ofsrc/client/Entity/index.ts
, we need to assign an ID field that is auto-incrementing. Its value is not mutated during the course of the game. -
This field needs to be set to a specific value when loading (deserializing) a saved game. Make it auto-increment by default or have it take a
creationId
just like any other field during thecreate()
call. -
Use of Components (as part of ECS) adds specific references to other entities to implement its behavior.
const F_
insidesrc/client/GameState/getEntityProperties.ts
defines a serialization map for this purpose. -
Entities are made up of Components. So map an entire Entity to a set of such Components.
const FIELDS_BY_ENTITY
insrc/client/GameState/getEntityProperties.ts
.
Deserialization
Once serialized, the game will need some way of recreating those references when loading a saved game. The game can do this in 2 passes:
-
Deserialize the saved game data into an intermediate game state. All entities are created and have their non-reference fields filled in. Reference fields are not yet linked and remain represented as numbers.
-
Inflate the references. From a collection of all entities, find by ID and set the values of the reference fields: numbers are mapped to entity instances.
function inflateReferencesForEntity(entity, index, allEntities) {
Object.keys(entity)
.forEach(prop => {
const value = entity[prop];
if (PROPERTY_IS_REFERENCE.test(prop) && !isNaN(value)) {
entity[prop] = allEntities.find(e => e._creationId === value);
}
});
}
function deserialize(json: string): void {
const all = JSON.parse(json);
let maxCreationId = 0;
Entity.clearAll();
// First pass, create all the entities, leaving references as numbers
all.forEach(e => {
Entity.create(e.type, e);
if (maxCreationId < e._creationId) {
maxCreationId = e._creationId;
}
});
...
// Second pass, inflate references
Entity.getAll().forEach(inflateReferencesForEntity);
...
}
Saved game storage
Currently, Celestial Combat stores only supports storing a single saved game
as compressed JSON in localStorage. It uses the lz-string
compression package
because:
- it is pure JS
- has no dependencies of its own
- is space-efficient (meant for use with localStorage)
Of course, we also want to save other info about a game session, not just entities:
- rank
- score
- number of consecutive wins / losses (for game over)
These are easily handled by JSON.stringify()
and JSON.parse()
and stored in the same
manner. Each being assigned its own localStorage key.
You can find the all source code for saving / loading games under
src/client/GameState
.
Thanks for reading!