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: nothingDatevalues compare by timestamp, not by identity.- A value outside the serializable domain (a class instance, a function —
setwarns 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:
| Event | Payload |
|---|---|
event:storable.change | StorableChange |
event:storable.restore | StorableRestore |
Prefer onChange, which does the namespace and key filtering for you.