Quick Design Spec: Save & Persistence
Type: New Small System Scope: The host-owned save file and the load/reconcile pipeline. Owns storage format, write atomicity, versioning, and reconcile orchestration — never the per-system schemas or reconcile semantics (each owning system defines those; this spec consolidates the five ⚑ provisional contracts). Does NOT own: diff schemas (Harvesting), metadata semantics (Crafting), structure posture (Base Building), stack serialization (Inventory), time/validity derivation (Tide). Date: 2026-08-26 Estimated Implementation: ~4 days (diff round-trip already spike-validated — prototypes/tide-scheduler-spike-2026-08-25/)
Overview
One world = one host-owned save file. The world itself is mostly math — DockedLands(seed, t, config) is pure — so the save stores only what the tide cannot recompute: the seed and clock guard, the group's earned knowledge, harvest diffs, the structure registry, gear identity, and inventories. On load, Save orchestrates a fixed reconcile order in which each system applies its own already-locked rules. Two classes of state exist and never blur: droppable (harvest diffs — stale ones age out by design) and permanent (knowledge, marks, structures — never stale-dropped, reconciled to inert/orphan forms instead).
Core Rules
- One file, host-owned, never merged. A world is one save on the host's machine (MVP = fixed host; save-anyone-can-host is Tier 1 first-to-slip). No merge path exists or may ever be added (concept risk ruling) — loading an older copy is legal but forks the world permanently; that is a fact, not an error.
- What persists (each row's schema + reconcile semantics owned by its system):
worldSeed+configVersion+saveFormatVersion;TideState = {lastKnownGoodT, epochIndex, epochActivationT}(tide's monotonic clock guard plus the epoch counter and its activation timestamp — persistent inputs to the schedule, never derivable; tide Edge Case 14, ADR-0001 2026-08-27) plussocketCountin the save's tide block — theNthe save last ran under, a load-time comparison input for the config epoch (tide EC14), not part ofTideState(ADR-0006 amendment to ADR-0004, 2026-08-28);KnownLandTemplates(LDU, historical facts, seeded starting templates — crafting Edge Case 17);KnownRecipes(crafting, group-level);HarvestDiff[](harvesting, epoch-qualified keys per tide review M2);StructureInstance[](Base Building);InstanceMetadata[](crafting, permanent); per-player CARRY + equipped instances;SharedStoragecontents (Inventory's serialization, keyed by containerinstanceId); the two host-monotonicinstanceIdcounters (gear + structures — persisted, never reused). - Durability contract: per-drain, bounded, broadcast-first (restated by ADR-0004, 2026-08-28 — supersedes the per-confirmed-action wording). Once the host confirms a mutation it is durable within
flushDeadlineMson a healthy disk; a host crash loses at most the drains sincelastDurableDrain(wall-clock bound, not a count) — clients may have seen a broadcast the reloaded save lacks (e.g. a placed wall vanishes on reload); accepted for a friends-only game. Writes are atomic (write-temp → fsync /F_FULLFSYNCon macOS →File.Replace); a torn write can never produce a half-loaded world — the previous good file or a backup wins. File I/O never runs on the host main thread during play (ADR-0002/0004). - Version gate, fail loudly. Unknown/future
saveFormatVersionorconfigVersion→ refuse load with a clear message; never partial-load, never silently migrate. Content changes within a known version (template pools, removed items) are handled by each system's reconcile — never by Save. - Load pipeline (fixed order): version gate → clock guard (
lastKnownGoodT; future value wins, no rollback — tide Edge Case 5) → tide derives current epoch/cycle purely → union-reconciles (KnownLandTemplates,KnownRecipes— knowledge is never revoked, crafting Edge Case 19) → droppable reconcile (HarvestDiffvia tide's validity predicate — stale/orphaned dropped per harvesting Edge Cases 19–20) → permanent reconcile (StructureRegistryinert relics per BB Edge Cases 13–16;InstanceMetadataorphan rules per crafting Edge Cases 8–9) → inventories (stack lists; tag-without-row → mystery gear, crafting Edge Case 9) → player placement (saved position on a now-Absent land → deposited at home shore, bag intact — tide Edge Case 1 / Rule 9). - The two-class rule (binding):
HarvestDiffis the only droppable table. Everything else is permanent — reconciled to inert/orphan/union forms, never deleted by staleness. A future system adding persisted state must declare its class in its own doc. - Player state at load: position per Rule 5; HP restores to full; Downed/revive state is never persisted (a session boundary is a fresh beat — no mechanical loss exists at session boundaries anywhere in this game, and loading into a downed state would be hostile). Equipped gear and its durability persist exactly (crafting Edge Case 15).
Tuning Knobs
| Knob | Default | Range | Category | Rationale |
|---|---|---|---|---|
saveDebounceMs | 0 | 0–1000 | gate | Meaning restated by ADR-0004: 0 = one background flush job per dirty drain; > 0 = coalescing window after a dirty drain before enqueuing (merging further drains). Raise only if disk I/O measurably hurts — anything above 0 widens the loss window and needs a measured reason |
flushDeadlineMs | 250 | 50–1000 | gate | ADR-0004: a flush exceeding this logs loudly (never a gameplay effect). Conservative default, retuned from the standalone-build measurement before Alpha |
backupCopies | 3 | 1–10 | gate | Rolling previous-good copies; the recovery path when the current file is damaged outside a torn write |
Values live in assets/data/save.json, not hardcoded.
Acceptance Criteria
- [ ] [UNIT] Full save → load round-trip: every table in Rule 2 is byte-identical (the crafting AC-37 shape, generalized).
- [ ] [UNIT] Unknown
saveFormatVersion/configVersion→ load refused loudly; no partial state is constructed. - [ ] [UNIT]
lastKnownGoodTin the future vs host clock → future value wins, no rollback (tide AC-32 through the pipeline). - [ ] [UNIT] Reconcile order matches Rule 5 exactly and each stage invokes the owning system's predicate — Save contains no reconcile semantics of its own (verified by inspection: no drop/keep decision logic lives in Save code).
- [ ] [UNIT] Two-class rule: a stale
HarvestDiffis dropped; a stale-lookingStructureInstance/InstanceMetadata/KnownRecipesentry is never dropped — inert/orphan/union per its owner's rules. - [ ] [UNIT] Both
instanceIdcounters survive save/load monotonically; no reuse after round-trip. - [ ] [UNIT] Torn-write simulation (truncated temp file at the swap instant) → previous good file loads; corrupted current + intact backup → backup loads.
- [ ] [UNIT] Saved position on a now-Absent land → player placed at home shore, CARRY bit-identical.
- [ ] [UNIT] Schema-negative: no merge/import path for a second save file exists anywhere.
- [ ] [INTEGRATION] Kill the host process between two drains → reload loses at most the drains after
lastDurableDrain; a flush exceedingflushDeadlineMsproduces the loud log and no gameplay effect (durability contract as restated by ADR-0004). - [ ] Regression: loading never triggers gameplay events (no craft commits, no harvest resolution, no tide stingers) — reconcile is silent by construction.
Systems Index
Already tracked as MVP system #4 (Persistence, quick-spec, "Diff-contract validated (spike)"). On approval: status → **Designed (quick-spec)** — and the five ⚑ provisional rows in tide/harvesting/crafting/base-building/inventory reconcile against this spec.
Forward refs owned elsewhere: on-disk serialization format choice (JSON vs binary) + save-file location → implementation; replication of loaded state to late-joining clients → netcode ADR; save-anyone-can-host → Tier 1 (first-to-slip, explicitly not designed here).