Skip to content

One store, many facts

Two hundred chests, doors and levers that stay as the player left them — through one save participant rather than two hundred.

Recipe

Systems required: Save, which lives in Core. Package: any — Core ships in all of them, so every package can run this one. Shape: one file holding two small components — a store that joins the save file, and an optional gate that switches a world object from a single fact. Public API only. It assumes: a RevSaveManager in the scene, and that the store sits in a scene that is never unloaded — the same one the manager is in. Once you change it, it is your code. Copying and editing is the intended path — so a modified recipe is yours to maintain and debug. Support covers the framework's behaviour, not a copy of this class.

The part that is not obvious

IRevSaveParticipant is deliberately per-owner: a thing that holds state writes it out and reads it back. That is the right shape for Inventory, for Health, and for a shop that remembers its stock, because each of those is one owner with a real payload.

It is the wrong shape for a boolean. A hundred chests under that arrangement means a hundred participants, a hundred keys to keep unique, a hundred version guards, and a hundred opportunities to meet the same small set of hazards — hazards that the chest recipe spends most of its page warning about, one chest at a time. Its own remarks name the way out:

A streamed world wants this flag owned by something scene-independent rather than by the chest itself.

This recipe is that owner. One participant, registered once, holding a map of string → string that anything can read at any time — whether or not the object it describes is loaded, spawned, or has ever existed.

What one participant actually changes, and what it does not

Worth being exact, because the honest answer is "two of these, and the rest move rather than vanish". Anyone who tells you a shared store removes every persistence hazard is selling something.

Hazard, writing one participant per object Under one store
The object streams in after the load and is never handed its section — nothing reports it Gone. The store is always registered, so nothing is ever missed or carried; a late object reads a live fact in OnEnable
Capture returning null while untouched is one-way — the object can never be restored to its default Gone. One section, always written, even when the map is empty
The version guard has to run before the first mutation, or a refusal lies about what was applied Gone. Written once here, and the map is replaced in a single assignment
Two objects share a key by accident Moved, and it gets quieter. See below
A restore raises nothing, so views show pre-load state Moved. Still your job, from one subscription instead of many
New. The map only grows; a fact about an object that no longer exists is indistinguishable from a live one
New. Renaming a fact id orphans every save that carried the old one, and nothing reports it

The last two rows are the price, and they are here because a table that only listed what this arrangement absorbs would be an advertisement. Per-object participants get both for free, because the coordinator works in keys and can therefore see them: a section whose object no longer exists is reported as Unrecognised -- no participant claimed it -- and so is the abandoned half of a rename. A fact id is not a key, so nothing in the report ever mentions it.

The map only grows, and nothing tells you which facts are dead

A fact is a string keyed by a string. Nothing ties it to the object it describes, which is exactly the property that lets you ask about a chest in an unloaded scene — and exactly why nothing can tell you the chest was deleted three releases ago. Every fact ever written stays in every save from then on.

In practice this is small: a few hundred bytes per thousand facts, and correctness is unaffected. It matters when the ids are minted rather than authored — one fact per spawned enemy is unbounded growth with a straight face. Key facts on things that persist by design, and if you need a sweep, write it as game code that knows which prefixes are still meaningful. The store cannot know.

Renaming a fact id is the sharper edge of the same thing. Change chest.crypt.looted to chest.crypt_01.looted and every existing save still carries the old key: the store restores it without complaint, nothing reads it, and the chest is full again. There is no report surface for it — the section parsed, the load succeeded. Treat a fact id as a shipped constant, and if one has to change, migrate it in Restore before anything reads the map.

Nothing reports a fact-id collision

This is the real cost, and it is the one thing the per-object arrangement does better. Two participants declaring one key is an error the coordinator catches: it reports DuplicateKey, names the type that lost, and turns RevSaveReport.Success false.

Two chests sharing a fact id get none of that. The coordinator sees one participant and one section, exactly as designed; the second chest silently reads the first one's flag, and the only symptom is a chest that was already empty when the player found it.

So the discipline moves to naming. Give each concern a prefix — chest., door., met. — and for objects that sit in a scene let StableId mint the instance half: it serialises an id into the scene file and ships with RevFramework ▸ Validate ▸ Duplicate Stable Ids, which is the only collision check anything here gets. An object spawned at runtime gets a different id every launch unless AssignId hands it a durable one, and a fact keyed on one of those is a fact that never matches again.

The store has to outlive the scenes it describes

The whole point is that it remembers objects that are not loaded. Put it on the same object as your RevSaveManager, in the scene that is never unloaded.

A store that lives in a streamed sub-scene is a store that forgets — and it fails in the most expensive way available, by looking completely correct until the player walks far enough away and comes back.

A new game does not clear itself

A per-object participant gets this free: new scene, new objects, default state. This store survives scene loads on purpose, so returning to the menu and starting again leaves the last run's facts sitting there — and the first save of the new game writes them into it.

Call Clear() when a run begins. The related case — loading a save that has no fact section at all, because it predates the store — is handled for you; see below.

The section is always written, even when the map is empty

Capture returns {"facts":[]} for an empty map rather than null, and the reason is worth keeping, because it is the trap most likely to be "optimised" back in by someone tidying up later.

A null payload is recorded as a skipped section and no section is written. The coordinator never calls Restore for a participant with no section in the file — reasonably, since a save older than a system is the ordinary case rather than a fault. So a save taken before the first fact was ever set would have nothing to restore from, and loading it could never take those facts back off again. The map would only ever grow.

Note what this does not mean. Individual facts are still free to be absent: SetFlag(id, false) removes the fact rather than storing "false", so the file stays a record of what actually happened. The rule is about the section, not the facts inside it. An empty map written as an empty map is what makes a load able to say "none of this had happened yet".

Replace, never merge

A load is a rewind. Facts the save does not mention had not happened at the moment it was written, so Restore builds a replacement map and swaps it in — it does not merge into what is already there.

Merging is the same bug as a chest that stays looted after loading a save from before it was opened, and it is worth naming because merging is what a dictionary makes easy and a rewind is what a save means.

The swap is also what makes the version guard cheap. Everything that can fail — the version check, the parse, every entry — runs against the replacement while the live map is untouched, so a refusal here is truthfully "nothing was applied", the section stays carryable into the next save, and there is no partial restore to report. That discipline is subtle enough that three of the framework's own participants got it wrong independently; doing it once, here, is most of what this arrangement buys.

The payload carries a stamp, and the reason is a trap worth the paragraph

Capture writes {"kind":"gamefacts","facts":[…]}, and Restore refuses anything without that kind. The obvious question is why a format needs identifying when the section key already does.

Because a key is a string your game chooses, and two participants can choose the same one. If the other participant writes first, this store is handed its payload — and the check you would naturally write to catch that does not work:

// Reads as watertight. Cannot fire.
if (parsed?.facts == null) throw new InvalidOperationException("no fact list");

JsonUtility never returns a null List<T>. A field the JSON does not mention comes back as an empty list, for the same reason an unset [SerializeField] list is never null in the inspector. So {} — and any other well-formed JSON object — parses cleanly into "this save had no facts", the map is replaced with nothing, and every fact in the game is gone. The next save writes the loss out.

This recipe shipped with exactly that guard, and the test written to prove the refusal is what found it. The general rule is worth more than the fix: anything that deserialises into a shape it did not write needs one field a foreign document cannot supply. A collection being present is not that field, and neither is a number that happens to default to something plausible.

This applies to your own participants, not just to this one

Every IRevSaveParticipant you write reads a payload addressed only by a key. If yours would treat a foreign document as an empty version of itself, it has the same hole — and the more harmless the empty case looks, the more damage it does quietly.

Two signals, because a load is not a player

Changed fires when one fact moves while the game is running. Reloaded fires after a load, when every fact you were holding may be different.

They are deliberately not one event. A listener needs to tell "the player just opened this chest" apart from "a save file was read", because the first is a moment to play a sound and the second is a moment to go quiet and re-read everything.

Restore raises nothing at all

Reloaded is raised from RevSaveManager.LoadCompleted, not from inside Restore — matching every participant in the framework, which restore state by writing it directly so that reading a save does not fire the events that state would normally fire. Health restores without raising Died, for the obvious reason.

Subscribing to the store's Reloaded rather than to LoadCompleted yourself also fixes the ordering: the store reconciles itself first, then tells you. That matters for the case it exists to handle — a save with no fact section at all, written before this store was added. The coordinator says nothing about a participant it did not call, so the store checks the report for its own key and, finding none, empties the map. Absent section means no facts, which is the same rule an empty section already follows.

When to stop using this

The moment a fact has structure.

A shop's stock lines, a corpse's position and contents, a quest's branch state — those want a participant of their own, with their own payload and their own version. They are owners with real state, which is exactly what IRevSaveParticipant is shaped for, and the shop recipe is the worked example.

Packing JSON into a fact value works right up until you need to migrate it, and then it does not: Version here covers the entire map at once, so there is no way to say "this one fact changed shape in build 12". A store that has grown a nested format inside one of its values has outgrown itself, and the fix is a second participant rather than a cleverer encoding.

The honest boundary is: facts that are small, flat and about things that may not be loaded. Chest emptied, door unlocked, region discovered, NPC met, tutorial seen, boss killed, run counter. Anything you would struggle to write on an index card belongs somewhere else.

Drop it in

GameFacts.cs
using System;
using System.Collections.Generic;
using System.Globalization;

using RevGaming.RevFramework.Core.Save;

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.OneStoreManyFacts
{
    /// <summary>
    /// One save participant holding many small durable facts — "this chest is empty", "this door is
    /// unlocked", "the player has met the smith" — keyed by strings your game chooses.
    /// </summary>
    /// <remarks>
    /// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
    /// <b>Core</b> only. Public API only.</para>
    ///
    /// <para><b>The composition.</b> The framework's route into the save file is
    /// <see cref="IRevSaveParticipant"/>, and it is deliberately per-owner: something that holds state
    /// writes it out and reads it back. That fits Inventory, Health and a shop. It fits a boolean
    /// badly — a hundred chests means a hundred participants, a hundred keys to keep unique, and a
    /// hundred chances to meet the same five hazards. This class is the other arrangement: <b>one
    /// participant, many facts</b>, registered once and never unregistered while the game is running.</para>
    ///
    /// <para><b>What that buys, precisely.</b> Two things, and it is worth being exact because the
    /// rest is unchanged. A fact does not need its owner to be loaded, so a chest in a sub-scene that
    /// streams in after the load is no longer missed by the restore. And the whole map is replaced in
    /// one assignment, so the version-guard-before-mutation discipline is written once here rather
    /// than in every participant you would otherwise write.</para>
    ///
    /// <para><b>What it does not buy.</b> Fact ids are yours to keep unique, and unlike participant
    /// keys <i>nothing reports a collision</i> — the coordinator sees one participant and one section.
    /// Two chests sharing an id silently share a flag. Read <b>naming facts</b> below before the
    /// second chest exists, not after.</para>
    ///
    /// <para><b>Lifetime.</b> The point of this class is that it outlives the objects it describes, so
    /// it has to outlive their scenes: put it on the same object as your
    /// <see cref="RevSaveManager"/>, in the scene that is never unloaded. A store in a streamed
    /// sub-scene is a store that forgets, which is the problem this recipe exists to avoid.</para>
    ///
    /// <para><b>Naming facts.</b> Ids are compared with ordinal case sensitivity, so
    /// <c>chest.crypt.01</c> and <c>Chest.Crypt.01</c> are two different facts. Give yourself a prefix
    /// per concern (<c>chest.</c>, <c>door.</c>, <c>met.</c>) and, for objects that sit in a scene, let
    /// <see cref="Core.Identity.StableId"/> mint the instance half: it serialises an id into the scene
    /// file and ships with <c>RevFramework ▸ Validate ▸ Duplicate Stable Ids</c>, which is the only
    /// collision check anything here gets. An object spawned at runtime gets a fresh id every launch
    /// unless <c>AssignId</c> hands it a durable one — so a fact keyed on a spawned object's
    /// <see cref="Core.Identity.StableId"/> is a fact that never matches again.</para>
    ///
    /// <para><b>This store's restore is silent, which not every participant is.</b> Health and
    /// Attributes write state directly and raise nothing; Currency, Inventory, Status Effects and
    /// Crafting restore through their own APIs and do raise. This one follows the quiet half by
    /// choice, and the choice is the interesting part:
    /// <see cref="Restore"/> raises nothing at all; <see cref="Reloaded"/> is raised afterwards, from
    /// <c>RevSaveManager.LoadCompleted</c>, which is where presentation is reconciled. The two signals
    /// are separate on purpose — <see cref="Changed"/> means one fact moved while the game was
    /// running, <see cref="Reloaded"/> means every fact you were holding is now potentially wrong.
    /// Collapsing them into one event sounds tidier and costs a listener the ability to tell a player
    /// action apart from a load.</para>
    ///
    /// <para><b>When to stop using this.</b> The moment a fact has structure. A shop's stock lines, a
    /// corpse's position and contents, a quest's branch state — those want a participant of their own,
    /// with their own payload and their own version. Packing a JSON blob into one fact value works
    /// exactly until you need to migrate it, at which point the single
    /// <see cref="Version"/> here covers the whole map and cannot help you.</para>
    /// </remarks>
    [DisallowMultipleComponent]
    [AddComponentMenu("RevFramework/Cookbook/Game Facts")]
    public sealed class GameFacts : MonoBehaviour, IRevSaveParticipant
    {
        [Tooltip("Save manager to join. Leave empty to find one in the scene on enable.")]
        [SerializeField] private RevSaveManager saveManager;

        [Tooltip("Section key for the whole map. Use your own prefix -- revframework.* belongs to the framework.")]
        [SerializeField] private string sectionKey = "mygame.facts";

        // Ordinal, not the default. The default comparer is culture-sensitive, and a fact id that
        // matches in English and misses in Turkish is a bug nobody finds until a player reports it.
        private readonly Dictionary<string, string> _facts =
            new Dictionary<string, string>(StringComparer.Ordinal);

        // Reused by Capture so a save does not allocate a list per call. Never handed to anything that
        // can raise an event while it is in use.
        private readonly List<string> _sortBuffer = new List<string>();

        // What SetFlag writes. Clearing a flag removes the fact rather than writing the opposite, so
        // this is the only flag value the store ever stores.
        private const string FlagSet = "1";

        // Stamped into every payload and checked on the way back in. It is the only thing that can
        // tell this store's section apart from any other well-formed JSON object -- see Restore for
        // why the obvious check cannot. Not a version: Version above is still what guards a format
        // change, and having two numbers for that would only make it unclear which one to bump.
        private const string PayloadKind = "gamefacts";

        /// <summary>Raised when one fact changes while the game is running, with its id.</summary>
        /// <remarks>
        /// <b>Never raised by a load.</b> A restore replaces every fact at once and says nothing; use
        /// <see cref="Reloaded"/> for that. Raised only when a value actually changes, so setting a
        /// fact to what it already held is silent.
        /// </remarks>
        public event Action<string> Changed;

        /// <summary>Raised after a load has finished, when the whole map may have changed.</summary>
        /// <remarks>
        /// <para>Raised from <c>RevSaveManager.LoadCompleted</c> rather than from
        /// <see cref="Restore"/>, for the reason every participant here restores silently: a load must
        /// not fire the events that ordinary play fires, or reading a save spawns the effects of the
        /// state it is reading. By the time this runs, the restore has finished and the map is
        /// correct — including the case where the save had no section at all.</para>
        ///
        /// <para>Subscribing here rather than to <c>LoadCompleted</c> directly also fixes the order:
        /// this store reconciles itself first, then tells you.</para>
        /// </remarks>
        public event Action Reloaded;

        /// <inheritdoc />
        public string Key => sectionKey;

        /// <inheritdoc />
        public int Version => 1;

        /// <summary>How many facts are currently held.</summary>
        public int Count => _facts.Count;

        private void OnEnable()
        {
            // FindObjectsInactive.Include, matching every framework resolver and FactGate 460
            // lines below. A coordinator parked on an inactive services object is a normal way
            // to wire a project; excluding it here found nothing and then warned that nothing
            // was there, which is the worst of both.
            if (!saveManager)
                saveManager = FindAnyObjectByType<RevSaveManager>(FindObjectsInactive.Include);

            if (!saveManager)
            {
                Debug.LogWarning($"[{nameof(GameFacts)}] '{name}' found no {nameof(RevSaveManager)}, so " +
                                 "no fact is saved or loaded. The lookup runs once per enable, so a " +
                                 "manager created after this store is not picked up either.", this);
                return;
            }

            saveManager.Register(this);
            saveManager.LoadCompleted += OnLoadCompleted;
        }

        private void OnDisable()
        {
            if (!saveManager) return;

            saveManager.Unregister(this);
            saveManager.LoadCompleted -= OnLoadCompleted;
        }

        /// <summary>Reads one fact.</summary>
        /// <param name="id">Fact id. Ordinal comparison, so case matters.</param>
        /// <param name="value">The stored string, or <c>null</c> when the fact is not held.</param>
        /// <returns><c>true</c> when the fact is held.</returns>
        public bool TryGet(string id, out string value)
        {
            if (string.IsNullOrWhiteSpace(id))
            {
                value = null;
                return false;
            }

            return _facts.TryGetValue(id, out value);
        }

        /// <summary>Sets one fact, or removes it when the value is null or blank.</summary>
        /// <remarks>
        /// <para>Null-or-blank removes rather than storing an empty string, so "held with no value" is
        /// not a third state anybody has to reason about. A fact is either held with a value or it is
        /// absent, and absent is the default every reader falls back to.</para>
        ///
        /// <para>A blank <paramref name="id"/> is refused with a warning rather than stored. It is
        /// always an authoring mistake, and storing it would put a fact in the save file that no
        /// reader could name.</para>
        /// </remarks>
        public void Set(string id, string value)
        {
            if (string.IsNullOrWhiteSpace(id))
            {
                Debug.LogWarning($"[{nameof(GameFacts)}] A fact was set with a blank id and ignored. " +
                                 "Facts are addressed by id, so a blank one could never be read back.", this);
                return;
            }

            if (string.IsNullOrWhiteSpace(value))
            {
                Remove(id);
                return;
            }

            if (_facts.TryGetValue(id, out var existing) && string.Equals(existing, value, StringComparison.Ordinal))
                return;

            _facts[id] = value;
            RaiseChanged(id);
        }

        /// <summary>Removes one fact.</summary>
        /// <returns><c>true</c> when the fact was held and has been removed.</returns>
        public bool Remove(string id)
        {
            if (string.IsNullOrWhiteSpace(id) || !_facts.Remove(id)) return false;

            RaiseChanged(id);
            return true;
        }

        /// <summary>Removes every fact.</summary>
        /// <remarks>
        /// <b>Call this when starting a new game.</b> This store outlives scenes by design, so nothing
        /// else clears it: loading the menu and starting again leaves the last run's facts standing,
        /// and the first save of the new game writes them back out. Loading a save that has no fact
        /// section is handled for you — see <see cref="Restore"/>.
        /// </remarks>
        public void Clear()
        {
            if (_facts.Count == 0) return;

            // A local list rather than the shared buffer: raising Changed runs game code, and game code
            // is entitled to save, read or set facts from inside the handler. Iterating a buffer that a
            // subscriber can reach is the kind of re-entrancy that only fails on the machine you cannot
            // reproduce on.
            var cleared = new List<string>(_facts.Keys);
            _facts.Clear();

            for (int i = 0; i < cleared.Count; i++) RaiseChanged(cleared[i]);
        }

        /// <summary>Fills <paramref name="results"/> with every held id, sorted ordinal.</summary>
        /// <remarks>
        /// Sorted so that a debug overlay reads the same way twice, and for the same reason
        /// <see cref="Capture"/> sorts: a stable order is what stops an unchanged map producing a
        /// different section every save.
        /// </remarks>
        public void GetIds(List<string> results)
        {
            if (results == null) return;

            results.Clear();
            results.AddRange(_facts.Keys);
            results.Sort(StringComparer.Ordinal);
        }

        /// <summary>Reads a fact as a flag. Absent means <c>false</c>.</summary>
        /// <remarks>
        /// A value this method did not write is reported as an error rather than read as <c>false</c>.
        /// Defaulting silently is how a fact written as a number and read as a flag becomes a chest
        /// that refills: the read succeeds, the answer is wrong, and nothing anywhere says so.
        /// </remarks>
        public bool GetFlag(string id)
        {
            if (!TryGet(id, out var raw)) return false;
            if (string.Equals(raw, FlagSet, StringComparison.Ordinal)) return true;

            Debug.LogError($"[{nameof(GameFacts)}] Fact '{id}' holds '{raw}', which is not a flag this " +
                           $"store wrote. Reported rather than read as false, because a wrong false " +
                           "here is indistinguishable from the fact never having been set.", this);
            return false;
        }

        /// <summary>Sets a flag, or removes the fact when <paramref name="on"/> is false.</summary>
        /// <remarks>
        /// Clearing a flag removes it rather than storing "false", which keeps the map to facts that
        /// are true and the file to what actually happened. It is safe precisely because the
        /// <i>section</i> is always written even when the map is empty — the trap a per-object
        /// participant falls into is skipping its section, not omitting a default value from one.
        /// </remarks>
        public void SetFlag(string id, bool on)
        {
            if (on) Set(id, FlagSet);
            else Remove(id);
        }

        /// <summary>Reads a fact as a whole number.</summary>
        /// <param name="id">Fact id.</param>
        /// <param name="fallback">Returned when the fact is absent.</param>
        /// <remarks>
        /// Parsed invariant, because <see cref="SetNumber"/> writes invariant. A save file that is
        /// written on one machine's locale and read on another's is the ordinary case, not the
        /// exotic one.
        /// </remarks>
        public long GetNumber(string id, long fallback = 0)
        {
            if (!TryGet(id, out var raw)) return fallback;

            if (long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
                return parsed;

            Debug.LogError($"[{nameof(GameFacts)}] Fact '{id}' holds '{raw}', which is not a whole " +
                           $"number. Returning the fallback of {fallback} and saying so, rather than " +
                           "letting a counter silently restart.", this);
            return fallback;
        }

        /// <summary>Stores a whole number.</summary>
        public void SetNumber(string id, long value)
            => Set(id, value.ToString(CultureInfo.InvariantCulture));

        /// <inheritdoc />
        /// <remarks>
        /// <para><b>Always a section, even with nothing in it.</b> Returning <c>null</c> for an empty
        /// map is the tempting optimisation and it is one-way: the coordinator records a null payload
        /// as a skipped section and never calls <see cref="Restore"/> for a participant that has none,
        /// so a save taken before the first fact was set could never take those facts back off again.
        /// An empty map has to be written <i>as</i> an empty map for the file to be the truth in both
        /// directions.</para>
        ///
        /// <para>Sorted ordinal so an unchanged map produces an identical section every time. A file
        /// that churns on every save is a file that cannot be diffed, and defeats any store that
        /// syncs only what changed.</para>
        /// </remarks>
        public string Capture()
        {
            _sortBuffer.Clear();
            _sortBuffer.AddRange(_facts.Keys);
            _sortBuffer.Sort(StringComparer.Ordinal);

            var payload = new Payload
            {
                kind = PayloadKind,
                facts = new List<Entry>(_sortBuffer.Count),
            };

            for (int i = 0; i < _sortBuffer.Count; i++)
            {
                var id = _sortBuffer[i];
                payload.facts.Add(new Entry { id = id, value = _facts[id] });
            }

            return JsonUtility.ToJson(payload);
        }

        /// <inheritdoc />
        /// <remarks>
        /// <para><b>Everything that can fail happens before anything changes.</b> The version guard,
        /// the parse and every entry are checked against a replacement map while <c>_facts</c> is
        /// still untouched; only then is the live map replaced. So a refusal here is truthfully
        /// "nothing was applied", the section stays carryable, and there is no partial state to report
        /// through <see cref="RevSavePartialRestoreException"/>. Writing a participant this way is the
        /// discipline three of the framework's own participants got wrong independently — doing it once
        /// here is most of what this arrangement is worth.</para>
        ///
        /// <para><b>Replace, never merge.</b> A load is a rewind: facts the save does not mention did
        /// not happen yet. Merging would leave a chest looted after loading a save from before it was
        /// opened, which is the quickload duplication bug wearing a different hat.</para>
        ///
        /// <para><b>A malformed entry refuses the whole section</b> rather than being dropped. This
        /// payload was written by this class, so a bad entry means the file was edited or corrupted,
        /// and silently discarding one fact out of two hundred is the kind of loss nobody notices
        /// until the save is the only copy left.</para>
        ///
        /// <para><b>The stamp exists because the obvious guard cannot work</b>, and it is worth
        /// knowing before you write your own participant. <c>JsonUtility</c> never returns a null
        /// <c>List&lt;T&gt;</c>: a field the JSON does not mention comes back as an empty list, for
        /// the same reason an unset <c>[SerializeField]</c> list is never null in the inspector. So
        /// <c>if (parsed.facts == null) throw</c> — which is what this class shipped with, and which
        /// reads as watertight — is unreachable, and <c>{}</c> parsed cleanly into "no facts" and
        /// emptied the whole map. Anything that deserialises into a shape it did not write needs one
        /// field a foreign document cannot supply; a collection being present is not that field.</para>
        /// </remarks>
        public void Restore(string payload, int version)
        {
            if (version > Version)
                throw new NotSupportedException(
                    $"Fact section '{Key}' was written by a newer build than this one can read.");

            if (string.IsNullOrWhiteSpace(payload))
                throw new InvalidOperationException($"Fact section '{Key}' is empty.");

            Payload parsed;
            try
            {
                parsed = JsonUtility.FromJson<Payload>(payload);
            }
            catch (Exception ex)
            {
                throw new InvalidOperationException(
                    $"Fact section '{Key}' could not be parsed, and was refused rather than read as " +
                    "an empty map — which would have silently reset every fact in the game.", ex);
            }

            // The stamp, and not a check on the list. JsonUtility never hands back a null List<T> --
            // a field the JSON does not mention arrives as an empty one, exactly as an unset
            // [SerializeField] list is never null in the inspector -- so `parsed.facts == null` is
            // unreachable, and without the stamp "{}" restores as an empty map and wipes everything.
            // If that ever stopped being true the loop below would throw before touching live state,
            // which is the safe direction; a null-coalesce there would put the wipe straight back.
            if (parsed == null || !string.Equals(parsed.kind, PayloadKind, StringComparison.Ordinal))
                throw new InvalidOperationException(
                    $"Fact section '{Key}' is JSON this store did not write: it carries no " +
                    $"'{PayloadKind}' stamp. Refused rather than read as an empty map, which would " +
                    "have reset every fact in the game. Usually a second participant using this key.");

            var replacement = new Dictionary<string, string>(parsed.facts.Count, StringComparer.Ordinal);

            for (int i = 0; i < parsed.facts.Count; i++)
            {
                var entry = parsed.facts[i];

                // entry is never null: JsonUtility fills a List<T> with default-constructed
                // elements or leaves the list empty, and never puts a null in it. Kept as the
                // blank-id check it really is -- see the note on this class about the guard
                // this page's own lesson condemns.
                if (string.IsNullOrWhiteSpace(entry.id))
                    throw new InvalidOperationException(
                        $"Fact section '{Key}' contains an entry with no id at position {i}.");

                if (replacement.ContainsKey(entry.id))
                    throw new InvalidOperationException(
                        $"Fact section '{Key}' names '{entry.id}' twice. Refused rather than letting " +
                        "one of the two win by position.");

                if (string.IsNullOrWhiteSpace(entry.value))
                    throw new InvalidOperationException(
                        $"Fact section '{Key}' holds '{entry.id}' with no value. A fact is either held " +
                        "with a value or absent, so this file did not come from this store.");

                replacement.Add(entry.id, entry.value);
            }

            // The first and only mutation. Nothing above it touched live state.
            ReplaceAll(replacement);
        }

        /// <summary>
        /// Reconciles the map after a load, and raises <see cref="Reloaded"/>.
        /// </summary>
        /// <remarks>
        /// <para><b>The case this exists for is the save that has no fact section at all</b> — one
        /// written before this store was added, or by a build without it. The coordinator does not
        /// call <see cref="Restore"/> for a participant whose key is not in the file, and says nothing
        /// about it, because a save older than a system is the ordinary case rather than a fault. For
        /// a per-object participant that is harmless: its object reloaded with the scene and is at its
        /// default already. This store did not reload, so without this the last game's facts would
        /// still be here — and the next save would write them into a file that never had them.</para>
        ///
        /// <para>Absent section means no facts, which is the same rule <see cref="Restore"/> applies to
        /// an empty one. A game where an older save should instead keep the current facts can drop
        /// this handler; it is policy, and it is the only policy in this class.</para>
        ///
        /// <para>Tested on <c>FatalError</c> rather than <c>Success</c> deliberately. A fatal load
        /// applied nothing to anybody, so clearing would throw away facts that are still correct; but
        /// another participant failing its own section says nothing about whether ours was in the
        /// file.</para>
        /// </remarks>
        private void OnLoadCompleted(string slot, RevSaveReport report)
        {
            if (report == null) return;

            if (!string.IsNullOrEmpty(report.FatalError)) return;

            var outcomes = report.Outcomes;
            for (int i = 0; i < outcomes.Count; i++)
            {
                if (!string.Equals(outcomes[i].Key, Key, StringComparison.Ordinal)) continue;

                // Restore ran, so the map is already this save's. Just tell the listeners.
                RaiseReloaded();
                return;
            }

            ReplaceAll(null);
            RaiseReloaded();
        }

        /// <summary>Replaces the whole map without raising <see cref="Changed"/>.</summary>
        /// <remarks>
        /// Silent for the same reason every restore in this framework is silent, and uniformly so:
        /// nothing on the load path raises <see cref="Changed"/>, whether the map arrived from a
        /// section or was emptied because there was none. One rule with no exception in it is one a
        /// listener can rely on.
        /// </remarks>
        private void ReplaceAll(Dictionary<string, string> replacement)
        {
            _facts.Clear();

            if (replacement == null) return;

            foreach (var pair in replacement) _facts.Add(pair.Key, pair.Value);
        }

        private void RaiseChanged(string id)
        {
            var subscribers = Changed;
            if (subscribers == null) return;

            var list = subscribers.GetInvocationList();

            for (int i = 0; i < list.Length; i++)
            {
                try
                {
                    ((Action<string>)list[i]).Invoke(id);
                }
                catch (Exception ex)
                {
                    // Matching how every system in this framework guards its own events: one listener
                    // throwing must not stop the others, and must not reach whoever set the fact.
                    Debug.LogException(ex, this);
                    Debug.LogError($"[{nameof(GameFacts)}] A {nameof(Changed)} subscriber threw; the " +
                                   "others still ran.", this);
                }
            }
        }

        private void RaiseReloaded()
        {
            var subscribers = Reloaded;
            if (subscribers == null) return;

            var list = subscribers.GetInvocationList();

            for (int i = 0; i < list.Length; i++)
            {
                try
                {
                    ((Action)list[i]).Invoke();
                }
                catch (Exception ex)
                {
                    Debug.LogException(ex, this);
                    Debug.LogError($"[{nameof(GameFacts)}] A {nameof(Reloaded)} subscriber threw; the " +
                                   "others still ran.", this);
                }
            }
        }

        // Public mutable fields, and a class rather than a struct: JsonUtility skips readonly fields
        // and cannot serialise a Dictionary at all, which is why the map is written as a list.
        [Serializable]
        private sealed class Entry
        {
            public string id;
            public string value;
        }

        [Serializable]
        private sealed class Payload
        {
            // The only field that can identify this format. Everything else about the payload is
            // shapes JsonUtility will invent for you out of any JSON object it is handed.
            public string kind;
            public List<Entry> facts;
        }
    }

    /// <summary>
    /// Switches a GameObject on or off from one fact — a collected pickup that stays collected, an
    /// unlocked door that stays open, a boss that does not come back.
    /// </summary>
    /// <remarks>
    /// <para><b>This is the payoff, and it is why the store outlives scenes.</b> The object reads its
    /// fact in <c>OnEnable</c>, so a sub-scene that streams in ten minutes after the load is as
    /// correct as one that was there at the start. Under a participant per object the same case is
    /// the known hole: the restore has already happened, the late object never receives its section,
    /// and nothing reports it.</para>
    ///
    /// <para><b>Switching this object off is supported and is the common case.</b> It does mean the
    /// component stops listening — an inactive object gets no events — so a fact cleared later in the
    /// same session will not bring it back until the scene reloads. Point <c>target</c> at a child if
    /// you need it to return while the game is running.</para>
    ///
    /// <para>It subscribes to both signals because they mean different things: <c>Changed</c> for the
    /// moment the player collects the thing, <c>Reloaded</c> for a load that may have rewound it.</para>
    /// </remarks>
    [DisallowMultipleComponent]
    [AddComponentMenu("RevFramework/Cookbook/Fact Gate")]
    public sealed class FactGate : MonoBehaviour
    {
        [Tooltip("Fact store to read. Leave empty to find one in the scene on enable.")]
        [SerializeField] private GameFacts facts;

        [Tooltip("Fact id for THIS object -- not shared with any other. Nothing reports a collision.")]
        [SerializeField] private string factId = "";

        [Tooltip("What to switch. Leave empty for this object.")]
        [SerializeField] private GameObject target;

        [Tooltip("Whether the target is active when the fact IS set. Off for a pickup that disappears " +
                 "once collected; on for a bridge that appears once a lever is pulled.")]
        [SerializeField] private bool activeWhenSet;

        private void OnEnable()
        {
            if (string.IsNullOrWhiteSpace(factId))
            {
                Debug.LogWarning($"[{nameof(FactGate)}] '{name}' has no fact id, so it cannot be told " +
                                 "apart from any other gate. Give each one its own.", this);
                return;
            }

            // Include, matching the framework's own resolvers: a store parked on an inactive object is
            // a supported setup, and the bare overload would not find it there.
            if (!facts) facts = FindAnyObjectByType<GameFacts>(FindObjectsInactive.Include);

            if (!facts)
            {
                Debug.LogWarning($"[{nameof(FactGate)}] '{name}' found no {nameof(GameFacts)}, so it is " +
                                 "left as authored and nothing about it persists.", this);
                return;
            }

            facts.Changed += OnFactChanged;
            facts.Reloaded += Apply;

            Apply();
        }

        private void OnDisable()
        {
            if (!facts) return;

            facts.Changed -= OnFactChanged;
            facts.Reloaded -= Apply;
        }

        /// <summary>Sets this object's fact, which switches it through the same path a load would.</summary>
        /// <remarks>
        /// Call this from whatever your game already uses — an interaction, a trigger, a pickup
        /// effect. The switching is not done here: it is done in <see cref="Apply"/>, off the store's
        /// own event, so the object behaves identically whether the fact was set by the player a
        /// second ago or by a save file a minute ago.
        /// </remarks>
        public void SetFact(bool on)
        {
            if (!facts || string.IsNullOrWhiteSpace(factId)) return;

            facts.SetFlag(factId, on);
        }

        private void OnFactChanged(string id)
        {
            if (string.Equals(id, factId, StringComparison.Ordinal)) Apply();
        }

        private void Apply()
        {
            var switched = target ? target : gameObject;

            switched.SetActive(facts.GetFlag(factId) == activeWhenSet);
        }
    }
}

Wiring it up

  1. Put GameFacts on the same GameObject as your RevSaveManager, in the scene that is never unloaded. Give it a section key with your own prefix.
  2. Register nothing — it joins the manager itself, and stays joined.
  3. For a world object that should stay collected or stay open, add FactGate, give it a unique fact id, and call SetFact(true) from whatever your game already uses for that interaction.
  4. For everything else, call the store directly:
// A door, from your own interaction code.
if (facts.GetFlag("door.crypt.north")) OpenImmediately();

// A counter that survives a reload.
facts.SetNumber("run.deaths", facts.GetNumber("run.deaths") + 1);

// A new run.
facts.Clear();
  1. Reconcile presentation from Reloaded — the same place you would reconcile a health bar from RevSaveManager.LoadCompleted.

What it deliberately does not do

It stores no floats. A float wants a round-trip format decision and a precision promise, and games that need one usually want fixed-point or a documented tolerance instead. Store the string yourself and parse it the way your game means it.

It ships no fact ids of its own. Every id is yours. A store that came with chest.<id>.looted built in would be a store with an opinion about what a chest is, and the framework does not have one.

It does not detect collisions. It cannot: two callers using one id is indistinguishable from one caller using it twice, which is the ordinary case. StableId and a prefix convention are the defence, and they are yours to apply.

It does not clear itself on a new game. Deliberate — the store cannot tell "the player started a new run" from "the player walked into a different scene", and guessing wrong loses a save. Clear() is one line at the point where your game already knows.

It does not grow a query API. No prefix search, no wildcards, no predicates. GetIds gives you every id in a stable order; anything more is a filter you write over that list, in the terms your game uses rather than the terms a string map could guess.

  • A chest that stays looted — the per-object arrangement, and the page that documents the hazards this one moves. Read it first if you have five chests rather than five hundred.
  • A shop that remembers — structured state that should stay a participant of its own, and the worked example of where this recipe stops.
  • A save that survives a crash mid-write — the other half of not losing a run, one layer further down.
  • Save — participants, keys, the envelope, and what carry-over does with sections nobody claimed.