-
Notifications
You must be signed in to change notification settings - Fork 0
Save System
"Save systems are like backups. Everyone agrees they are important. Nobody invests in them until something breaks. By then, your players have lost their progress and your Steam reviews are on fire."
Every Unreal Engine project needs a save system. The engine provides the primitive: a serializable object that can be written to disk. What the engine does not provide is everything around that primitive that makes persistence actually work in production.
The lifecycle of a save system in a typical project follows a predictable arc. Month 1: one class, one slot, one file. It works. Month 3: format change breaks existing saves. No migration path. Month 6: corrupted saves from partial writes. No checksums. Month 9: multi-slot rewrite, auto-save bolted on top. Month 12: "can we share characters across campaigns?" The monolithic save class cannot accommodate it.
Month 1: [SaveGame] --write--> [Disk] "It works."
Month 12: [SaveGame v7]
[MigrationV1toV2]
[MigrationV2toV3] "It barely works."
[ChecksumValidator]
[SlotManager]
[AutoSaveTimer]
[AsyncIOWrapper]
The pattern: start with the minimum, accrete complexity, never compose it. The result is simultaneously brittle (one format change breaks everything), incomplete (no checksums, no async, no migration chains), and unmaintainable.
The root cause: the engine's save primitive assumes a single monolithic object per slot. Real games need partitioned data, versioned formats, migration paths, integrity validation, async I/O, and multi-slot management.
PGX Save separates concerns into a three-level hierarchy and provides production-grade infrastructure around each level.
Context: "Campaign"
+-------------------------------------------------------+
| |
| Domain: "Player" Domain: "World" |
| +-------------------+ +-------------------+ |
| | Health: 100 | | TimeOfDay: 14:30 | |
| | Inventory: [...] | | NPCStates: [...] | |
| | Level: "Forest" | | Quests: [...] | |
| +-------------------+ +-------------------+ |
| |
| Slot: "Autosave_001" |
| Slot: "Manual_001" |
| Slot: "Quick" |
+-------------------------------------------------------+
Context: "Settings"
+-------------------------------------------------------+
| |
| Domain: "Audio" Domain: "Video" |
| +-------------------+ +-------------------+ |
| | MasterVol: 0.8 | | Resolution: 1080p | |
| | MusicVol: 0.6 | | VSync: true | |
| +-------------------+ +-------------------+ |
| |
| Slot: "UserPrefs" (single-slot context) |
+-------------------------------------------------------+
Context is a project-level partition. "Campaign" and "Settings" are independent save trees. They can have different slot policies, different backup rules, different retention periods.
Domain is a data category within a context. "Player" and "World" are independent data containers. They can have different save game types, different serialization formats, different migration paths. Sharing characters across campaigns means sharing the "Player" domain — not rewriting the save format.
Slot is a specific save file within a context. Multi-slot games have many slots per context. Single-slot games (like settings) have one. The slot is the unit of disk I/O.
+--------------------+ +--------------------+
| In-Memory Layer | | Disk Layer |
| | | |
| Key-value access | | Explicit Save() |
| Immediate reads | | Explicit Load() |
| Immediate writes | | Async variants |
| Per-domain typed | | Compression |
| access | | Checksums |
| | | Versioning |
+--------------------+ +--------------------+
| |
+---------- Decoupled ---------+
The in-memory layer provides key-value access to save data. Read a string, write an integer, get a vector — all immediate, all type-safe, all operating on the in-memory save game object for a specific domain.
The disk layer handles persistence. When the developer (or auto-save) triggers a save, the in-memory data is serialized, compressed, checksummed, and written to disk. When a load is triggered, the reverse happens: disk read, checksum validation, decompression, deserialization, version migration if needed, and population of the in-memory layer.
These layers are decoupled. Game code reads and writes the in-memory layer freely. Disk I/O happens at explicit save/load points (or via auto-save). This means game code never blocks on I/O during normal operation.
Save file on disk: Version 1
Current game version: 3
Load path:
+----------+ +-----------+ +-----------+ +-----------+
| Disk | --> | Validate | --> | Migrate | --> | In-Memory |
| Read | | Checksum | | v1 -> v2 | | (v3) |
+----------+ +-----------+ | v2 -> v3 | +-----------+
+-----------+
Each save file carries a format version number. The Config Data Asset declares the current version and provides a migration chain. When a save file's version does not match the current version, the system applies migrations sequentially: v1 to v2, then v2 to v3. Each migration step is a discrete, testable operation.
This means:
- Players can load saves from any previous version
- Each migration step is isolated — a bug in the v2-to-v3 migration does not affect the v1-to-v2 migration
- The migration chain is explicit and auditable — no hidden version-check if-else trees
Every save operation writes a CRC32 checksum to a sidecar file alongside the save data. Every load operation validates the checksum before deserialization.
Save: [data.sav] + [data.checksum]
Load:
1. Read data.sav
2. Read data.checksum
3. Compute CRC32 of data.sav
4. Compare with stored checksum
5. If mismatch: return Corrupted result (do NOT deserialize)
6. If match: decompress and deserialize
Sidecar files (rather than embedded checksums) mean the validation format is independent of the save format. Legacy saves without checksum files are loaded gracefully with a log message — backward compatibility is preserved.
+--------------------------------+
| Save Subsystem |
| (GameInstanceSubsystem) |
| |
| +----------+ +----------+ |
| | Context | | Slot | |
| | Manager | | Manager | |
| | | | | |
| | Tag -> | | Create | |
| | Domains | | Delete | |
| | Config | | Copy | |
| +----------+ | List | |
| | Metadata | |
| +----------+ +----------+ |
| | IO Engine| |
| | | +----------+ |
| | Sync | | Versioner| |
| | Async | | | |
| | Compress | | Detect | |
| | Checksum | | Migrate | |
| +----------+ | Chain | |
| +----------+ |
| +----------+ |
| | AutoSave | +----------+ |
| | | | Saveable | |
| | Timer | | Registry | |
| | Interval | | | |
| | Guard | | Objects | |
| +----------+ | Callbacks| |
| +----------+ |
+--------------------------------+
|
+-----------------+-----------------+
| |
+-------v--------+ +--------v-------+
| Save Config DA | | Save Game(s) |
| | | |
| Contexts [] | | Per-domain |
| - Tag | | typed instance |
| - Domains [] | | |
| - DomainTag | | Key-value API |
| - SaveClass | | Read/Write ops |
| - Required | | |
| - SaveMode | | Version tracked |
| Auto-save cfg | | |
| Versioning cfg | +----------------+
+----------------+
SAVE: PreSave callbacks -> Serialize -> Compress -> CRC32 -> Write + Sidecar
LOAD: Read -> Validate CRC32 -> Decompress -> Migrate versions -> Deserialize -> PostLoad
Both pipelines operate per-domain within a context and broadcast completion delegates with result codes.
Both save and load have async variants. Serialization runs on the game thread (UObjects are not thread-safe). Compression, checksums, and disk I/O run on a background thread. Completion delegates fire on the game thread. Same pipeline, same guarantees, no blocking.
| Feature | Description |
|---|---|
| Multi-slot | Configurable per context (single-slot, multi-slot, session-based) |
| Slot metadata | Display name, save date, play time, chapter tag, level name, size, corruption flag |
| Slot copy | Duplicate a slot within the same context |
| Slot deletion | Delete with delegate notification |
| Auto-naming | Generate next available slot name based on configurable pattern |
| Quick Save/Load | Convenience wrappers with configurable slot name |
| Feature | Description |
|---|---|
| Typed read/write | String, integer, float, boolean, vector, rotator, transform, gameplay tag |
| Per-domain access | Each domain has its own typed save game instance |
| Domain clearing | Clear all data for a specific domain without affecting others |
| Type-safe retrieval | Compile-time type resolution from domain tag configuration |
| Has-data queries | Check if a domain contains data without loading from disk |
- Game-thread-safe timer with configurable interval
- Guard: auto-save will not trigger during an active save or load operation
- Context-scoped: auto-save targets a specific context, not the entire save tree
- Enable/disable at runtime via API or console command
Objects that implement the Saveable interface receive callbacks during save and load operations:
- PreSave callback: Called before serialization. Objects write their current state to the save game.
- PostLoad callback: Called after deserialization. Objects read their state from the save game.
Registration is explicit (register/unregister calls) to prevent dangling references. Base classes in the framework expose checkboxes in the Details panel — enable "Participate in Save", select a domain, and the base class handles registration automatically.
| Feature | Description |
|---|---|
| CRC32 checksums | Sidecar file alongside each save |
| Validation on load | Both sync and async paths validate before deserializing |
| Corruption detection | Returns explicit "Corrupted" result code — no silent data loss |
| Legacy compatibility | Saves without checksum files load with a warning log |
| Compression | zlib compression for disk size reduction |
The primary configuration asset defines the project's save structure:
Save Config DA
+--------------------------------------------+
| |
| Contexts: |
| +--------------------------------------+ |
| | Context Tag: "Campaign" | |
| | Save Mode: MultiSlot | |
| | Max Slots: 16 | |
| | Domains: | |
| | - "Player" (required) | |
| | - "World" (required) | |
| | - "Statistics" (optional) | |
| +--------------------------------------+ |
| +--------------------------------------+ |
| | Context Tag: "Settings" | |
| | Save Mode: SingleSlot | |
| | Max Slots: 1 | |
| | Domains: | |
| | - "Audio" (required) | |
| | - "Video" (required) | |
| +--------------------------------------+ |
| |
| Auto-Save: |
| | Interval: 300 seconds | |
| | Context: "Campaign" | |
| | Slot Name: "Autosave" | |
| |
| Versioning: |
| | Current Version: 3 | |
| | Quick Save Slot: "QuickSave" | |
| | Enable Checksum: true | |
| | Enable Compression: true | |
+--------------------------------------------+
Each domain entry specifies:
- Domain tag: Gameplay tag identifying the data category
- Save game type: The class used for this domain's data (can be default or custom)
- Display name: Human-readable label for the editor
- Required flag: Whether this domain must be present in every save (validation)
Tags are extensible by the game project. The branches PGX.Save.Domain and PGX.Save.Context are open for project-specific children (e.g., PGX.Save.Domain.Player, PGX.Save.Context.Gameplay). No framework modification needed.
Framework base classes (characters, actors, components) expose save integration directly in the Details panel:
- Participate in Save: Boolean checkbox. Enables save/load callbacks.
- Save Domain: Tag picker. Selects which domain this object writes to.
The developer does not write registration code. The base class handles Saveable interface registration and deregistration automatically based on these properties.
+----------------------------------------------------------+
| PGX Save Inspector [Pin] |
+----------------------------------------------------------+
| |
| [Contexts] [Domains] [Pipeline] [Slots] |
| |
| CONTEXTS |
| +------+-----------+---------+--------+ |
| | Tag | Mode | Domains | Slots | |
| +------+-----------+---------+--------+ |
| | Camp.| MultiSlot | 3 | 4/16 | |
| | Sett.| SingleSlot| 2 | 1/1 | |
| +------+-----------+---------+--------+ |
| |
| PIPELINE LOG |
| 14:30:01 SAVE Campaign/Manual_001 -> Success (24KB) |
| 14:32:05 LOAD Campaign/Manual_001 -> Success (v3) |
| 14:35:00 AUTO Campaign/Autosave -> Success (32KB) |
| |
| SLOT BROWSER |
| Manual_001 | 2026-02-23 14:30 | 24.5 KB | Play: 1:30 |
| Manual_002 | 2026-02-23 15:00 | 28.1 KB | Play: 2:15 |
| Autosave | 2026-02-23 14:35 | 32.0 KB | Play: 1:35 |
| QuickSave | 2026-02-23 14:33 | 25.2 KB | Play: 1:32 |
| |
+----------------------------------------------------------+
| Active Slot: Manual_001 | Auto-Save: ON (300s) |
+----------------------------------------------------------+
A specialized Blueprint graph node auto-resolves the save game type from the domain tag. When the developer connects a domain tag, the output pin automatically assumes the correct type — no manual casting, no runtime type errors. The node validates at compile time that the domain tag exists in a Config DA and that a save game type is configured for it.
The typical integration flow:
- Create a Save Config DA in the Content Browser
- Define contexts and domains
- In Blueprints, use typed read/write nodes to access save data
- Save/Load operations handle compression, checksums, and versioning automatically
- (Optional) Enable "Participate in Save" on actors/components for callback-based integration
Other PGX subsystems use the Save system for their own persistence needs. The Profile system enforces save budgets (max slots, disk limits). The Log system checks the "Allow Persistent Logs" capability before writing log files.
| Event | When | Use Case |
|---|---|---|
| Save Completed | After a save operation finishes | UI feedback, auto-save indicators |
| Load Completed | After a load operation finishes | World reconstruction, state restoration |
| Slot Deleted | After a slot is removed | Slot list refresh |
| Auto-Save Triggered | When auto-save fires | UI notification, progress indicator |
| Save Progress | During async save (percentage) | Progress bar updates |
| Command | Description |
|---|---|
pgx.save.status |
Print save system state (active slot, auto-save status, in-progress ops) |
pgx.save.slots |
List all slots with metadata (name, date, size, play time) |
pgx.save.save |
Trigger a save operation to the active slot |
pgx.save.load |
Trigger a load operation from the active slot |
pgx.save.delete |
Delete a slot by name |
Organized into seven categories:
| Category | Nodes | Purpose |
|---|---|---|
| Core | Save, Load | Most common operations |
| Data | Has Data, Clear Domain, Get Save Game, Get Typed Save Game | Domain-level access |
| Data / Read | Read String, Int, Float, Bool, Vector, Rotator, Transform, Tag | Type-safe reads (8) |
| Data / Write | Write String, Int, Float, Bool, Vector, Rotator, Transform, Tag | Type-safe writes (8) |
| Query | All Slots, Slot Info, Slot Exists, Next Slot Name, Active Slot, Contexts, Context Count | Read-only queries (7) |
| Advanced | Save To Slot, Load From Slot, Delete, Copy, Auto-Save Toggle, Trigger Auto-Save, Set Active Slot, Version Info, Migration Info | Batch/management (12) |
| Debug | Is Save In Progress, Is Load In Progress | Status checks (2) |
Save systems are infrastructure. Like databases, they are invisible when they work and catastrophic when they fail. The cost of a save system failure is measured in player trust — and player trust, once lost, does not come back.
The Context/Domain/Slot model prevents the most common architectural mistake: the monolithic save game. By separating data into domains from day one, the save system supports sharing data across contexts (characters across campaigns), independent versioning per domain (change the player format without touching the world format), and selective persistence (save player data frequently, save world data rarely).
Checksums and migration chains prevent the two most common runtime failures: corrupted data and version mismatches. CRC32 validation ensures that partially-written files are detected before they can cause deserialization crashes. Chained migration ensures that a save from version 1 can be loaded in version 47 — each step is discrete, testable, and auditable.
The dual sync/async pipeline means game code never has to choose between simplicity and performance. Use sync for development and quick-save. Use async for auto-save and load screens. The API is the same. The guarantees are the same.
The per-instance integration via base class checkboxes means save system adoption scales with the project. Adding a new actor to the save system is a checkbox, not a code change. The framework handles registration, callbacks, and domain routing.
This is what enterprise-grade persistence looks like: not more features, but fewer failure modes.
- Development Preview
- Getting Started
- Release branch catalog
- Public Plugin Matrix
- Early Preview Plugins
- Known Issues
- Architecture Overview
- Plugin Topology
- Module Reference
- Configuration and Registry
- Data-Driven Design
- Profiles and Budgets
- Gameplay Tag Architecture
- Initialization Pipeline
- Cross-Plugin Communication
- Message System
- Event Handlers
- Logging and Trace
- Runtime Flows
- Blueprint API Design
- Editor Integration
- Editor Visual System