Data Persistence System¶
A robust, configurable data persistence solution for Unity applications built with the OxHeart framework.
Quick Start¶
IMPORTANT: Config Assets Must Be In Resources Folder
- Create OxHeartConfig (Right-click → Create → OxHeart → Config → OxHeartConfig):
- Save at:
Assets/[YourProject]/Resources/Configs/DefaultOxHeartConfig.asset -
Configure: obfuscation (enabled by default), encryption (off by default), compression, save paths
-
Create SaveConfig (Right-click → Create → OxHeart → Data Persistence → Save Configuration):
- Save at:
Assets/[YourProject]/Resources/Configs/DefaultSaveConfig.asset -
Configure: save slots, auto-save interval, quick-save settings
-
Enable Services in your
ServiceConfiguration.asset: Enable Data Persistence Service(core save system)Enable Serialization Service(required for JSON)Enable Compression Service(if using compression)Enable Encryption Service(if using encryption)Enable Auto Save Service(optional, for auto-saving)-
Enable Quick Save Service(optional, for F5/F9 quick save) -
Add ImprovedGameInitializer to your first scene and assign your
ServiceConfiguration
Basic usage:
using OxHeart.Core.DataPersistence.Interfaces;
using OxHeart.Core.ServiceManagement.Service;
var saveSystem = ServiceLocator.Instance.Get<ISaveSystem>();
// Access or create data
var player = saveSystem.GetData<PlayerData>();
player.PlayerName = "Hero";
player.Health = 100;
// Save/Load
saveSystem.SaveGame("save1");
bool ok = saveSystem.LoadGame("save1");
Note: This file reflects recent improvements to the Save System. It aligns with the current OxHeart implementation: engine-wide compression/obfuscation/encryption live in OxHeartConfig, AutoSave uses SaveConfig if present, saves use atomic writes, and the save management API is available.
Overview¶
The OxHeart Data Persistence system provides a service-based approach to saving and loading game data. Built on the Service Locator pattern, it offers JSON serialization with optional compression and protection (obfuscation or encryption), plus an organized structure for managing persistent game data.
Features¶
- Type-safe data persistence through
PersistableDatainheritance - Multiple formats: JSON, JSON with obfuscation, or JSON with encryption
- Obfuscation for fast save protection (enabled by default)
- Optional compression to reduce save file sizes
- Optional encryption for sensitive game data (configured in
OxHeartConfig) - Save slots for multiple save files
- Asynchronous operations for performance
- Events for save begin/complete (via
GameEvent) - Auto-save functionality
- Checksum stored with save container (validation available to call)
- Configuration via
OxHeartConfig(engine-wide compression/obfuscation/encryption, paths, extensions) andSaveConfig(game behavior for autosave/quicksave slots/intervals). - Atomic save operations (writing to temporary files first) for enhanced data integrity.
- Data versioning support within save files.
- Checksum validation for save file integrity.
- Asynchronous operations by default for non-blocking save/loads.
Overhaul Summary and How To Use It¶
The Save System received a major update. This section explains what changed and how to adopt it.
What Changed¶
- SECURITY: Removed committed encryption keys from the repository. Use environment variables or build-time injection for production keys.
- ARCHITECTURE: Consolidated configuration. Protection/compression now live in
OxHeartConfig(single source of truth).SaveConfigfocuses on gameplay behavior (slots/intervals/toggles for auto/quick save). - PRODUCTION PATHS: Saves now use
Application.persistentDataPathfor builds. Editor paths remain predictable for debugging. - FLEXIBILITY: File extensions are resolved dynamically via
SaveSettings.GetFileExtension(); do not hardcode extensions. - SCENE-AWARE AUTOSAVE: AutoSave service can exclude common non-gameplay scenes using a built-in list (e.g., MainMenu/Boot/Loading). Toggle with
RestrictAutoSaveToGameplay. - API ENHANCEMENT: Added
ListSavesAsync,DeleteAsync,GetLatestAsync,HasAnySavesAsyncfor richer file management. - VERSION SUPPORT: Added on-load version validation and a foundation for save migrations.
- INTERFACE CONSISTENCY: Clarified
ISaveSystemvsIAutoSaveServiceresponsibilities.
Setup Steps (New)¶
1. OxHeartConfig (engine-wide)
- Create/locate Resources/Configs/DefaultOxHeartConfig.asset (created via menu: OxHeart/Config/OxHeartConfig).
- Set compression/obfuscation/encryption here. Do NOT place production keys in the repo; inject via environment variables/CI and write into OxHeartConfig at build/startup.
2. SaveConfig (game behavior)
- Create via: Create → OxHeart → Data Persistence → Save Configuration.
- Configure: default/quick/auto save slots, quick-save limits, auto-save interval, and whether to restrict auto-save to gameplay scenes.
- Placement: put the asset at Resources/Configs/DefaultSaveConfig.asset to be auto-discovered by AutoSave.
3. ServiceConfiguration - Enable: Serialization, (optional) Compression, (optional) Encryption services. - Enable: Data Persistence (Save System), Auto Save Service (if needed), Quick Save Service (optional).
4. Game Initializer
- Use ImprovedGameInitializer and assign your ServiceConfiguration.
- The initializer registers SaveSystem and (optionally) AutoSaveService. AutoSave picks up settings from DefaultSaveConfig if present; SaveSystem reads engine-level settings from OxHeartConfig.
Where to Put Obfuscation/Encryption Configuration¶
- Put obfuscation and encryption configuration in
OxHeartConfigONLY. - Obfuscation (default): Set
obfuscateSaveFiles = truefor fast protection. - Encryption: Set
encryptSaveFiles = trueonly for sensitive data. This takes precedence over obfuscation at runtime. - In production, inject encryption keys via environment variables or CI/build scripts. Do not commit production keys.
Save Paths and Extensions¶
- Use
SaveSettings.GetSaveFilePath(slot)to get the full path underApplication.persistentDataPath. - Use
SaveSettings.GetFileExtension()to get the correct extension for current settings (do not hardcode.sav/.osav/encrypted ext).
AutoSave Timing and Scene Behavior¶
The AutoSave service has two triggers, both configured on SaveConfig and both writing to the autoSaveSlot:
Interval saves (autoSaveInterval, default 300 seconds)
- Interval progress accumulates across scene transitions. Entering an excluded scene pauses the timer; re-entering a gameplay scene resumes it from where it left off. Only a fired save resets it, so players who change scenes frequently still get interval saves on schedule.
- With
restrictAutoSaveToGameplayenabled, time spent in excluded scenes does not count toward the interval, and no save fires while an excluded scene is active.
Scene-change saves (autoSaveOnSceneChange, default true)
- A save fires right after a scene load completes, but only for
LoadSceneMode.Singleloads — additive loads (UI overlays, streamed chunks) do not change the active scene and never trigger it. Scene swaps done purely viaSetActiveScene(no load) also do not trigger it. - The snapshot is deferred by one frame so the new scene's
Awake/Startmethods — a common place to load or reset game state — run before the save is taken. - Excluded scenes are respected when
restrictAutoSaveToGameplayis on: enteringMainMenusaves nothing; the pending progress is captured on the next gameplay-scene entry or interval tick. - A scene-change save resets the interval timer, and overlapping triggers never stack: if a save is already in flight, additional triggers (interval, scene change, or
SaveNow()) are skipped.
Scene filtering
- Configure an excluded-scenes list (e.g.,
Boot,MainMenu,Loading) inSaveConfig; matching is a case-insensitive substring check against the active scene name. - The AutoSave service subscribes to scene changes and pauses/resumes the interval loop accordingly.
New Save Management API¶
// List existing save files (with file system metadata)
IReadOnlyList<SaveFileInfo> files = await _saveSystem.ListSavesAsync();
// Delete a save
await _saveSystem.DeleteAsync("save1");
// Get the most recent save
var latest = await _saveSystem.GetLatestAsync();
// Do we have any saves at all?
bool any = await _saveSystem.HasAnySavesAsync();
SaveFileInfo provides file-level metadata (name, timestamp, size) and is separate from your game's SaveMetadata.
Debounced Change-Driven Saves (RequestSave)¶
If your game saves on every player action (a move, a placement, a stat tick), calling SaveGame/SaveGameAsync per change rewrites the whole save file once per change — needless flash/SD-card wear on Steam Deck and mobile. Use RequestSave instead:
_saveSystem.RequestSave("save1"); // marks the slot dirty and returns immediately
_saveSystem.RequestSave("save1", urgent: true); // a precious write (a solve, a checkpoint) — lands on the next frame
_saveSystem.FlushPendingSaves(); // synchronous checkpoint: every dirty slot is on disk when this returns
How it behaves:
- A burst of requests collapses into one write, taken
writeDebounceSeconds(default 3) after the last request. Each request pushes the deadline out; a hard cap ofwriteMaxDelaySeconds(default 10) measured from the first unsaved request guarantees the write cannot be starved forever. - Serialization happens at write time, so the deferred write always captures the freshest registered
PersistableDatastate. The worst-case exposure is a crash inside the quiet window losing the last few seconds — the same exposure as any save-on-change scheme. - Pending writes are flushed automatically on application quit, pause (Steam Deck suspend delivers pause, not quit), focus loss, and engine shutdown. You only need
FlushPendingSaves()for game-driven checkpoints that must be on disk before the call returns. - A failed write re-arms and retries after
writeRetrySeconds(default 5). Slots schedule independently, and a directSaveGame/SaveGameAsynccall consumes any pending request for that slot. urgent: truepins the current batch to the next poll; later non-urgent requests cannot push an urgent write back out.
Tune the three timings on your SaveConfig asset under Debounced Save Writes. Custom ISaveSystem implementations keep compiling unchanged: both members are default interface members (RequestSave degrades to an immediate SaveGame, FlushPendingSaves to a no-op).
Atomic Writes and Integrity¶
- Saves write to a temporary file and then perform an atomic move to prevent partial/corrupted files.
- Checksums validate integrity during load.
Rolling Backup and Automatic Restore¶
Every overwrite of a save slot preserves the file's previous contents as a rolling one-deep backup next to it — save1.osav gets save1.osav.bak (the .bak suffix is appended to the full filename, whatever extension your protection mode uses). The first write of a slot creates no backup; each later write replaces the backup with the previous primary.
On load, if the primary file fails any validation step (JSON parse, checksum, schema version), it is quarantined exactly as before — and the engine then tries the backup with the same validation. A healthy backup loads transparently, and the primary is re-minted from it; LoadGame returns true and the game never sees the failure except through the report:
var loaded = _saveSystem.LoadGame("save1");
if (loaded && _saveSystem.LastLoadReport?.RecoveredFromBackup == true)
ShowNotice("Your progress was restored from a backup.");
Details and semantics:
- Report fields —
SaveLoadReport.RecoveredFromBackupistruewhen the load came from the backup (including when the primary was missing, e.g. deleted by hand, but its backup survived). If the backup ALSO fails validation it is quarantined too, with a-BAKmarker on the reason code (JSN-BAK,CHK-BAK,SCH-BAK), andSaveLoadReport.BackupQuarantineReasoncarries the backup's failure code alongsideQuarantineReason. HasSaveFilereturnstruefor a slot that only has a backup left —LoadGamewill restore it.DeleteAsyncdeletes a slot's backup along with its primary.ListSavesAsyncnever lists.bakfiles.- Scope — the backup guards against corruption-at-rest, torn writes, and bad key/version states of a single file. It is written in the same debounce window as the primary, so it is not a time-spaced checkpoint; for staleness protection, pair it with the auto-save slot (which, being written through the same pipeline, gets its own rolling backup for free).
- Opt out — tick Disable Save Backups in your
OxHeartConfigasset (Save/Load section). The flag also disables the restore attempt. CustomISaveStoreimplementations keep compiling: the newWriteAtomicWithBackup/WriteAtomicWithBackupAsyncmembers are default interface members that fall back to plain atomic writes (no backup) until overridden.
Versioning and Migration¶
- Override
SchemaVersionon eachPersistableDatasubclass whose serialized fields change, then register a per-domain migration with_saveSystem.RegisterDomainMigration(...)(a bundle-levelISaveMigrationviaRegisterMigration(...)is also available). - The system validates each domain's stored version at load and routes incompatible versions to your registered migration steps.
Security Checklist¶
- Never commit production keys.
- Use environment variables or CI secrets to inject keys.
- Keep Encryption/Compression config in
OxHeartConfigonly (do not duplicate inSaveConfig).
Architecture¶
The Data Persistence system is organized as follows:
DataPersistence/
├── Data/ # Core data structures
│ ├── GameSaveData.cs # Container for all game data
│ └── PersistableData.cs # Base class for all save data
├── Events/ # Save-related events
│ └── SaveEvents.cs # Event definitions
├── Interfaces/ # Service interfaces
│ ├── IAutoSaveService.cs # Auto-save functionality
│ ├── IQuickSaveService.cs # Quick save functionality
│ └── ISaveSystem.cs # Main persistence interface
├── Service/ # Service implementations
│ ├── AutoSaveService.cs # Auto-save implementation
│ ├── QuickSaveService.cs # Quick save implementation
│ └── SaveSystem.cs # Main persistence implementation
└── Settings/ # Configuration
├── SaveConfig.cs # ScriptableObject for editor-time configuration
└── SaveSettings.cs # Runtime persistence setting (derived from SaveConfig)
Getting Started¶
1. Configure and Initialize the Save System¶
The Data Persistence system is initialized as part of the OxHeart's startup sequence when using the ImprovedGameInitializer. Configuration is primarily handled through ScriptableObject assets:
-
Enable Data Persistence Services:
- In your
ServiceConfigurationasset (assigned toImprovedGameInitializer), ensure the relevant data persistence services are enabled:EnableDataPersistenceService(for the coreISaveSystem)EnableAutoSaveService(if you need auto-save functionality)EnableQuickSaveService(if you need quick save/load)- Ensure
EnableSerializationService,EnableCompressionService, andEnableEncryptionServiceare enabled if you intend to use their respective features with saving.
- In your
-
Create a
SaveConfigAsset:- Create a
SaveConfigasset in your project (Right-click in Project window →Create→OxHeart→Data Persistence→Save Configuration). - Customize settings within this
SaveConfigasset: default/quick/auto save slots, max quick saves, auto-save interval, and restrict-to-gameplay toggle. - Place the asset at
Resources/Configs/DefaultSaveConfig.assetso AutoSave can auto-discover it.
- Create a
-
Ensure
ImprovedGameInitializeris Set Up:- Have an
ImprovedGameInitializercomponent in your first scene with yourServiceConfigurationassigned to it.
- Have an
The engine will then use these configurations to initialize the SaveSystem and related services.
2. Define Data Classes¶
Create data classes by inheriting from PersistableData:
using System;
using OxHeart.Core.DataPersistence.Data;
using UnityEngine;
namespace YourGame.Data
{
[Serializable]
public class PlayerData : PersistableData
{
// Unique identifier for this data class
public override string DataId => "player";
// Serialized fields
[SerializeField] private string playerName;
[SerializeField] private int health;
[SerializeField] private Vector3 position;
// Properties with getters/setters
public string PlayerName
{
get => playerName;
set => playerName = value;
}
public int Health
{
get => health;
set => health = value;
}
public Vector3 Position
{
get => position;
set => position = value;
}
// Optional lifecycle hooks
public override void OnBeforeSave()
{
// Prepare data before saving (e.g., update timestamps)
}
public override void OnAfterLoad()
{
// Process data after loading (e.g., validate values)
}
}
}
3. Register and Use Data¶
Access the save system and register your data objects:
using OxHeart.Core.DataPersistence.Interfaces;
using OxHeart.Core.ServiceManagement.Service;
using UnityEngine;
using YourGame.Data;
public class GameManager : MonoBehaviour
{
private ISaveSystem _saveSystem;
private void Awake()
{
// Get save system through service locator
_saveSystem = ServiceLocator.Instance.Get<ISaveSystem>();
// Initialize game data
InitializeGameData();
}
private void InitializeGameData()
{
// Create or get player data
var playerData = _saveSystem.GetData<PlayerData>();
// Initialize with default values if needed
playerData.PlayerName = "Hero";
playerData.Health = 100;
playerData.Position = Vector3.zero;
// Register other game data types
// Note: GetData automatically registers a new instance if not found
var inventoryData = _saveSystem.GetData<InventoryData>();
var questData = _saveSystem.GetData<QuestData>();
}
// Call this to manually save the game
public void SaveGame()
{
// Update values before saving
var playerData = _saveSystem.GetData<PlayerData>();
playerData.Position = transform.position;
try
{
// Save to "save1" slot
_saveSystem.SaveGame("save1");
Debug.Log("Game saved successfully");
}
catch (System.Exception e)
{
Debug.LogError($"Save failed: {e.Message}");
}
}
// Call this to load a saved game
public void LoadGame()
{
// Check if save exists before loading
if (_saveSystem.HasSaveFile("save1"))
{
if (_saveSystem.LoadGame("save1"))
{
// Access loaded data
var playerData = _saveSystem.GetData<PlayerData>();
// Apply loaded values to game state
transform.position = playerData.Position;
Debug.Log($"Loaded player: {playerData.PlayerName}, Health: {playerData.Health}");
}
else
{
Debug.LogError("Failed to load save file");
}
}
else
{
Debug.Log("No save file found");
}
}
// For large save files, use async methods
public async void SaveGameAsync()
{
try
{
await _saveSystem.SaveGameAsync("save1");
Debug.Log("Game saved asynchronously");
}
catch (System.Exception e)
{
Debug.LogError($"Async save failed: {e.Message}");
}
}
}
Configuration Options¶
Configuration is split between OxHeartConfig (engine-wide) and SaveConfig (game behavior):
OxHeartConfig(Resources/Configs/DefaultOxHeartConfig):saveDirectory,saveFileExtension,obfuscatedSaveFileExtension,encryptedSaveFileExtensioncompressSaveFiles,compressionLevelForSaveFiles-
obfuscateSaveFiles(default),encryptSaveFiles,encryptionKeySeed(the key source for both);defaultEncryptionKeyis deprecated/unused -
SaveConfig(Resources/Configs/DefaultSaveConfig): DefaultSaveSlot,QuickSaveSlot,AutoSaveSlotMaxSaveSlots,EnableQuickSave,EnableAutoSaveAutoSaveInterval,RestrictAutoSaveToGameplaywriteDebounceSeconds,writeMaxDelaySeconds,writeRetrySeconds(debouncedRequestSavetuning — see Debounced Change-Driven Saves above)
Tip: You generally do not need to construct SaveSettings yourself. SaveSystem reads engine-wide options from OxHeartConfig; AutoSaveService reads game behavior from DefaultSaveConfig if present.
Save File Types¶
The system supports three formats with automatic detection (names configurable in OxHeartConfig):
- Obfuscated JSON (default, fast) — default extension .osav
- Encrypted JSON (for sensitive data) — default extension .esave
- Plain JSON — default extension .sav (not recommended)
Priority when loading: obfuscated > encrypted > plain. Always use SaveSettings.GetFileExtension() rather than hardcoding extensions.
See Save protection below for obfuscation vs encryption guidance.
Advanced Features¶
Save Slots¶
The system supports multiple save files:
// Save to different slots
_saveSystem.SaveGame("quicksave");
_saveSystem.SaveGame("slot1");
_saveSystem.SaveGame("autosave");
// Load from specific slot
_saveSystem.LoadGame("slot1");
// Check if a save exists
if (_saveSystem.HasSaveFile("quicksave"))
{
// Save exists
}
Auto-Save (IAutoSaveService)¶
Enable in ServiceConfiguration: Enable Auto Save Service (requires Data Persistence + Serialization).
Configure behavior in SaveConfig:
Auto Save Settings:
├─ Auto Save Enabled: true
├─ Auto Save Interval: 300 (seconds)
├─ Auto Save Slot: "autosave"
└─ Restrict Auto Save To Gameplay: true
Auto-save starts automatically when the service initializes (if enabled in config). Use DefaultSaveConfig at Resources/Configs/DefaultSaveConfig.asset so AutoSave can auto-discover settings.
using OxHeart.Core.DataPersistence.Interfaces;
using OxHeart.Core.ServiceManagement.Service;
var autoSave = ServiceLocator.Instance.Get<IAutoSaveService>();
autoSave.StartAutoSave(); // resume periodic saves
autoSave.StopAutoSave(); // pause (e.g. main menu)
autoSave.SaveNow(); // force immediate save (scene transitions, quit)
Scene filtering: When RestrictAutoSaveToGameplay is true, AutoSave pauses in scenes whose names match the six built-in exclusions (MainMenu, Boot, Loading, Splash, Settings, Credits). Add your own (e.g. CharacterSelect, Lobby, Tutorial) via SaveConfig.excludedSceneNames.
Recommended intervals: 60–120s (action/permadeath), 300–600s (RPG/strategy), 900–1800s (casual games with manual saves).
Quick Save (IQuickSaveService)¶
Enable in ServiceConfiguration: Enable Quick Save Service (requires Data Persistence + Serialization).
Configure in SaveConfig:
Quick Save Settings:
├─ Quick Save Enabled: true
└─ Max Quick Saves: 5
using OxHeart.Core.DataPersistence.Interfaces;
using OxHeart.Core.ServiceManagement.Service;
using UnityEngine;
public class QuickSaveHandler : MonoBehaviour
{
private IQuickSaveService _quickSave;
void Start() => _quickSave = ServiceLocator.Instance.Get<IQuickSaveService>();
void Update()
{
if (Input.GetKeyDown(KeyCode.F5)) _quickSave.QuickSave();
if (Input.GetKeyDown(KeyCode.F9)) _quickSave.QuickLoad();
}
}
API highlights:
| Method / property | Purpose |
|---|---|
QuickSave() |
Create a timestamped quick save (fire-and-forget) |
QuickSaveAsync() |
Create a quick save; await it to know it finished — returns the slot name written |
QuickLoad(index = 0) |
Load most recent (0) or older slot (1, 2, …) (fire-and-forget) |
QuickLoadAsync(index = 0) |
Load a quick save; await it before reading loaded state — returns true on success |
HasQuickSave() |
Whether any quick save exists |
GetLatestQuickSaveSlot() |
Most recent slot name |
QuickSaveSlots |
All current quick save slot names |
MaxQuickSaves |
Rotation limit (oldest removed when exceeded) |
Quick saves use timestamped names: quicksave_yyyyMMdd_HHmmss (e.g. quicksave_20250108_143025).
QuickSaveService exposes BeforeQuickSave / AfterQuickSave / AfterQuickLoad events (on the concrete class) for UI feedback — pause, show indicators, notifications. AfterQuickLoad fires once after a quick save has successfully loaded.
Awaitable quick-save / quick-load¶
QuickSave() and QuickLoad() are fire-and-forget: they return immediately and the save/load completes later, so reading game state right after QuickLoad() races the load:
_quickSave.QuickLoad();
var stats = _saveSystem.GetData<PlayerStats>(); // ❌ may still hold pre-load values
Use the awaitable overloads when you need to act on completion — refresh UI after a load, chain follow-up work, or show a "Saved" toast once the file is actually on disk:
async void LoadNewestQuickSave()
{
bool loaded = await _quickSave.QuickLoadAsync(); // completes when the load has finished
if (loaded)
{
var stats = _saveSystem.GetData<PlayerStats>(); // ✅ now reflects the loaded save
RefreshHud(stats);
}
}
async void QuickSaveWithToast()
{
string slot = await _quickSave.QuickSaveAsync(); // returns the slot name written
ShowToast($"Saved to {slot}");
}
The void methods still behave exactly as before — they now delegate to the awaitable versions and swallow-and-log any failure; only the awaitable methods surface exceptions to you. A quick save is written immediately: it is not affected by the RequestSave write-debounce.
Save Protection (Obfuscation and Encryption)¶
Configure in OxHeartConfig only (not SaveConfig). Enable Serialization Service; obfuscation/encryption run through the serializer pipeline automatically when saving.
Priority at runtime: obfuscation → encryption → plain JSON.
| Mode | Extension (default) | When to use |
|---|---|---|
| Obfuscation (default) | .osav |
Game progress, settings — fast, stops casual editing |
| Encryption | .esave |
Payment data, PII, compliance requirements |
| Plain | .sav |
Debugging only — not recommended for shipping |
Performance comparison:
| Obfuscation (XOR) | Encryption (AES-256) | |
|---|---|---|
| Speed | ~10× faster | Slower (PBKDF2 key derivation) |
| Security | Casual protection | Strong |
| Best for | Frequent saves, mobile | Sensitive data, rare saves |
Recommended defaults (most games):
// OxHeartConfig
obfuscateSaveFiles = true; // on by default
encryptSaveFiles = false; // only for sensitive data
Set a unique key seed. Both obfuscation and encryption derive their key from OxHeartConfig.encryptionKeySeed. If you leave it empty, the engine falls back to a fixed salt that ships inside the package — i.e. the same, publicly-derivable default key for every game that doesn't set one. Set encryptionKeySeed to a value unique to your game and keep it stable across releases (changing it invalidates existing saves). Treat a production seed as a secret: inject it via CI / environment at build time rather than committing it. See Config — Security Best Practices.
Rotating the save key (without losing player data)¶
Changing encryptionKeySeed changes the derived key, so existing obfuscated saves would normally
be quarantined (they no longer decode). To rotate safely, keep the old seed as a legacy key:
// OxHeartConfig
encryptionKeySeed = "games.mystudio.mygame.v2"; // the NEW seed
legacyEncryptionKeySeeds = new List<string> // previous seeds, newest first
{
"games.mystudio.mygame.v1", // the OLD seed
};
On load, the deobfuscator tries the current key first, then each legacy seed. A save that only decodes under a legacy seed is loaded and transparently re-saved under the current key — a one-time silent migration, so the next load uses the current key with no fallback. Saves that match no key are still quarantined exactly as before. This applies to the obfuscation (XOR) path; the list can also carry the pre-1.0 internal salt if you shipped an rc-line build without a seed. There is no automatic migration for the AES-encrypted path.
Obfuscation — automatic when enabled; no extra code at save sites:
await _saveSystem.SaveGameAsync("save1"); // writes save1.osav
await _saveSystem.LoadGameAsync("save1"); // auto-detects .osav / .esave / .sav
Direct service access (advanced):
var obfuscation = ServiceLocator.Instance.Get<IObfuscationService>();
string obfuscated = obfuscation.Obfuscate(plainText, key: "");
string restored = obfuscation.Deobfuscate(obfuscated, key: "");
Encryption — enable encryptSaveFiles in OxHeartConfig. Keys come from SecureKeyProvider, derived from your encryptionKeySeed (set a unique per-game seed — see the note above). Never commit production keys/seeds — inject via CI/environment at build time.
// Keys are wired by ConfigurableServiceInitializer from OxHeartConfig
var encryption = ServiceLocator.Instance.Get<IEncryptionService>();
string encrypted = encryption.EncryptString(sensitiveData, password);
string decrypted = encryption.DecryptString(encrypted, password);
Security tips:
- Obfuscation does not stop determined reverse engineering — validate loaded data (sanity checks, checksums).
- For multiplayer, validate critical values server-side.
- Migrating encryption → obfuscation: new saves use
.osav; old.esave/.savfiles still load.
Compression (ICompressionService)¶
Enable in ServiceConfiguration: Enable Compression Service. Toggle save compression in OxHeartConfig (compressSaveFiles, compressionLevelForSaveFiles).
When enabled, the save/serializer pipeline compresses JSON before obfuscation/encryption. Useful for large save files; skip for tiny saves if CPU matters more than disk.
Direct service access (non-save use cases):
using OxHeart.Core.Compression.Interfaces;
using OxHeart.Core.ServiceManagement.Service;
using System.IO.Compression;
var compression = ServiceLocator.Instance.Get<ICompressionService>();
string compressed = compression.CompressString(largeJson, CompressionLevel.Optimal);
string restored = compression.DecompressString(compressed);
compression.CompressFile(sourcePath, outputZipPath, CompressionLevel.Fastest);
compression.DecompressFile(inputZipPath, outputDirectory);
Services register automatically via ImprovedGameInitializer — do not manually register unless writing custom bootstrap code.
Event System Integration¶
The system publishes events for save begin/complete via GameEvent. Subscribe with GameEvent.Subscribe and implement IEventListener<T>:
using OxHeart.Core.DataPersistence.Events;
using OxHeart.Core.Events.GameEvents;
using OxHeart.Core.Events.Interfaces;
using UnityEngine;
public class SaveEventsListener : MonoBehaviour,
IEventListener<SaveBeginEvent>,
IEventListener<SaveCompleteEvent>
{
private void OnEnable()
{
GameEvent.Subscribe(this as IEventListener<SaveBeginEvent>);
GameEvent.Subscribe(this as IEventListener<SaveCompleteEvent>);
}
private void OnDisable()
{
GameEvent.Unsubscribe(this as IEventListener<SaveBeginEvent>);
GameEvent.Unsubscribe(this as IEventListener<SaveCompleteEvent>);
}
public void OnGameEvent(SaveBeginEvent e)
{
Debug.Log($"Save starting for slot: {e.SaveSlot}");
}
public void OnGameEvent(SaveCompleteEvent e)
{
Debug.Log($"Save completed for slot: {e.SaveSlot}, Success: {e.Success}");
}
}
Note: Quick/Auto save/load event types exist but are not emitted by all services yet; rely on the save begin/complete events for now.
Best Practices¶
-
DataId Uniqueness: Ensure each
PersistableDataclass has a uniqueDataId -
Data Registration: Use
GetData<T>()which auto-registers new data instances if needed -
Data Updates: Update your data objects before calling
SaveGame() -
Error Handling: Always handle exceptions from save operations
-
Save File Management:
- Use meaningful slot names
- Implement a save management UI
-
Clean up old saves to prevent storage bloat
-
Performance:
- Use asynchronous methods for large saves
- Configure compression level based on your needs
- Use obfuscation for normal game saves (10x faster than encryption)
-
Only encrypt truly sensitive data (payment info, personal details)
-
Testing:
- Test with various save sizes
- Verify data integrity between saves
-
Test on target platforms (mobile devices may have different storage behavior)
-
Use
SaveConfigfor autosave/quicksave behavior andOxHeartConfigfor engine-wide protection/compression settings.
Integration with Other Systems¶
-
UI Integration: Create a save/load UI that displays save slots and timestamps
-
Scene Management: Save current scene information to restore game state properly
-
Player Settings: Save user preferences such as graphics quality, sound volume, etc.
Example: Complete Save System Implementation¶
using OxHeart.Core.DataPersistence.Interfaces;
using OxHeart.Core.ServiceManagement.Service;
using OxHeart.Core.Events.GameEvents;
using OxHeart.Core.DataPersistence.Events;
using UnityEngine;
using System;
using YourGame.Data;
public class SaveManager : MonoBehaviour,
OxHeart.Core.Events.Interfaces.IEventListener<SaveBeginEvent>,
OxHeart.Core.Events.Interfaces.IEventListener<SaveCompleteEvent>
{
private ISaveSystem _saveSystem;
private IQuickSaveService _quickSaveService;
[SerializeField] private string defaultSlot = "save1";
public event Action<string> OnSaveCompleted;
public event Action<string> OnLoadCompleted;
private void Awake()
{
_saveSystem = ServiceLocator.Instance.Get<ISaveSystem>();
_quickSaveService = ServiceLocator.Instance.Get<IQuickSaveService>();
// Subscribe to events (save begin/complete)
GameEvent.Subscribe(this as IEventListener<SaveBeginEvent>);
GameEvent.Subscribe(this as IEventListener<SaveCompleteEvent>);
}
private void OnDestroy()
{
// Unsubscribe from events
GameEvent.Unsubscribe(this as IEventListener<SaveBeginEvent>);
GameEvent.Unsubscribe(this as IEventListener<SaveCompleteEvent>);
}
public void QuickSave()
{
_quickSaveService.QuickSave();
}
public void QuickLoad()
{
_quickSaveService.QuickLoad();
}
public void SaveToSlot(string slotName)
{
try
{
UpdateAllData();
_saveSystem.SaveGame(slotName);
}
catch (Exception e)
{
Debug.LogError($"Error saving to slot {slotName}: {e.Message}");
}
}
public bool LoadFromSlot(string slotName)
{
if (!_saveSystem.HasSaveFile(slotName))
{
Debug.LogWarning($"No save file found in slot: {slotName}");
return false;
}
bool success = _saveSystem.LoadGame(slotName);
if (success)
{
ApplyLoadedData();
}
else
{
Debug.LogError($"Failed to load from slot: {slotName}");
}
return success;
}
public string[] GetAllSaveSlots()
{
// Implementation would depend on additional methods in SaveSystem
// This is just a conceptual example
return new string[] { "save1", "save2", "quicksave", "autosave" };
}
private void UpdateAllData()
{
// Update player data before saving
var playerData = _saveSystem.GetData<PlayerData>();
playerData.Position = FindFirstObjectByType<PlayerController>().transform.position;
playerData.Health = FindFirstObjectByType<PlayerHealth>().CurrentHealth;
// Update other game data...
}
private void ApplyLoadedData()
{
// Get loaded data
var playerData = _saveSystem.GetData<PlayerData>();
// Apply to game objects
var player = FindFirstObjectByType<PlayerController>();
if (player != null)
{
player.transform.position = playerData.Position;
}
var healthComp = FindFirstObjectByType<PlayerHealth>();
if (healthComp != null)
{
healthComp.SetHealth(playerData.Health);
}
// Apply other game data...
}
public void OnGameEvent(SaveBeginEvent e)
{
Debug.Log($"Starting save to slot: {e.SaveSlot}");
}
public void OnGameEvent(SaveCompleteEvent e)
{
Debug.Log($"Save {(e.Success ? "succeeded" : "failed")} for slot: {e.SaveSlot}");
if (e.Success) OnSaveCompleted?.Invoke(e.SaveSlot);
}
}