NarraLeaf

Watching stored values

Listening for stored value changes and save restores through the Storable event system, and where subscriptions should be attached.

Available since 0.19.0.

Storable reports every write, so a host that reacts to a stored value can listen instead of polling: unlock an achievement when gold reaches 100, mirror a flag into an editor panel, or keep a HUD in sync.

const storable = game.getLiveGame().getStorable();

// every change, anywhere
storable.onChange(({namespace, key, previous, next}) => {
    console.log(`${namespace}.${key}: ${previous} -> ${next}`);
});

// one namespace
storable.onChange("persistent:player", ({key, next}) => {
    console.log(key, next);
});

// one key
const token = storable.onChange("persistent:player", "gold", ({next}) => {
    if (next === 100) achievements.unlock("rich");
});

token.cancel();

The payload is StorableChange. Its namespace is the key the namespace is registered under, the same string getNamespace takes and the one a save file carries, not the human-readable name. A namespace declared as new Persistent("player", ...) is registered as "persistent:player"; a scene's local store is registered as "local:" plus the scene's name.

The listener runs after the new value is readable, so it can read the rest of the namespace and see a consistent state. assign reports one change per key. reset reports the return to each default, and reports a key written after construction as changing to undefined.

Subscribe on the Storable, not on a Namespace

newGame() and loading a save both rebuild every namespace from scratch: the store is cleared and each registered Persistent constructs a fresh Namespace object. The Storable is created once, with the LiveGame, and is never replaced. A subscription registered on the store survives both; a subscription bound to a namespace object goes quiet after the first load, with no error to explain it.

For the same reason, do not cache the object returned by getNamespace across a newGame() or a load. Call getNamespace again. A held reference points at a namespace that is no longer registered, so reads see stale values and writes report nothing.

Writes that do not change the value

Writing a value equal to the one already there reports nothing.

Equality is structural, not by reference. A stored value is serializable by definition (a primitive, a Date, or a plain object or array of those), and the ordinary authoring idioms rebuild the container even when nothing inside it moved:

namespace.assign({gold: 10});                        // reports once
namespace.assign({gold: 10});                        // reports nothing

namespace.set("bag", v => ({...v, gold: v.gold}));   // new object, same contents: nothing
  • Date values compare by timestamp, not by identity.
  • A value outside the serializable domain (a class instance, or a function, which set warns about but still stores) is only equal to itself, so it always reports a change.

Loading a save

A bulk application fires onRestore once, naming the namespaces involved, and no onChange at all:

storable.onRestore(({namespaces}) => {
    rereadMyDerivedView();
});

This covers deserialize, which fires one event for the whole save, and rewinding a single namespace to a snapshot, which is how a scene's locals are undone and fires one event naming that namespace. Ordinary play still reports per-key changes.

A host needs both signals. onChange is silent during a load, so a listener watching one key does not fire when a loaded save arrives already at the interesting value. Re-check the value on onRestore as well.

const storable = game.getLiveGame().getStorable();

function readGold() {
    return storable.getNamespace("persistent:player").get("gold");
}

// evolving values
storable.onChange("persistent:player", "gold", ({next}) => render(next));

// the reload discontinuity
storable.onRestore(({namespaces}) => {
    if (namespaces.includes("persistent:player")) render(readGold());
});

Raw events

onChange and onRestore are filters over one dispatcher, exposed as storable.events for a host that attaches to it directly:

EventPayload
event:storable.changeStorableChange
event:storable.restoreStorableRestore

Prefer onChange, which filters by namespace and key.

On this page