Skip to main content

Backup and Restore

How to backup, restore, and protect your ConjureDB database.

Related docs: Persistence covers the persistence architecture in depth. Configuration details every option and its default. This guide focuses on operational backup/restore strategies and recipes.


Overview

ConjureDB persists data through two complementary mechanisms:

MechanismWhat it capturesGranularityFile pattern
SnapshotFull (or delta) database statePoint-in-timesnapshot_{UnixTimeMs}.dat
Journal (WAL)Individual committed transactionsPer-commitjournal_{Ticks}.dat

Together they provide a recovery model analogous to PostgreSQL's base backup + WAL archiving: a snapshot is the "base backup" and journal files are the "WAL segments."

Why Backup Matters for Game Clients

Game client databases hold irreplaceable player state — progression, inventory, purchases, and settings. Unlike server-side databases that run on redundant storage, game clients operate on consumer devices where:

  • The app can be killed mid-write (swipe-away, OS memory pressure).
  • Storage can be corrupted (sudden power loss, SD-card failures).
  • Players switch devices and expect continuity.
  • Refund disputes require proof of purchase history.

A solid backup strategy turns a potential data-loss incident into a transparent recovery that the player never notices.

Backup Strategies at a Glance

StrategyData-loss windowComplexityBest for
Snapshot-onlyUp to snapshot intervalLowConfig data, non-critical state
Snapshot + JournalZero (last committed txn)MediumPlayer progress, purchases
Cloud SaveZero + cross-deviceHigherCross-device play, live-service games

Snapshot Backups

Manual Snapshots

Trigger a snapshot explicitly with Save() or SaveAsync():

// Synchronous — blocks until fully written to disk
db.Save();

// Asynchronous — preferred on main thread
await db.SaveAsync();

When to trigger manual snapshots:

EventWhy
Level / stage completionNatural checkpoint the player expects
Significant purchaseProtect monetization data immediately
App backgroundingLast chance before OS may kill the process
Before risky operationSchema migration, bulk import

Example — save on Unity app pause:

private void OnApplicationPause(bool paused)
{
if (paused)
{
// Fire-and-forget is acceptable here; the snapshot
// writer is already crash-safe.
_ = db.SaveAsync();
}
}

Example — save after purchase:

public async Task CompletePurchase(PurchaseRecord purchase)
{
db.Set<PurchaseRecord>().Add(purchase);
db.Commit();

// Force a durable snapshot so the purchase survives any crash.
await db.SaveAsync();
}

Note: Save() / SaveAsync() is a no-op if no data has changed since the last snapshot (version comparison). Calling it frequently is safe.

Automatic Snapshots

Enable persistence and the runtime takes periodic snapshots automatically:

var db = DbContextBuilder<GameDb>.Create()
.WithDataDirectory("save/")
.WithSnapshot(snap => snap
.AutomaticSnapshotInterval(TimeSpan.FromMinutes(5))
.MaxSnapshotsToKeep(10))
.Build();
OptionDefaultDescription
AutomaticSnapshotInterval5 minTime between automatic snapshots
MaxSnapshotsToKeep10Oldest snapshots are pruned beyond this count
SerializationBufferSize64 KBInternal buffer for MessagePack serialization

File naming and location:

Snapshots are stored under <DataDirectory>/snapshots/:

save/
└── snapshots/
├── snapshot_1718000000000.dat ← oldest
├── snapshot_1718000300000.dat
└── snapshot_1718000600000.dat ← latest

When MaxSnapshotsToKeep is exceeded, the oldest file is deleted after the new snapshot is fully written and fsynced.

Incremental (Delta) Snapshots

Full snapshots serialize the entire database. For large datasets this can be slow and produce large files. Delta snapshots capture only entities modified since the last full snapshot.

var db = DbContextBuilder<GameDb>.Create()
.WithDataDirectory("save/")
.WithSnapshot(snap => snap
.EnableIncrementalSnapshots()
.MaxDeltaChainLength(10)
.DeltaToFullThreshold(0.5))
.Build();
OptionDefaultDescription
EnableIncrementalSnapshotsfalseTurn on delta snapshot mode
MaxDeltaChainLength10Max deltas before forcing a full snapshot
DeltaToFullThreshold0.5If ≥ 50 % of entities are dirty, take a full snapshot instead

How delta chains work:

Full snapshot (epoch 100)
└─ Delta 1 (epoch 101) — 12 modified entities
└─ Delta 2 (epoch 102) — 3 modified entities
└─ Delta 3 (epoch 103) — 7 modified entities
└─ ... up to MaxDeltaChainLength
└─ Full snapshot (epoch 110) — chain resets

Recovery loads the base full snapshot, then applies each delta in epoch order.

When a full snapshot is forced:

  1. Delta chain length reaches MaxDeltaChainLength.
  2. Ratio of dirty entities to total entities exceeds DeltaToFullThreshold.
  3. A post-migration snapshot is required after schema changes.

Recommendation: Start with MaxDeltaChainLength = 10 and DeltaToFullThreshold = 0.5. Monitor snapshot sizes — if deltas approach the full snapshot size, lower the threshold.

Snapshot File Format

Full snapshots (V5 format):

┌──────────────────────────────────────────────────┐
│ Magic (8 bytes) 0x554D_4442_5632_0000 "UMDBV2" │
│ Format Version (2 bytes) ushort = 5 │
│ Epoch / Version (8 bytes) ulong │
│ Schema Length (4 bytes) uint │
│ Encryption Flag (1 byte) 0/1 │
│ Schema CRC-32 (4 bytes) │
│ Schema Section (N bytes) │
│ DbSet Count (4 bytes) int │
├──────────────────────────────────────────────────┤
│ DbSet Section 1: │
│ Section Length (4 bytes) │
│ Section CRC-32 (4 bytes) │
│ TypeId (2 bytes), MaxId (4 bytes) │
│ DataLength (4 bytes), MessagePack payload │
│ DbSet Section 2 ... │
│ ... │
└──────────────────────────────────────────────────┘

The schema section and every full-snapshot DbSet section are CRC-32 validated before deserialization. A checksum mismatch fails recovery instead of applying partially corrupted state.

Delta snapshots:

┌──────────────────────────────────────────────────┐
│ Magic (4 bytes) 0x55_4D_44_53 "UMDS" │
│ Base Epoch (8 bytes) ulong │
│ New Epoch (8 bytes) ulong │
│ Transaction Version (8 bytes) ulong │
│ DbSet Section Count (4 bytes) int │
├──────────────────────────────────────────────────┤
│ Modified entity sections (MessagePack) │
└──────────────────────────────────────────────────┘

File sizes: Snapshot size is proportional to the number of entities and their serialized field sizes. A database with 10,000 simple entities typically produces snapshots in the 100 KB – 1 MB range. Enable encryption (AES-GCM) adds ~16 bytes per 4 KB chunk for authentication tags.


Journal (WAL) Backups

Journal Files

The write-ahead journal records every committed transaction between snapshots. Each journal entry is a framed record:

┌─────────────────────────────────────────┐
│ Magic (4 bytes) 0x554D_4A45 "UMJE" │
│ Length (4 bytes) │
│ CRC-32 checksum (4 bytes) │
│ MessagePack payload (variable) │
└─────────────────────────────────────────┘

Journal files are stored under <DataDirectory>/journal/:

save/
└── journal/
├── journal_638550000000000000.dat
└── journal_638550036000000000.dat

File rotation: When a journal file exceeds MaxJournalFileSize (default: 2 MB), a new file is created. Old journal files are pruned after a successful snapshot captures their state.

Journal + Snapshot = complete backup: The journal captures every commit that happens between snapshots. On recovery, the latest snapshot is loaded first, then journal entries with versions newer than the snapshot are replayed in order. This guarantees zero data loss down to the last committed transaction.

Journal Configuration for Durability

var db = DbContextBuilder<GameDb>.Create()
.WithDataDirectory("save/")
.WithSnapshot(snap => snap
.AutomaticSnapshotInterval(TimeSpan.FromMinutes(5))
.WithJournaling(journal => journal
.FlushInterval(TimeSpan.FromMilliseconds(100))
.MaxJournalFileSize(2 * 1024 * 1024)))
.Build();
OptionDefaultEffect on durability
FlushInterval100 msLower = less data at risk; higher = better throughput
MaxJournalFileSize2 MBControls rotation; larger files = fewer fsyncs
MaxStateChangesBeforeSnapshot100Auto-triggers snapshot after N journal entries
CorruptionPolicyStopAtCorruptionRecovery behavior on a corrupt journal entry; configurable via .WithJournaling(j => j.CorruptionPolicy(JournalCorruptionPolicy.Fail))

Corruption policies:

PolicyBehaviorUse when
StopAtCorruptionStop replay at first corrupt entry; recover what is validDefault — safe, predictable
SkipCorruptedScan forward for next valid magic marker; skip corrupt entriesMaximize recovery at the cost of potential gaps
FailThrow JournalCorruptionException immediatelyStrict environments; fail-fast

Forced flush for critical commits:

// CommitAsync with forceFlush: true waits until the journal entry
// is fsynced to disk before returning.
await db.CommitAsync(forceFlush: true);

Use forceFlush: true for high-value operations (purchases, irreversible actions) where you need a guarantee that the data is on persistent storage before proceeding.


Backup Strategies

Strategy 1: Snapshot-Only

The simplest approach. Periodic snapshots capture full database state.

var db = DbContextBuilder<GameDb>.Create()
.WithDataDirectory("save/")
.WithSnapshot(snap => snap
.AutomaticSnapshotInterval(TimeSpan.FromMinutes(5))
.MaxSnapshotsToKeep(5))
.Build();

Characteristics:

  • Data-loss window equals the snapshot interval (up to 5 minutes in this example).
  • No journal overhead — simpler file management.
  • Recovery loads a single file.

Best for: Settings, preferences, cosmetic state — anything where losing a few minutes of changes is acceptable.

Example — settings database:

// SettingsDb is generated from `.conjure` schema AdditionalFiles.
// Its schema declares UserPreference and GraphicsSettings tables with explicit type_id values.

// Configure with snapshot-only, long interval
var settings = DbContextBuilder<SettingsDb>.Create()
.WithDataDirectory("settings/")
.WithSnapshot(snap => snap
.AutomaticSnapshotInterval(TimeSpan.FromMinutes(15))
.MaxSnapshotsToKeep(3))
.Build();

Strategy 2: Snapshot + Journal

Zero data loss. The journal captures every committed transaction between snapshots.

var db = DbContextBuilder<GameDb>.Create()
.WithDataDirectory("save/")
.WithSnapshot(snap => snap
.AutomaticSnapshotInterval(TimeSpan.FromMinutes(5))
.MaxSnapshotsToKeep(10)
.WithJournaling(journal => journal
.FlushInterval(TimeSpan.FromMilliseconds(100))
.MaxJournalFileSize(2 * 1024 * 1024)
.MaxStateChangesBeforeSnapshot(100)))
.Build();

Characteristics:

  • Zero data loss (every committed transaction is journaled).
  • Journal auto-triggers snapshots after MaxStateChangesBeforeSnapshot entries, keeping journal files short and recovery fast.
  • Slightly higher I/O — journal writes on every commit.

Best for: Player progression, inventory, in-app purchase records — anything where losing even one transaction is unacceptable.

Example — player progress database:

// PlayerDb is generated from `.conjure` schema AdditionalFiles.
// Its schema declares PlayerProfile, InventoryItem, PurchaseRecord, and QuestProgress tables.

var playerDb = DbContextBuilder<PlayerDb>.Create()
.WithDataDirectory("player/")
.WithSnapshot(snap => snap
.AutomaticSnapshotInterval(TimeSpan.FromMinutes(3))
.MaxSnapshotsToKeep(10)
.EnableIncrementalSnapshots()
.MaxDeltaChainLength(10)
.WithJournaling(journal => journal
.FlushInterval(TimeSpan.FromMilliseconds(50))
.MaxStateChangesBeforeSnapshot(50)))
.Build();

Strategy 3: Cloud Save (Remote Sync)

Extends snapshot + journal with remote storage for cross-device play.

var db = DbContextBuilder<GameDb>.Create()
.WithDataDirectory("save/")
.WithSnapshot(snap => snap
.AutomaticSnapshotInterval(TimeSpan.FromMinutes(5))
.WithJournaling())
.WithRemoteSnapshot(remote => remote
.RemoteHandler(new MyCloudSaveHandler())
.ResolutionStrategy(StateResolutionStrategy.RemoteFirst)
.MaxRetryAttempts(3)
.RequestTimeout(TimeSpan.FromSeconds(10)))
.Build();

Characteristics:

  • Combines local persistence with cloud backup.
  • Conflict resolution handles concurrent saves from multiple devices.
  • Network failures fall back to local state gracefully.

Best for: Cross-device play, live-service games, games requiring server-side save verification.

Strategy Decision Table

Game typeRecommended strategySnapshot intervalJournalCloud
Casual / hyper-casualSnapshot-only10–15 minNoOptional
Mid-core mobileSnapshot + Journal3–5 minYesRecommended
Hardcore RPG / MMO clientSnapshot + Journal + Cloud1–3 minYes, forceFlush on purchasesYes
PC / Console single-playerSnapshot + Journal5 minYesOptional (Steam Cloud)
Live-service with cross-deviceSnapshot + Journal + Cloud3 minYesRequired

Restore Procedures

Automatic Recovery on Open

Build() and BuildAsync() perform a full recovery automatically by calling InitializeAsync() internally:

var db = DbContextBuilder<GameDb>.Create()
.WithDataDirectory("save/")
.WithSnapshot(snap => snap.WithJournaling())
.Build();

Recovery sequence:

Build() / BuildAsync()

└─ InitializeAsync()

├─ 1. Build() — register all DbSets

├─ 2. RecoverAsync()
│ ├─ Check remote snapshot (if IRemoteSnapshotHandler configured)
│ ├─ Apply StateResolutionStrategy (remote vs. local)
│ ├─ Load latest full snapshot
│ ├─ Apply delta snapshots in epoch order (if incremental enabled)
│ └─ Replay journal entries with version > snapshot version

├─ 3. InitializeJournal() — start journal writer

└─ 4. TakePostMigrationSnapshotIfNeededAsync() — if schema changed

No manual intervention is required. After Build() or BuildAsync() returns, the database is in a consistent state reflecting the last committed transaction.

Manual Restore from Backup

To restore from a specific backup, copy the desired snapshot files into the data directory before re-opening the context:

// 1. Stop the database (if running)
db.Dispose();

// 2. Clear the current data directory
Directory.Delete("save/snapshots", recursive: true);
Directory.Delete("save/journal", recursive: true);

// 3. Copy backup files into place
Directory.CreateDirectory("save/snapshots");
File.Copy("backups/snapshot_1718000000000.dat", "save/snapshots/snapshot_1718000000000.dat");

// 4. Re-initialize — recovery loads the restored snapshot
var db = DbContextBuilder<GameDb>.Create()
.WithDataDirectory("save/")
.WithSnapshot(snap => snap.WithJournaling())
.Build();

Handling corrupted snapshots: The snapshot handler loads the latest snapshot file by Unix timestamp. If the latest snapshot is corrupted, remove it and the next Build() / BuildAsync() call will fall back to the next most recent snapshot. With MaxSnapshotsToKeep = 10, you have up to 10 recovery points.

// Fallback chain example:
// save/snapshots/
// snapshot_1718000600000.dat ← corrupted, delete this
// snapshot_1718000300000.dat ← recovery will use this
// snapshot_1718000000000.dat ← oldest fallback

Point-in-Time Recovery

With journaling enabled, recovery replays all journal entries after the snapshot. To restore to a specific point in time:

  1. Start with a snapshot taken before the target time.
  2. Keep only journal files covering the period up to the target time.
  3. Remove journal files with timestamps after the desired recovery point.
  4. Re-open the context with Build() or BuildAsync().
// To recover to a state at approximately 2024-06-10 14:30:00:

// 1. Keep the snapshot from before that time
// 2. Remove journal files created after that time:
var cutoff = new DateTime(2024, 6, 10, 14, 30, 0).Ticks;
foreach (var journal in Directory.GetFiles("save/journal/", "journal_*.dat"))
{
var ticks = long.Parse(Path.GetFileNameWithoutExtension(journal).Split('_')[1]);
if (ticks > cutoff)
File.Delete(journal);
}

// 3. Re-open the context; Build()/BuildAsync() replay only the remaining entries

Limitations:

  • Point-in-time recovery granularity depends on journal entry density and FlushInterval. With FlushInterval = 100ms, the resolution is ~100 ms.
  • Journal entries are replayed in full — you cannot stop at a specific transaction within a journal file without external tooling.
  • Delta snapshots must form a complete chain from the base full snapshot; removing a delta in the middle invalidates subsequent deltas.

Encrypted Backups

When encryption is enabled, both snapshots and journal files are transparently encrypted using AES-GCM with per-4 KB-chunk authentication tags.

var db = DbContextBuilder<GameDb>.Create()
.WithDataDirectory("save/")
.WithSnapshot(snap => snap.WithJournaling())
.WithEncryption(enc => enc
.Password("player-specific-secret")
.KeySizeInBits(256))
.Build();

Key Management for Backups

ConcernRecommendation
Key derivationPBKDF2 from password + salt. Salt is auto-generated (32 random bytes) if not specified.
Salt persistenceThe salt must be available at recovery time. Store it alongside (but separate from) the backup, or use a deterministic salt derived from a player ID.
Key rotationTo change the encryption key, load the database with the old key, then re-save with a new DbContextBuilder configured with the new key.
Backup portabilityEncrypted backups require the same password + salt to restore. Document these in your key management system.
// Key rotation example:

// 1. Open with old key
var oldDb = DbContextBuilder<GameDb>.Create()
.WithDataDirectory("save/")
.WithSnapshot(snap => snap.WithJournaling())
.WithEncryption(enc => enc
.Password("old-password")
.Salt("old-salt"))
.Build();

// 2. Export data (in-memory — no files involved)
var profiles = oldDb.Set<PlayerProfile>().All().ToArray();

oldDb.Dispose();

// 3. Clear old files and re-create with new key
Directory.Delete("save/", recursive: true);

var newDb = DbContextBuilder<GameDb>.Create()
.WithDataDirectory("save/")
.WithSnapshot(snap => snap.WithJournaling())
.WithEncryption(enc => enc
.Password("new-password")
.KeySizeInBits(256))
.Build();

foreach (var profile in profiles)
newDb.Set<PlayerProfile>().Add(profile);

newDb.Commit();
await newDb.SaveAsync();

Encryption Details

Backups use AES-GCM authenticated encryption with PBKDF2 key derivation (per-4 KB chunk, 16-byte auth tags; key sizes 128/192/256 bits, default 256; 32-byte random salt if omitted). See Configuration for the full parameter reference and Encryption Threat Model for the security contract.


Cloud Save Integration

Remote Snapshot Handler

Implement IRemoteSnapshotHandler to integrate with any cloud storage backend:

public interface IRemoteSnapshotHandler : IAsyncDisposable
{
Task<ulong?> GetRemoteVersionAsync();
Task LoadSnapshotAsync(DbContext context);
Task UploadSnapshotAsync(DbContext context, ulong version);
}

ConjureDB provides RemoteSnapshotHandlerBase as a base class for remote handlers. Override three protected methods:

public class MyCloudSaveHandler : RemoteSnapshotHandlerBase
{
private readonly ICloudStorageClient _client;

public MyCloudSaveHandler(ICloudStorageClient client, RemoteSnapshotOptions options)
: base(options)
{
_client = client;
}

protected override async Task<ulong?> GetRemoteVersionInternalAsync(
CancellationToken ct)
{
var metadata = await _client.GetMetadataAsync("save.dat", ct);
return metadata?.Version;
}

protected override async Task<SnapshotData?> GetSnapshotDataAsync(
CancellationToken ct)
{
var bytes = await _client.DownloadAsync("save.dat", ct);
if (bytes == null) return null;
return MessagePackSerializer.Deserialize<SnapshotData>(bytes);
}

protected override async Task UploadSnapshotDataAsync(
SnapshotData data, CancellationToken ct)
{
var bytes = MessagePackSerializer.Serialize(data);
await _client.UploadAsync("save.dat", bytes, ct);
}
}

SnapshotData structure (nested in RemoteSnapshotHandlerBase):

public class SnapshotData // Nested inside RemoteSnapshotHandlerBase
{
public ulong Version { get; set; }
public Dictionary<ushort, byte[]> Data { get; init; } // DbSet ID → serialized data
public EntitySchemaDescriptor[] SchemaDescriptors { get; init; } // Schema metadata
}

Inside a class that inherits from RemoteSnapshotHandlerBase, you can use SnapshotData directly in method signatures. Outside that inheritance scope, use RemoteSnapshotHandlerBase.SnapshotData.

Conflict Resolution

When both local and remote snapshots exist, StateResolutionStrategy controls which one wins:

.WithRemoteSnapshot(remote => remote
.ResolutionStrategy(StateResolutionStrategy.RemoteFirst))
StrategyBehaviorUse when
RemoteFirstUse remote unless unavailable; then fall back to localDefault — server is authority
LocalFirstUse local unless remote is newerOffline-first games
ForceRemoteAlways use remote; ignore localServer-authoritative games
ForceLocalAlways use local; ignore remoteDebugging, offline mode

Resolution flow during InitializeAsync():

GetRemoteVersionAsync()

├─ Remote available?
│ ├─ Yes → Compare with local snapshot version
│ │ ├─ RemoteFirst: load remote
│ │ ├─ LocalFirst: load local (unless remote > local)
│ │ ├─ ForceRemote: load remote
│ │ └─ ForceLocal: load local
│ │
│ └─ No (timeout / network error)
│ └─ Fall back to local snapshot

└─ No handler configured → load local snapshot

Retry and Timeout Configuration

.WithRemoteSnapshot(remote => remote
.RemoteHandler(new MyCloudSaveHandler())
.MaxRetryAttempts(3) // Default: 3
.RequestTimeout(TimeSpan.FromSeconds(10))) // Per-request timeout

RemoteSnapshotHandlerBase exposes RequestTimeout and MaxRetryAttempts as protected fields (populated from RemoteSnapshotOptions). The base class does not itself retry or enforce the timeout — your derived handler is responsible for honoring them inside its own GetSnapshotDataAsync / UploadSnapshotDataAsync / GetRemoteVersionInternalAsync implementations.

Platform Integration Examples

Unity Cloud Save:

public class UnityCloudSaveHandler : RemoteSnapshotHandlerBase
{
protected override async Task<ulong?> GetRemoteVersionInternalAsync(
CancellationToken ct)
{
var data = await CloudSaveService.Instance.Data.Player
.LoadAsync(new HashSet<string> { "save_version" });

if (data.TryGetValue("save_version", out var item))
return Convert.ToUInt64(item.Value.GetAs<long>());

return null;
}

protected override async Task<SnapshotData?> GetSnapshotDataAsync(
CancellationToken ct)
{
var data = await CloudSaveService.Instance.Data.Player
.LoadAsync(new HashSet<string> { "save_data" });

if (!data.TryGetValue("save_data", out var item))
return null;

var bytes = Convert.FromBase64String(item.Value.GetAs<string>());
return MessagePackSerializer.Deserialize<SnapshotData>(bytes);
}

protected override async Task UploadSnapshotDataAsync(
SnapshotData data, CancellationToken ct)
{
var bytes = MessagePackSerializer.Serialize(data);
var saveData = new Dictionary<string, object>
{
["save_version"] = (long)data.Version,
["save_data"] = Convert.ToBase64String(bytes)
};
await CloudSaveService.Instance.Data.Player.SaveAsync(saveData);
}
}

Steam Cloud (Steamworks.NET):

public class SteamCloudHandler : RemoteSnapshotHandlerBase
{
private const string FileName = "player_save.dat";

protected override Task<ulong?> GetRemoteVersionInternalAsync(
CancellationToken ct)
{
if (!SteamRemoteStorage.FileExists(FileName))
return Task.FromResult<ulong?>(null);

var bytes = new byte[SteamRemoteStorage.GetFileSize(FileName)];
SteamRemoteStorage.FileRead(FileName, bytes, bytes.Length);
var data = MessagePackSerializer.Deserialize<SnapshotData>(bytes);
return Task.FromResult<ulong?>(data.Version);
}

protected override Task<SnapshotData?> GetSnapshotDataAsync(
CancellationToken ct)
{
if (!SteamRemoteStorage.FileExists(FileName))
return Task.FromResult<SnapshotData?>(null);

var bytes = new byte[SteamRemoteStorage.GetFileSize(FileName)];
SteamRemoteStorage.FileRead(FileName, bytes, bytes.Length);
return Task.FromResult<SnapshotData?>(
MessagePackSerializer.Deserialize<SnapshotData>(bytes));
}

protected override Task UploadSnapshotDataAsync(
SnapshotData data, CancellationToken ct)
{
var bytes = MessagePackSerializer.Serialize(data);
SteamRemoteStorage.FileWrite(FileName, bytes, bytes.Length);
return Task.CompletedTask;
}
}

Best Practices

Mobile Games

Mobile platforms aggressively kill backgrounded apps. Design your backup strategy around this reality.

var db = DbContextBuilder<MobileGameDb>.Create()
.WithDataDirectory(Application.persistentDataPath + "/save")
.WithSnapshot(snap => snap
.AutomaticSnapshotInterval(TimeSpan.FromMinutes(3))
.MaxSnapshotsToKeep(5)
.WithJournaling(journal => journal
.FlushInterval(TimeSpan.FromMilliseconds(200))
.MaxStateChangesBeforeSnapshot(50)))
.WithRemoteSnapshot(remote => remote
.RemoteHandler(cloudHandler)
.ResolutionStrategy(StateResolutionStrategy.RemoteFirst))
.Build();

Key practices:

PracticeImplementation
Save on app backgroundCall SaveAsync() in OnApplicationPause(true)
Save on level completionCall SaveAsync() after committing level results
Minimize battery impactUse longer FlushInterval (200–500 ms)
Minimize snapshot frequencyUse journal to avoid frequent full writes
Cloud sync on resumeUpload snapshot in OnApplicationPause(false)
private async void OnApplicationPause(bool paused)
{
if (paused)
{
// Save locally before OS kills us
await db.SaveAsync();
}
else
{
// Optionally sync to cloud on resume
await UploadToCloud();
}
}

PC / Console Games

PC and console games have more reliable storage and power, but players expect manual save slots and quick-save.

Auto-save with manual save slots:

public class SaveManager
{
private readonly string _baseDir;

public SaveManager(string baseDir)
{
_baseDir = baseDir;
}

/// <summary>
/// Create a named save slot by copying the current snapshot files.
/// </summary>
public async Task CreateSaveSlot(DbContext db, string slotName)
{
// 1. Force a fresh snapshot
await db.SaveAsync();

// 2. Copy snapshot files to the save slot directory
var slotDir = Path.Combine(_baseDir, "slots", slotName);
Directory.CreateDirectory(slotDir);

var snapshotDir = Path.Combine(_baseDir, "snapshots");
foreach (var file in Directory.GetFiles(snapshotDir, "snapshot_*.dat"))
{
File.Copy(file, Path.Combine(slotDir, Path.GetFileName(file)),
overwrite: true);
}
}

/// <summary>
/// Restore from a named save slot.
/// </summary>
public void RestoreSaveSlot(string slotName)
{
var slotDir = Path.Combine(_baseDir, "slots", slotName);
var snapshotDir = Path.Combine(_baseDir, "snapshots");
var journalDir = Path.Combine(_baseDir, "journal");

// Clear current state
if (Directory.Exists(snapshotDir))
Directory.Delete(snapshotDir, recursive: true);
if (Directory.Exists(journalDir))
Directory.Delete(journalDir, recursive: true);

// Copy slot files to snapshot directory
Directory.CreateDirectory(snapshotDir);
foreach (var file in Directory.GetFiles(slotDir, "snapshot_*.dat"))
{
File.Copy(file, Path.Combine(snapshotDir, Path.GetFileName(file)));
}

// Next Build()/BuildAsync() will load from the restored slot
}
}

Quick-save / quick-load:

// Quick-save: just trigger a snapshot
public async Task QuickSave(DbContext db)
{
db.Commit();
await db.SaveAsync();
}

// Quick-load: dispose, restore, rebuild
public async Task<GameDb> QuickLoad(GameDb db, DbContextBuilder<GameDb> builder)
{
db.Dispose();
// Build() loads the latest snapshot automatically
return builder.Build();
}

Live-Service Games

Live-service games need frequent saves, cloud sync, and schema migration support across updates.

var db = DbContextBuilder<LiveGameDb>.Create()
.WithDataDirectory("save/")
.WithSnapshot(snap => snap
.AutomaticSnapshotInterval(TimeSpan.FromMinutes(1))
.MaxSnapshotsToKeep(15)
.EnableIncrementalSnapshots()
.MaxDeltaChainLength(5)
.WithJournaling(journal => journal
.FlushInterval(TimeSpan.FromMilliseconds(50))
.MaxStateChangesBeforeSnapshot(30)))
.WithRemoteSnapshot(remote => remote
.RemoteHandler(cloudHandler)
.ResolutionStrategy(StateResolutionStrategy.RemoteFirst)
.MaxRetryAttempts(5)
.RequestTimeout(TimeSpan.FromSeconds(15)))
.WithEncryption(enc => enc
.Password(PlayerAuth.GetEncryptionKey())
.KeySizeInBits(256))
.Build();

Key practices:

ConcernApproach
Session dataFrequent snapshots (1 min) + journal
Cross-deviceCloud sync with RemoteFirst strategy
Game updatesSchema migration (see SchemaMigration) + post-migration snapshot
Anti-cheatEncrypt local saves; validate server-side
Version conflictsUpload snapshot after every significant event; RemoteFirst ensures latest device wins

Troubleshooting

For general persistence and encryption errors (corrupted snapshot / JournalCorruptionException, slow restore of large databases, encryption key mismatch), see Troubleshooting. Backup-specific symptoms not covered there:

ProblemCauseSolution
Large snapshot filesToo many or large entitiesEnable incremental snapshots; lower DeltaToFullThreshold; prune unused data
Cloud sync conflictsConcurrent saves from multiple devicesSet StateResolutionStrategy explicitly; RemoteFirst is safest for most games
Recovery loads stale dataJournal files deleted or corruptEnsure MaxSnapshotsToKeep > 1 for fallback; use StopAtCorruption policy to recover partial journal
Out of disk spaceToo many snapshots + journalsLower MaxSnapshotsToKeep; reduce MaxJournalFileSize; enable incremental snapshots
Encryption key lostSalt not persistedAlways store the salt separately; use deterministic salt derivation from player ID if possible
Snapshot skippedNo data changed since last snapshotExpected behavior — Save() is a no-op when version is unchanged

See Also