NarraLeaf

Watching stored values

Available since 0.19.0.

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

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, because that is what reset does to it.

Subscribe on the Storable, not on a Namespace

The events live on Storable rather than on Namespace, and that is not an arbitrary choice.

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 itself is created once, with the LiveGame, and is never replaced. A subscription registered on the store therefore survives both; anything bound to a namespace object would go quiet after the first load, without an 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.

A write that does not move the value reports nothing

Writing a value equal to the one already there is not a change and wakes nothing up. A line that re-asserts a flag it has already set, or a script that runs assign with the values already in place, costs no callbacks.

Equality is structural, not by reference. A stored value is by definition serializable — a primitive, a Date, or a plain object/array of those — so comparing it costs no more than writing it to a save, while 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, a function — set warns but still stores it) is only equal to itself, so it always reports a change.

Loading a save reports itself once, not key by key

A save carries every key of every namespace it knew about. Reporting a load as changes would turn one deserialize() into hundreds of callbacks describing a history the player never lived through — the values did not evolve, they were replaced wholesale.

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

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

This covers deserialize — one event for the whole save — and rewinding a single namespace to a snapshot, which is how a scene's locals are undone: one event naming that namespace. Ordinary play, where values do evolve one write at a time, still reports per-key changes.

A host needs both signals. onChange is deliberately silent during a load, so a listener watching one key will not fire when a loaded save arrives already at the interesting value. Re-check the value on onRestore as well; using only one of the two will miss cases.

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 the rare host that wants to attach to it directly:

EventPayload
event:storable.changeStorableChange
event:storable.restoreStorableRestore

Prefer onChange, which does the namespace and key filtering for you.

On this page