Save/Load
The Save/Load system persists selected gameplay state over an authored scene or prefab. It supports manual saves, quick saves, checkpoints, independent profile data, and cloud integration hooks.
The normal entry point is plGameState::GetSaveGame(). Every game state owns one service from construction to destruction. Changing its main scene keeps the service, registered schemas and pending I/O alive. plGameApplication delivers native completions after rendering drains. Deactivation cancels outstanding operations and suppresses callbacks into the old state. Standalone users of plSaveGame must call Update() themselves.
Configure a game
Include <GameEngine/SaveGame/SaveGame.h> and initialize in your derived game state's activation/startup code, after the application mounts its file system:
auto& saves = GetSaveGame();
if (!saves.IsInitialized())
{
const auto result = saves.Initialize("my-game");
// Handle a non-Success result before offering Save/Load in the UI.
}
The service always exists, but does not create files until initialized. Use a stable project identifier, not a localized or changeable display name. Identifiers are ASCII letters, digits, underscores and hyphens, at most 64 characters; reserved Windows device names are rejected and identifiers are normalized to lowercase. The root defaults to :appdata/; tests may supply another writable mount.
:appdata/my-game/SaveGame/
project.lock
Profiles/<profile-uuid>/
Profile/ # Independent account/progression document
Slots/<slot-uuid>/ # Manual, quick or checkpoint slot
Do not create another storage instance for the same project. An exclusive project lease prevents competing writers. Native and managed clients share the game-state service. Use GetStorage() to retain its thread-safe storage handle for cloud I/O.
Profiles and slots have stable UUIDs. Keep the selected profile and save policy in your game's UI/state; the engine does not choose them. Reinitializing an initialized native service returns InvalidArgument. Renaming a project's save identifier requires an explicit data migration.
What is saved
The authored scene/prefab supplies the base content. A save contains marked objects' selected persistent state, stable identities, transform/parent changes, registered runtime prefab spawns and tombstones for tracked objects that were destroyed. It does not serialize the entire live world or automatically save every component.
Add plPersistenceComponent to each object that needs persistence. The editor assigns its stable identity during conversion and exports it through the existing scene serializer. Leave SaveTransform enabled for position, rotation and scale. Enable SavePhysics for a Jolt dynamic body's linear/angular velocity and sleeping state, and SaveBlackboard for a local blackboard's entries carrying the Save flag. Other component state is selected through the marker's Schemas list and registered adapters.
Existing SerializeComponent/DeserializeComponent functions still construct the base scene. They are not a guarantee that transient runtime fields are serialized. Save adapters explicitly capture supported runtime state into a versioned graph. The implementation stores selected state snapshots over the base, rather than a byte-level diff of component streams. Unmarked objects and unselected component fields come from the base scene on load.
Save a physics box
- Create a dynamic game object with a Jolt dynamic actor and box shape.
- Add a Persistence component. Keep SaveTransform enabled and enable SavePhysics.
- Export the scene with the updated editor so the persistence identity is included.
- Save the scene using the game-state service. No custom physics serializer is required.
The base scene supplies the shape, mass and other authored configuration; the save restores the transform, velocity and sleeping state before the restored physics simulation starts. Additional changing fields need an adapter. A runtime-created box must come from a single-root prefab with persistence markers, and must be registered using plSaveGameWorldModule::RegisterSpawn(root, prefabPath, uniqueSpawnId) immediately after instantiation. Do not reuse an authored identity for independent runtime spawns.
plGameState begins baseline tracking after normal scene/player setup. If you override OnChangedMainWorld, call the base implementation or call BeginTracking() yourself before gameplay can destroy persistent objects. Standalone worlds need the same explicit call under their write marker.
Native save and load
plSaveGameMetadata metadata;
metadata.m_Profile = selectedProfile; // Persistent UUID chosen by the game
metadata.m_Slot = selectedSlot;
metadata.m_sDisplayName = "Before the bridge";
metadata.m_sScene = sceneResourcePath;
metadata.m_sContentVersion = "1";
auto operation = GetSaveGame().Save(*GetMainWorld(), metadata, expectedRevision,
{}, [](const plSaveGameOperation& completed)
{
// On success, retain completed.GetRevision() for the next overwrite.
// Otherwise show completed.GetResult() without losing the previous save.
});
An invalid expected revision means create-only. For an overwrite or delete, read and supply the current revision. A stale revision returns Conflict. Metadata and thumbnails can be requested without constructing a world; the current storage reader still reads/checksums the complete envelope, though it skips graph deserialization for these requests. PNG bytes are supplied by the caller; automatic screenshot capture is not included.
For native loading, call Load(profile, slot), retain a plSaveGameRestore(service, operation, expectedContentVersion), and tick it on the main thread. When it finishes successfully, pass TakeWorld() to QueueSavedWorld() and check that result. The application activates it after rendering drains. The current world survives all failures before activation. Only one world can be queued at a time. Cancel the restore when its owning UI/game state is discarded.
Restore reloads the base scene, migrates and validates saved schemas, applies state in a separate non-simulating world, fixes references, then permits activation. It suppresses normal player-start spawning for the restored world. Component initialization must avoid irreversible external effects before activation; disabling simulation alone does not suppress every initialization callback.
QuickSave uses a deterministic quick-slot UUID. RequestCheckpoint uses a deterministic ring slot; the default ring has three entries, configurable to 1-32 via SetCheckpointCount before requests. The game advances its ring cursor only after a successful commit. SaveProfile and loading with an invalid slot UUID access the separate profile graph. Slot loading and deletion do not overwrite that graph. Application-specific profile migrations are the game's responsibility.
Custom component schemas
Implement plPersistenceAdapter with a stable schema name/version, Capture, Validate, Restore and, if needed, FixReferences. Register before play with RegisterOwnedAdapter; the service owns the adapter across scene changes. A non-owning registry registration is also available, but the caller must keep the adapter alive. Add the schema name to each participating object's marker. All selected schemas are required: unknown schemas, future versions and missing required content reject the load.
plPropertyPersistenceAdapter is a convenience for an explicit list of writable reflected member properties on one component instance. Custom collections, runtime fields and object references need a custom adapter. Persist object references as stable persistence UUIDs and resolve them in FixReferences, after all objects exist. Multiple components of the same type on one object require a custom disambiguation policy.
To change a schema, increment its version and register consecutive plGraphPatch node migrations. Saved type descriptors drive migration, rather than the current RTTI version. Keep patches for every supported shipped version. Validation runs after migration and before applying state. The wire format accepts bounded value types, arrays and dictionaries, and rejects raw pointers/custom typed objects.
C# access
The native bridge and managed surface live in the local plasma.csharp package. After native initialization:
var saves = SaveGame.FromGameState(this); // 'this' is the owning ComponentScript
// In a script update on the engine thread:
saves.Update();
// From a script callback with a current world:
var result = await saves.SaveAsync(profile, slot, "Before the bridge", scene, "1", revision);
Alternatively, new SaveGame("my-game", this) initializes the active game state's service if necessary and checks the namespace if already configured. FromGameState requires an initialized active service. Keep and reuse a client; do not create one per frame. Dispose it when finished; owner detachment and managed reset dispose associated clients. Disposing one client cancels its own operations without cancelling other clients' work.
Native I/O completions are pumped by the game state, but managed clients still need Update() to poll results and advance staged loads. It completes tasks on the engine thread. Cancellation tokens only signal cancellation from other threads. Save requests need a current script world. Do not access engine objects from thread-pool continuations.
The managed API exposes save/load, metadata, thumbnails, enumeration, deletion, checkpoints and independent JSON profile documents. RegisterScript(schema, version, fieldNames) opts named script variables into a native versioned graph; add that schema to the object's marker. Register once during setup. Schema registration remains owned by the game-state service across script/client changes.
Storage and cloud behavior
Local commits write an immutable, checksummed generation and atomically publish a manifest, retaining the previous generation. Failed/cancelled writes before publication preserve the committed revision. Cancellation after publication reports success. If the current generation is damaged, reads can recover the previous generation and a subsequent save can commit from that recovered revision. A corrupt manifest is reported rather than guessed. Deletion publishes a tombstone; a deleted slot UUID cannot be silently recreated.
Limits include a 64 MiB envelope, 1 MiB thumbnail, 100,000 graph nodes, 64 KiB strings and bounded nesting/collection counts. Invalid optional thumbnails fall back to a placeholder. Storage errors currently use IoError rather than separate disk-full/permission statuses. Atomic replacement has been tested on Windows; physical power-loss durability and other platform backends need separate qualification.
plSaveGameCloudProvider and plSaveGameCloud supply vendor-neutral read, enumeration, compare-and-swap upload/download and deletion hooks. Execute these on I/O tasks. A cloud failure does not invalidate a successful local save. The game/provider owns durable retry scheduling, sync cursors and conflict UI; there is no automatic persisted outbox or vendor SDK integration. Profile JSON can retain application sync policy independently from slots.
Release qualification
Packaged-player and real managed gameplay round trips, shipped-version fixtures, platform and power-loss testing, capture latency and memory measurements on reference hardware, and real cloud-provider integration still require release qualification. Passing the unit tests alone does not establish production readiness.