Skip to content
Edit on GitHub

Add a persisted field

verifiedAgainst f1eaefe · verifiedOn 2026-09-10 · the automated staleness banner is planned, not built.

This guide walks through persisting a new per-world configuration field through the game’s atomic save system, handling legacy save migrations, validating ranges, and verifying round-trip integrity with EditMode tests. The reference implementations to diff alongside it are WorldMetadata.hasEnvironmentState (which added optional time-of-day state) and WorldMetadata.userWorldSeed (which introduced format version 2).

A per-world difficulty setting (WorldDifficulty) — an enum specifying world simulation rules:

public enum WorldDifficulty
{
Peaceful = 0,
Standard = 1,
Hardcore = 2
}

You will:

  1. Store worldDifficulty in WorldMetadata.json for each save slot.
  2. Increment WorldMetadata.CurrentFormatVersion from 2 to 3.
  3. Support backward compatibility: existing save files (format versions 1 and 2) without this field load safely and migrate to WorldDifficulty.Standard.
  4. Validate that incoming JSON cannot deserialize invalid enum values (e.g. out-of-range integer casts).
  5. Expose slot difficulty to menus via SaveSlotSummary without loading chunk or player payloads.
  6. Verify persistence with automated EditMode unit tests using temporary test directories.

World persistence is strictly separated into two storage tiers:

  • Lightweight metadata (world-metadata.json): Managed via AtomicJsonFile. Loaded rapidly by world select menus to display slot name, seed, timestamp, and game rules.
  • Sparse chunk edits (world-edits.bin): Managed via AtomicBinaryWorldSaveFile. Stores player voxel additions/removals across chunks via WorldEditOverlay.
sequenceDiagram
  autonumber
  actor User as Game / UI
  participant Service as SaveGameService
  participant Meta as WorldMetadata
  participant Atomic as AtomicJsonFile
  participant Disk as Local Disk (.tmp / .json / .bak)

  Note over User,Disk: Save Flow (Atomic Write)
  User->>Service: TryCreateSlot(slotId, name, seed, difficulty)
  Service->>Meta: Create(slotId, name, seed, difficulty, utcNow)
  Service->>Meta: TryValidate()
  Meta-->>Service: true (validated)
  Service->>Atomic: TrySave(metadataPath, metadata)
  Atomic->>Disk: Write JSON to "world-metadata.json.tmp"
  alt File already exists
    Atomic->>Disk: File.Replace(tmp, target, backup=".bak")
  else New file
    Atomic->>Disk: File.Move(tmp, target)
  end
  Atomic-->>Service: true (saved)
  Service-->>User: slot created

  Note over User,Disk: Load & Migration Flow (Safe Fallback)
  User->>Service: TryLoadMetadata(slotId)
  Service->>Atomic: TryLoad(metadataPath, validator)
  alt Primary JSON corrupt or missing
    Atomic->>Disk: Read "world-metadata.json.bak"
  else Primary JSON healthy
    Atomic->>Disk: Read "world-metadata.json"
  end
  Atomic->>Meta: JsonUtility.FromJson<WorldMetadata>()
  Atomic->>Meta: TryValidate()
  Note over Meta: formatVersion < 3 defaults to Standard
  Atomic-->>Service: WorldMetadata instance
  Service-->>User: metadata loaded
  1. Atomic replacement only: Save files are never written directly in place with File.WriteAllText. Writes always target .tmp, then execute an atomic filesystem replace with .bak preserved.
  2. Never serialize terrain: Terrain voxels are generated deterministically from worldSeed and generatorVersion. Only sparse modifications (WorldEditOverlay) are written to disk.
  3. Validate before accepting: Deserialized data is untrusted. If TryValidate() fails, the load returns false with a descriptive error string rather than poisoning game state.

Read these files at pinned commit f1eaefe before authoring:

# File Symbol / What to extract
1 Assets/_Game/Scripts/World/Persistence/WorldMetadata.cs CurrentFormatVersion, Create(), TryValidate(), and how hasUserWorldSeed handled version 2.
2 Assets/_Game/Scripts/World/Persistence/AtomicJsonFile.cs TrySave() and TryLoad(), the .tmp and .bak double-buffered file lifecycle.
3 Assets/_Game/Scripts/World/Persistence/SaveGameService.cs TryCreateSlot(), TryLoadMetadata(), and TryListSlots().
4 Assets/_Game/Tests/EditMode/World/SaveGameServiceTests.cs MetadataValidation_AcceptsLegacyFormatOneSeeds test pattern to copy for migration verification.
Decision Choice Why
Enum serialization Integer / named enum Unity’s JsonUtility serializes C# enums as integers. Default value 0 matches WorldDifficulty.Peaceful or Standard.
Default for legacy saves WorldDifficulty.Standard Pre-existing worlds created in format version 1 or 2 were created under standard simulation rules.
Format version bump 2 -> 3 Explicit format versions allow distinguishing intentionally set defaults from unmigrated older saves.

Create Assets/_Game/Scripts/World/Persistence/WorldDifficulty.cs:

namespace VoxelSandbox.World.Persistence
{
public enum WorldDifficulty
{
Peaceful = 0,
Standard = 1,
Hardcore = 2
}
}

In Assets/_Game/Scripts/World/Persistence/WorldMetadata.cs:

  1. Bump CurrentFormatVersion to 3.
  2. Add the worldDifficulty field.
  3. Update Create() factory overloads.
  4. Add range validation in TryValidate().
// In Assets/_Game/Scripts/World/Persistence/WorldMetadata.cs
public sealed class WorldMetadata
{
// Bump format version from 2 to 3
public const int CurrentFormatVersion = 3;
public int formatVersion = CurrentFormatVersion;
public string slotId;
public string displayName;
public bool hasUserWorldSeed;
public long userWorldSeed;
public int worldSeed;
public int generatorVersion;
public long createdUtcTicks;
public long lastSavedUtcTicks;
public bool hasEnvironmentState;
public float timeOfDayFraction;
public float weatherElapsedSeconds;
public bool hasGlobalSoilMoisture;
public float globalSoilMoisture;
// New field with default
public WorldDifficulty worldDifficulty = WorldDifficulty.Standard;
// ...

Update the Create factory method to accept difficulty (with a backward-compatible default):

public static WorldMetadata Create(
string slotId,
string displayName,
long userWorldSeed,
int worldSeed,
int generatorVersion,
DateTime utcNow,
WorldDifficulty difficulty = WorldDifficulty.Standard)
{
// ... (existing validation checks) ...
long timestamp = utcNow.ToUniversalTime().Ticks;
return new WorldMetadata
{
slotId = slotId,
displayName = displayName.Trim(),
hasUserWorldSeed = true,
userWorldSeed = userWorldSeed,
worldSeed = worldSeed,
generatorVersion = generatorVersion,
createdUtcTicks = timestamp,
lastSavedUtcTicks = timestamp,
worldDifficulty = difficulty
};
}

Update TryValidate() to check both format version compatibility and enum validity:

public bool TryValidate(out string error)
{
if (formatVersion < 1 || formatVersion > CurrentFormatVersion)
{
error = $"Unsupported world metadata format {formatVersion}.";
return false;
}
if (formatVersion >= 2 && !hasUserWorldSeed)
{
error = "World metadata is missing its user-entered seed.";
return false;
}
// Validate enum range for FormatVersion >= 3
if (formatVersion >= 3)
{
if (!Enum.IsDefined(typeof(WorldDifficulty), worldDifficulty))
{
error = $"World metadata contains an invalid world difficulty value {(int)worldDifficulty}.";
return false;
}
}
else
{
// Legacy migration: ensure legacy saves are normalized to Standard
worldDifficulty = WorldDifficulty.Standard;
}
// ... (remaining existing checks) ...
error = null;
return true;
}

Expose difficulty through TryCreateSlot so callers can specify it at world creation time:

// In Assets/_Game/Scripts/World/Persistence/SaveGameService.cs
public bool TryCreateSlot(
string slotId,
string displayName,
long userWorldSeed,
int worldSeed,
int generatorVersion,
WorldDifficulty difficulty,
out WorldMetadata metadata,
out string error)
{
metadata = null;
try
{
metadata = WorldMetadata.Create(slotId, displayName, userWorldSeed, worldSeed, generatorVersion, DateTime.UtcNow, difficulty);
string slotDirectory = GetSlotDirectory(slotId);
if (Directory.Exists(slotDirectory) || File.Exists(slotDirectory))
{
error = $"A world slot named '{slotId}' already exists.";
metadata = null;
return false;
}
Directory.CreateDirectory(slotDirectory);
if (!AtomicJsonFile.TrySave(GetMetadataPath(slotId), metadata, out error))
{
metadata = null;
return false;
}
return true;
}
catch (Exception exception)
{
metadata = null;
error = exception.Message;
return false;
}
}

Keep the existing overload as a forwarding wrapper passing WorldDifficulty.Standard to prevent breaking existing callsites.

Open Assets/_Game/Tests/EditMode/World/SaveGameServiceTests.cs and add test cases for:

  1. Round-trip serialization of the new field.
  2. Legacy save file migration (format version 2 JSON deserializing and defaulting cleanly).
  3. Rejection of corrupted or out-of-range difficulty values.
// In Assets/_Game/Tests/EditMode/World/SaveGameServiceTests.cs
[Test]
public void CreateSlot_PreservesWorldDifficultyAcrossRoundTrip()
{
Assert.That(saves.TryCreateSlot(
"hardcore-world",
"Hardcore World",
12345L,
12345,
1,
WorldDifficulty.Hardcore,
out WorldMetadata created,
out string createError), Is.True, createError);
Assert.That(created.worldDifficulty, Is.EqualTo(WorldDifficulty.Hardcore));
Assert.That(saves.TryLoadMetadata("hardcore-world", out WorldMetadata loaded, out string loadError), Is.True, loadError);
Assert.That(loaded.worldDifficulty, Is.EqualTo(WorldDifficulty.Hardcore));
Assert.That(loaded.formatVersion, Is.EqualTo(WorldMetadata.CurrentFormatVersion));
}
[Test]
public void MetadataValidation_MigratesFormatVersionTwoToStandardDifficulty()
{
WorldMetadata legacy = WorldMetadata.Create("legacy-v2", "Legacy V2", 42L, 42, 1, DateTime.UtcNow);
legacy.formatVersion = 2;
// Simulate legacy JSON where worldDifficulty field was absent or unassigned
Assert.That(legacy.TryValidate(out string error), Is.True, error);
Assert.That(legacy.worldDifficulty, Is.EqualTo(WorldDifficulty.Standard));
}
[Test]
public void MetadataValidation_RejectsInvalidDifficultyEnumValues()
{
WorldMetadata metadata = WorldMetadata.Create("corrupt-diff", "Corrupt Diff", 42L, 42, 1, DateTime.UtcNow);
metadata.formatVersion = 3;
metadata.worldDifficulty = (WorldDifficulty)999; // Invalid out-of-range value
Assert.That(metadata.TryValidate(out string error), Is.False);
Assert.That(error, Does.Contain("invalid world difficulty"));
}

Run the test suite in headless batch mode:

Terminal window
/home/soulwax/Unity/Hub/Editor/6000.6.0f1/Editor/Unity \
-batchmode -nographics \
-projectPath . \
-runTests \
-testPlatform EditMode \
-testCategory World \
-testResults Logs/editmode-world-results.xml \
-logFile Logs/test-run.log

Confirm that CreateSlot_PreservesWorldDifficultyAcrossRoundTrip, MetadataValidation_MigratesFormatVersionTwoToStandardDifficulty, and MetadataValidation_RejectsInvalidDifficultyEnumValues pass.

Verify that when TrySave executes:

  1. world-metadata.json.tmp is created first.
  2. If world-metadata.json already exists, it is renamed to world-metadata.json.bak.
  3. The new metadata is written to world-metadata.json.

Use this checklist when persisting any new field in WorldMetadata, PlayerSaveData, or WorldSaveData:

  1. Determine the lifecycle tier:
    • Is it human-facing slot metadata needed by menus? $\rightarrow$ Add to WorldMetadata.cs (JSON).
    • Is it player runtime state (inventory, health, vitals)? $\rightarrow$ Add to PlayerSaveData.cs (JSON).
    • Is it a world block modification? $\rightarrow$ Add to WorldSaveData.cs (binary).
  2. Bump the format version: Increment CurrentFormatVersion by 1.
  3. Handle older versions in TryValidate:
    • If formatVersion < NewVersion, assign a safe documented baseline default (e.g. 0, empty collection, standard enum).
    • If formatVersion > CurrentFormatVersion, reject with "Unsupported format version".
  4. Ensure JSON serializability: Unity’s JsonUtility requires [Serializable], public fields (or [SerializeField]), and does not serialize dictionaries or multi-dimensional arrays without custom wrappers.
  5. Always write via atomic wrappers: Always invoke AtomicJsonFile.TrySave or AtomicBinaryWorldSaveFile.TrySave. Never write directly using System.IO.File.WriteAllText.
  6. Add the 3 mandatory tests:
    • Round-trip save and load of the new field.
    • Migration test verifying a lower formatVersion payload defaults properly.
    • Validation failure test verifying corrupted/invalid inputs are rejected before mutating game state.

Pitfall 1: Not bumping CurrentFormatVersion

Section titled “Pitfall 1: Not bumping CurrentFormatVersion”
  • Symptom: Saves created on newer builds fail silently or load with uninitialized zeroes on older builds without raising a clear version error.
  • Cause: The loader cannot differentiate whether a missing field was intentional or from an older format.
  • Fix: Always increment CurrentFormatVersion whenever fields are added or restructured.

Pitfall 2: Using File.WriteAllText directly

Section titled “Pitfall 2: Using File.WriteAllText directly”
  • Symptom: If the game process terminates mid-write (crash, power loss, force close), the world save is truncated to 0 bytes and unrecoverable.
  • Cause: Bypassing AtomicJsonFile.TrySave.
  • Fix: Use AtomicJsonFile.TrySave(path, data, out error). It writes to .tmp first, then atomically swaps files, leaving .bak as a fail-safe backup.

Pitfall 3: Storing raw voxel grids instead of deltas

Section titled “Pitfall 3: Storing raw voxel grids instead of deltas”
  • Symptom: Save file balloons to hundreds of megabytes within minutes of exploration.
  • Cause: Attempting to serialize entire chunk voxel buffers into the save file.
  • Fix: Terrain is procedural and deterministic. Only sparse edits recorded in WorldEditOverlay are saved to world-edits.bin.

Pitfall 4: Forgetting to test legacy JSON loading

Section titled “Pitfall 4: Forgetting to test legacy JSON loading”
  • Symptom: Existing players update the game and their save worlds fail validation or become unplayable.
  • Cause: TryValidate strictly required the new field without checking formatVersion < CurrentVersion.
  • Fix: Guard new strict requirements with if (formatVersion >= TargetVersion). Provide fallback defaults for older format versions.

User-contributed notes

Corrections, clarifications, and practical tips for this page. Anonymous is fine — a name is optional. Basic Markdown works: **bold**, *italic*, `code`, and links.

Notes policy

Notes are lightly filtered for spam and may be edited or removed. Keep them about this page — no support requests, no personal data, nothing you would not publish. Links are limited and marked nofollow.

  1. Loading notes…