Skip to content

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

  1. Create OxHeartConfig (Right-click → Create → OxHeart → Config → OxHeartConfig):
  2. Save at: Assets/[YourProject]/Resources/Configs/DefaultOxHeartConfig.asset
  3. Configure: obfuscation (enabled by default), encryption (off by default), compression, save paths

  4. Create SaveConfig (Right-click → Create → OxHeart → Data Persistence → Save Configuration):

  5. Save at: Assets/[YourProject]/Resources/Configs/DefaultSaveConfig.asset
  6. Configure: save slots, auto-save interval, quick-save settings

  7. Enable Services in your ServiceConfiguration.asset:

  8. Enable Data Persistence Service (core save system)
  9. Enable Serialization Service (required for JSON)
  10. Enable Compression Service (if using compression)
  11. Enable Encryption Service (if using encryption)
  12. Enable Auto Save Service (optional, for auto-saving)
  13. Enable Quick Save Service (optional, for F5/F9 quick save)

  14. 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 PersistableData inheritance
  • 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) and SaveConfig (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). SaveConfig focuses on gameplay behavior (slots/intervals/toggles for auto/quick save).
  • PRODUCTION PATHS: Saves now use Application.persistentDataPath for 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, HasAnySavesAsync for richer file management.
  • VERSION SUPPORT: Added on-load version validation and a foundation for save migrations.
  • INTERFACE CONSISTENCY: Clarified ISaveSystem vs IAutoSaveService responsibilities.

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 OxHeartConfig ONLY.
  • Obfuscation (default): Set obfuscateSaveFiles = true for fast protection.
  • Encryption: Set encryptSaveFiles = true only 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 under Application.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 restrictAutoSaveToGameplay enabled, 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.Single loads — additive loads (UI overlays, streamed chunks) do not change the active scene and never trigger it. Scene swaps done purely via SetActiveScene (no load) also do not trigger it.
  • The snapshot is deferred by one frame so the new scene's Awake/Start methods — a common place to load or reset game state — run before the save is taken.
  • Excluded scenes are respected when restrictAutoSaveToGameplay is on: entering MainMenu saves 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) in SaveConfig; 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 of writeMaxDelaySeconds (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 PersistableData state. 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 direct SaveGame/SaveGameAsync call consumes any pending request for that slot.
  • urgent: true pins 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 fieldsSaveLoadReport.RecoveredFromBackup is true when 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 -BAK marker on the reason code (JSN-BAK, CHK-BAK, SCH-BAK), and SaveLoadReport.BackupQuarantineReason carries the backup's failure code alongside QuarantineReason.
  • HasSaveFile returns true for a slot that only has a backup left — LoadGame will restore it. DeleteAsync deletes a slot's backup along with its primary. ListSavesAsync never lists .bak files.
  • 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 OxHeartConfig asset (Save/Load section). The flag also disables the restore attempt. Custom ISaveStore implementations keep compiling: the new WriteAtomicWithBackup/WriteAtomicWithBackupAsync members are default interface members that fall back to plain atomic writes (no backup) until overridden.

Versioning and Migration

  • Override SchemaVersion on each PersistableData subclass whose serialized fields change, then register a per-domain migration with _saveSystem.RegisterDomainMigration(...) (a bundle-level ISaveMigration via RegisterMigration(...) 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 OxHeartConfig only (do not duplicate in SaveConfig).

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:

  1. Enable Data Persistence Services:

    • In your ServiceConfiguration asset (assigned to ImprovedGameInitializer), ensure the relevant data persistence services are enabled:
      • EnableDataPersistenceService (for the core ISaveSystem)
      • EnableAutoSaveService (if you need auto-save functionality)
      • EnableQuickSaveService (if you need quick save/load)
      • Ensure EnableSerializationService, EnableCompressionService, and EnableEncryptionService are enabled if you intend to use their respective features with saving.
  2. Create a SaveConfig Asset:

    • Create a SaveConfig asset in your project (Right-click in Project window → CreateOxHeartData PersistenceSave Configuration).
    • Customize settings within this SaveConfig asset: default/quick/auto save slots, max quick saves, auto-save interval, and restrict-to-gameplay toggle.
    • Place the asset at Resources/Configs/DefaultSaveConfig.asset so AutoSave can auto-discover it.
  3. Ensure ImprovedGameInitializer is Set Up:

    • Have an ImprovedGameInitializer component in your first scene with your ServiceConfiguration assigned to it.

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, encryptedSaveFileExtension
  • compressSaveFiles, compressionLevelForSaveFiles
  • obfuscateSaveFiles (default), encryptSaveFiles, encryptionKeySeed (the key source for both); defaultEncryptionKey is deprecated/unused

  • SaveConfig (Resources/Configs/DefaultSaveConfig):

  • DefaultSaveSlot, QuickSaveSlot, AutoSaveSlot
  • MaxSaveSlots, EnableQuickSave, EnableAutoSave
  • AutoSaveInterval, RestrictAutoSaveToGameplay
  • writeDebounceSeconds, writeMaxDelaySeconds, writeRetrySeconds (debounced RequestSave tuning — 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 / .sav files 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

  1. DataId Uniqueness: Ensure each PersistableData class has a unique DataId

  2. Data Registration: Use GetData<T>() which auto-registers new data instances if needed

  3. Data Updates: Update your data objects before calling SaveGame()

  4. Error Handling: Always handle exceptions from save operations

  5. Save File Management:

  6. Use meaningful slot names
  7. Implement a save management UI
  8. Clean up old saves to prevent storage bloat

  9. Performance:

  10. Use asynchronous methods for large saves
  11. Configure compression level based on your needs
  12. Use obfuscation for normal game saves (10x faster than encryption)
  13. Only encrypt truly sensitive data (payment info, personal details)

  14. Testing:

  15. Test with various save sizes
  16. Verify data integrity between saves
  17. Test on target platforms (mobile devices may have different storage behavior)

  18. Use SaveConfig for autosave/quicksave behavior and OxHeartConfig for engine-wide protection/compression settings.

Integration with Other Systems

  1. UI Integration: Create a save/load UI that displays save slots and timestamps

  2. Scene Management: Save current scene information to restore game state properly

  3. 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);
    }
}