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).
What you will build
Section titled “What you will build”A per-world difficulty setting (WorldDifficulty) — an enum specifying world simulation rules:
public enum WorldDifficulty{ Peaceful = 0, Standard = 1, Hardcore = 2}You will:
- Store
worldDifficultyinWorldMetadata.jsonfor each save slot. - Increment
WorldMetadata.CurrentFormatVersionfrom2to3. - Support backward compatibility: existing save files (format versions 1 and 2) without this field load safely and migrate to
WorldDifficulty.Standard. - Validate that incoming JSON cannot deserialize invalid enum values (e.g. out-of-range integer casts).
- Expose slot difficulty to menus via
SaveSlotSummarywithout loading chunk or player payloads. - Verify persistence with automated EditMode unit tests using temporary test directories.
Where this sits
Section titled “Where this sits”World persistence is strictly separated into two storage tiers:
- Lightweight metadata (
world-metadata.json): Managed viaAtomicJsonFile. Loaded rapidly by world select menus to display slot name, seed, timestamp, and game rules. - Sparse chunk edits (
world-edits.bin): Managed viaAtomicBinaryWorldSaveFile. Stores player voxel additions/removals across chunks viaWorldEditOverlay.
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
Invariants
Section titled “Invariants”- 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.bakpreserved. - Never serialize terrain: Terrain voxels are generated deterministically from
worldSeedandgeneratorVersion. Only sparse modifications (WorldEditOverlay) are written to disk. - Validate before accepting: Deserialized data is untrusted. If
TryValidate()fails, the load returnsfalsewith a descriptive error string rather than poisoning game state.
Before you start
Section titled “Before you start”Reading manifest
Section titled “Reading manifest”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. |
Intake decisions
Section titled “Intake decisions”| 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. |
The files, in order
Section titled “The files, in order”Step 1: Define the WorldDifficulty enum
Section titled “Step 1: Define the WorldDifficulty enum”Create Assets/_Game/Scripts/World/Persistence/WorldDifficulty.cs:
namespace VoxelSandbox.World.Persistence{ public enum WorldDifficulty { Peaceful = 0, Standard = 1, Hardcore = 2 }}Step 2: Update WorldMetadata.cs
Section titled “Step 2: Update WorldMetadata.cs”In Assets/_Game/Scripts/World/Persistence/WorldMetadata.cs:
- Bump
CurrentFormatVersionto3. - Add the
worldDifficultyfield. - Update
Create()factory overloads. - Add range validation in
TryValidate().
// In Assets/_Game/Scripts/World/Persistence/WorldMetadata.cspublic 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; }Step 3: Update SaveGameService.cs
Section titled “Step 3: Update SaveGameService.cs”Expose difficulty through TryCreateSlot so callers can specify it at world creation time:
// In Assets/_Game/Scripts/World/Persistence/SaveGameService.cspublic 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.
Step 4: Add EditMode persistence tests
Section titled “Step 4: Add EditMode persistence tests”Open Assets/_Game/Tests/EditMode/World/SaveGameServiceTests.cs and add test cases for:
- Round-trip serialization of the new field.
- Legacy save file migration (format version 2 JSON deserializing and defaulting cleanly).
- 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"));}Verify
Section titled “Verify”1. Run EditMode tests via CLI
Section titled “1. Run EditMode tests via CLI”Run the test suite in headless batch mode:
/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.logConfirm that CreateSlot_PreservesWorldDifficultyAcrossRoundTrip, MetadataValidation_MigratesFormatVersionTwoToStandardDifficulty, and MetadataValidation_RejectsInvalidDifficultyEnumValues pass.
2. Verify Atomic Replacement
Section titled “2. Verify Atomic Replacement”Verify that when TrySave executes:
world-metadata.json.tmpis created first.- If
world-metadata.jsonalready exists, it is renamed toworld-metadata.json.bak. - The new metadata is written to
world-metadata.json.
Now do your own
Section titled “Now do your own”Use this checklist when persisting any new field in WorldMetadata, PlayerSaveData, or WorldSaveData:
- 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).
- Is it human-facing slot metadata needed by menus? $\rightarrow$ Add to
- Bump the format version: Increment
CurrentFormatVersionby 1. - 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".
- If
- Ensure JSON serializability: Unity’s
JsonUtilityrequires[Serializable], public fields (or[SerializeField]), and does not serialize dictionaries or multi-dimensional arrays without custom wrappers. - Always write via atomic wrappers: Always invoke
AtomicJsonFile.TrySaveorAtomicBinaryWorldSaveFile.TrySave. Never write directly usingSystem.IO.File.WriteAllText. - Add the 3 mandatory tests:
- Round-trip save and load of the new field.
- Migration test verifying a lower
formatVersionpayload defaults properly. - Validation failure test verifying corrupted/invalid inputs are rejected before mutating game state.
Pitfalls
Section titled “Pitfalls”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
CurrentFormatVersionwhenever 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.tmpfirst, then atomically swaps files, leaving.bakas 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
WorldEditOverlayare saved toworld-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:
TryValidatestrictly required the new field without checkingformatVersion < 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.