Standard of Iron stores match saves in a versioned SQLite database. A save is more than a serialized entity list: it combines the authoritative battlefield, non-entity session state, campaign/mission metadata, and a preview image, then stores that snapshot with compression, checksums, transactional database writes, schema versioning, migration, integrity checking, recovery, and autosave retention.
The save system is designed around a clear ownership rule: persist state that changes the meaning of the match, rebuild state that is derived, and keep renderer/UI-only state outside simulation authority.
The implementation is split by responsibility:
| Area | Source | Responsibility |
| World serialization | game/save/serialization.* |
entity/component world document |
| Session snapshot | game/session/session_snapshot.* |
authoritative non-entity match state |
| Snapshot contract | game/save/snapshot_contract.* |
field classification and version contract |
| Application coordination | app/persistence/save_load_coordinator.* |
capture/restore orchestration around the live game |
| Async save service | game/systems/save_load_service.* |
queued jobs, slots, progress, cancellation, verification |
| SQLite storage | game/systems/save_storage.* |
schema, transactions, migration, recovery, campaign tables |
| Payload format | game/systems/save_format.* |
compression, checksums, packed world payload |
These layers deliberately separate “what constitutes the match” from “how bytes are stored.”
game/save/snapshot_contract.cpp classifies runtime state into four categories.
State that affects the meaning or future evolution of the match is persisted explicitly.
Examples include:
Game::Session::SessionSnapshot, including AI and mission/victory state where registered.If losing a value would change what happens after load, that value belongs in the authoritative contract or must be derivable from other authoritative state.
Derived runtime data is rebuilt after restoration instead of being serialized as a second authority.
Examples include:
The command queue is also rebuilt empty. Commands that have not executed when the save is captured are not replayed after loading.
That is an important semantic boundary: the save captures the authoritative state at the save point, not a speculative set of queued future mutations.
Renderer and UI presentation are not part of simulation authority.
Transient examples include:
Some player-facing presentation state, such as camera state, can be restored separately for continuity without making it authoritative gameplay state.
The database also stores information that describes the save slot rather than entity simulation state, including:
Campaign completion and mission result tables live in the same storage layer but have their own schema and lifecycle.
SaveLoadCoordinator is the application-facing capture boundary.
It captures the world/session state on the owning thread, prepares the metadata needed for the slot, and submits a SaveLoadService::SaveRequest to the background save service.
The expensive storage work can then happen off the live gameplay path because the worker operates on the captured save document rather than continuing to read the mutable world.
This boundary is what makes asynchronous save writes safe: the worker is writing a snapshot, not sharing authority over live entities.
SaveLoadService queues save work on its worker thread and reports progress through save_progress.
The current stages are:
Save::pack() creates the stored payload and integrity metadata.SaveStorage writes the slot transactionally.save_finished is emitted.These stages describe the current worker pipeline rather than a UI-only progress animation.
The service also exposes:
pending_save_count(); andwait_for_pending_saves() for shutdown or other synchronization points.A caller can therefore distinguish “save requested” from “save durably completed” and can prevent application shutdown from abandoning queued work.
A screenshot/preview can be attached as a separate queued job after the slot row exists. The preview is metadata for browsing saves; it is not part of the authoritative world payload.
game/systems/save_format.cpp owns packing and unpacking of the world payload.
The stored save row records enough information to validate both the compressed representation and the reconstructed world bytes:
The current format supports zlib compression.
The stored-blob checksum catches corruption of the representation that was written to SQLite. The uncompressed-world checksum verifies the bytes obtained after unpacking.
That distinction makes corruption detectable on both sides of decompression instead of assuming that a successful decompression implies a valid save document.
Save::unpack() verifies the stored data before JSON deserialization proceeds.
SaveLoadService::verify_save_slot() exposes slot verification through the storage layer without requiring a full live-game replacement.
The save database is named:
saves.sqlite
and lives under the application's save directory.
SaveStorage configures SQLite with:
synchronous=FULL; andThe database is used for both match saves and campaign/mission persistence.
The current schema includes:
saves — match snapshots, slot metadata, packed payload, and preview;campaign_progress — campaign-level completion/progression state;campaign_missions — per-mission unlock/completion state; andmission_results — recorded mission outcomes.SaveStorage::schema_shape_is_current() checks the expected table/column shape rather than trusting only the numeric schema version.
Two different version numbers describe two different contracts.
SQLite PRAGMA user_version describes the database schema.
game/systems/save_format.h defines k_database_schema_version, currently:
3
This version controls whether the SQLite tables/columns need migration.
Each save row also carries a snapshot_version describing the world/session snapshot format stored in that row.
A database can therefore have the current table schema while still containing snapshot rows written under an older snapshot contract.
Keeping these versions separate avoids conflating “the table layout changed” with “the serialized gameplay document changed.”
When SaveStorage opens an older supported database schema, it performs a guarded migration instead of simply discarding the file.
The migration flow is:
<path>.v<version>-backup;migrate_schema() inside a transaction; andPRAGMA user_version to the current schema.The backup is created before migration so the pre-migration file remains available if the upgrade cannot be completed.
A schema mismatch is therefore not automatically destructive. Current storage code has a real migration path for supported older versions.
Opening a save database is also a validation step.
SaveStorage::initialize() checks that the file can be opened, runs an integrity check, and validates the schema/version shape.
A database can be rejected when it is:
In those cases the original file is moved aside rather than silently deleted.
The quarantine name uses a timestamped form containing:
.unreadable-...
and a fresh database is created for continued operation.
SaveLoadService::quarantined_database_path() exposes the moved-aside path so the application can report where the original data was preserved.
This recovery behavior is different from claiming that the old database was successfully migrated. Quarantine means the current runtime refused to trust it as the active save database and preserved it for inspection/recovery.
Loading is deliberately staged so a bad save does not immediately destroy the currently running match.
SaveLoadService::load_game_from_slot() performs the storage/document validation path:
SessionContext;The temporary session is a safety boundary. A malformed or semantically unusable document can fail before the live battlefield is replaced.
If staging fails, the current battle remains intact.
That includes failures such as:
Once the live world is cleared, a failure during final deserialization is reported and the world remains cleared rather than pretending that the old battle is still present.
This distinction is important: the staging pass protects against known-invalid saves, but the final restore is still an authoritative replacement boundary.
After the entity world is restored, SaveLoadCoordinator restores the remaining session snapshot against the loaded map and rebuilds the derived runtime services required by the new world.
The loaded match therefore consists of both:
Restoring one without the other would leave deterministic RNG, AI/victory state, visibility knowledge, or other registered session facts inconsistent with the world.
The renderer consumes published render snapshots rather than the mutable world.
After loading, the restored entities exist in simulation, but the renderer still needs a new published snapshot representing that world. The runtime resumes in an unpaused state so the first simulation tick can publish fresh presentation data and rebuild transient systems.
This is a real part of load correctness: a successfully deserialized world that never publishes a new render snapshot would still present stale or empty visual state.
World clearing advances the world's content epoch.
Saved entity IDs can reuse the same index/generation values as entities that existed before the load. Without an additional world-content boundary, renderer-side slot caches could mistake a restored entity for the old entity that occupied the same handle.
Advancing the content epoch invalidates those stale render-snapshot associations.
tests/core/save_load_render_snapshot_test.cpp and tests/core/save_runtime_restore_test.cpp exercise the post-load publication/restoration path.
Explored fog is player knowledge, not merely a function of the units that are currently alive.
GameStateSerializer stores the exploration mask in save metadata using run-length encoding plus base64.
GameStateSerializer::restore_visibility_from_metadata() applies the mask after the world and visibility grid exist.
The restoration is additive:
Unseen cells into Explored;If the saved mask dimensions do not match the restored map, the mask is ignored with a warning rather than making the entire save unloadable.
The save snapshot preserves deterministic simulation state required for the match to continue consistently, including the simulation clock and deterministic RNG state.
The command queue is not persisted, which means determinism after load begins from the committed state represented by the save rather than from commands that were waiting to execute at the instant capture occurred.
This is also why replay and save contracts overlap conceptually but are not the same artifact: a replay stores an accepted command stream and digests, while a save stores a complete restorable state snapshot.
Autosaves use the same packed world format and storage pipeline as manual saves.
The differences are slot policy and retention rather than serialization semantics.
After a successful autosave, the service prunes old autosave slots according to the supported retention range. Manual and quick-save slots are not transformed into a different world format simply because they belong to another save kind.
All save kinds share:
They differ primarily in naming/selection behavior, UI flow, and retention rules.
Keeping one payload path prevents a “quick save format” from drifting away from a “manual save format.”
Campaign progression is stored in the database alongside match saves but is not embedded into every entity snapshot.
The storage layer maintains campaign progression, mission completion/unlock state, and mission result records in dedicated tables.
That keeps campaign-level facts separate from battlefield ECS state while still giving the application one persistence service for the player's local progress.
Because save writes are queued, application shutdown and destructive transitions need to account for outstanding work.
pending_save_count() exposes whether jobs remain. wait_for_pending_saves() provides the explicit synchronization point used when the process must not exit while a requested save is still being written.
Cancellation is job-based rather than a global “throw away all persistence” switch.
The worker owns storage work; it does not become another thread mutating the live world.
The snapshot contract is as important for what it excludes as for what it stores.
Examples of intentionally rebuilt or omitted state include:
Serializing these would make restoration harder to reason about because the loaded game would need to decide whether the persisted cache or the authoritative components were correct.
A save/load problem can usually be localized to one layer.
Inspect database initialization, schema shape, migration, quarantine state, and the slot row.
Inspect packed-payload checksum, compression mode, uncompressed size, and unpacking.
Inspect the serialized world shape, entity list, component decoding, and staged-world validation.
Inspect final deserialization, session snapshot restoration, map-dependent services, and derived-state rebuild.
Inspect content epoch/render-snapshot publication and the first post-load simulation tick.
Inspect visibility metadata dimensions and restore_visibility_from_metadata().
This layered diagnosis mirrors the actual architecture and avoids treating every load failure as a generic database problem.
The save contract is exercised at several levels:
tests/db/save_storage_test.cpp — schema, migration, quarantine/recovery, slot storage, and database behavior;tests/save/snapshot_contract_test.cpp — snapshot classification/versioning;tests/core/serialization_test.cpp — entity/component world serialization;tests/core/save_load_render_snapshot_test.cpp — render publication after load; andtests/core/save_runtime_restore_test.cpp — runtime/session restoration behavior.Background save jobs, progress stages, compression, integrity checks, database migration, and quarantine are current implemented behavior and should be documented as such.
The current save/load design depends on these invariants:
| Concern | Source |
| Entity/world serialization | game/save/serialization.* |
| Authoritative non-entity snapshot | game/session/session_snapshot.* |
| Snapshot classification | game/save/snapshot_contract.* |
| Save job queue | game/systems/save_load_service.* |
| SQLite storage/migration | game/systems/save_storage.* |
| Compression/checksums | game/systems/save_format.* |
| Application restore orchestration | app/persistence/save_load_coordinator.* |
| Storage tests | tests/db/save_storage_test.cpp |
| Restore/render tests | tests/core/save_* |
The storage, migration, compression, asynchronous write, integrity, and recovery paths described here are present in the current repository. They are not proposed enhancements.