NarraLeaf

Watching stored values

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