On this page

4. Managing model state

Manual contents | Previous: Execution and lifecycle | Next: Programming Main | Supporting types reference

Use this chapter when adding a model variable, changing a built-in property, or diagnosing a reset or update-order problem. The lifecycle chapter explains when rules execute.

Lifecycle selection: examples in this chapter assume top-level Project.lifecycle: "aligned"; see the aligned contract. Legacy differences are called out explicitly, not used as the teaching default. Store operations themselves are the same in both modes. If you are new to the API, begin with the fire tutorial: built-in color/nextColor are enough for its first synchronous rule.

The shipped Life and Antz migrations illustrate two ownership patterns. Life retains each patch's custom Store and color listener across world resets; setup restores active-cell scheduling without registering another listener. Antz retains patch Stocks but replaces turtles. Its model-specific world-reset wrapper retires each old turtle's food Stock and grid link before replacement; death performs the same cleanup through unlink. Local Main reset does not retire those child resources. The runtime does not automatically infer ownership of arbitrary Stock.create(..., main) registrations; custom models must arrange appropriate cleanup when the owning object is discarded.

State ownership in aligned models

Allocate each Store once in the constructor, initialize/register it once in init() (or wire it in the constructor if an early setter needs it), restore it in the object's local reset(), and rebuild population relationships in runtime-invoked setup(). For example:

class Main extends NLSimMain {
  constructor(Patch) {
    super();
    this.Patch = Patch;
    this.total = new Store(0);
  }
  init() {
    super.init();
    this.synchronize(this.total);
  }
  initCells() { this.initializePatches(this.Patch); }
  reset() {
    super.reset();
    this.total.reinitialize();
  }
  setup() {
    // Both configured populations now exist.
    this.total.set(this.patches.toList().length);
  }
}

Use this class only with the aligned Project option. A world reset is main.resetWorld() (or the browser Reset command); aligned main.reset() is local and does not recreate populations. Patch reset likewise restores only local state. Turtle local reset restores its configured Store list but does not call setup. World reset still recreates turtles; do not retain references to the prior population.

Aligned birth helpers initialize inherited/default built-in state before init(), run the customization callback, then defer setup until insertion. Do not repeat init/setup inside the customization callback. None of these changes automatically reset custom Stores, dispose listeners registered on long-lived objects, or migrate legacy constructor/reset code.

4.1 Three values, three different purposes

A Store separates:

The API implementation is in store.js.

Operation Current Next Reset baseline
new Store(value) Set to value Set to value Set to value
store.get() Read Unchanged Unchanged
store.next = value Unchanged Set to value Unchanged
store.set(value) Set to value immediately Set to value Unchanged
store.initialize(value) Set to value immediately Set to value Replace
store.shift() Set from next Retained Unchanged
store.reinitialize() Restore from initial Restore from initial Retained

shift() does not restore next to a fresh default after commitment: current and next then normally contain the same value.

This standalone example shows the difference:

import {Store} from "@nwk/api/store.js";

const state = new Store(2);
state.next = 3;
console.assert(state.get() === 2);
state.shift();
console.assert(state.get() === 3);
state.reinitialize();
console.assert(state.get() === 2);

state.set(4);
state.reinitialize();
console.assert(state.get() === 2);

state.initialize(5);
state.next = 6;
state.reinitialize();
console.assert(state.get() === 5 && state.next === 5);

Manual shift is appropriate in this isolated demonstration. In a running model, register Stores and let Main commit them.

Factories and mutable objects

new Store(factory) and initialize(factory) call the zero-argument factory and retain it for future reinitialization. A factory can produce fresh state.

Objects and arrays are not automatically cloned. Mutating a stored object in place can modify current, next, and initial state through shared references. Prefer scalar values or replace values deliberately; use a factory when a fresh object is required at reset. Do not use in-place mutation as a substitute for a next-state assignment.

4.2 Built-in properties are Store operations

For patch color and turtle appearance/motion, property names encode different operations:

Example Meaning
patch.color = 1 Initialize current/next color and replace its reset baseline
patch.currentColor = 1 Set current/next color immediately without replacing the baseline
patch.nextColor = 1 Schedule color for commitment
turtle.theta = angle Initialize heading and replace its reset baseline
turtle.currentTheta = angle Change current/next heading immediately
turtle.nextTheta = angle Schedule a heading change

Turtles also have nextLocation, nextSize, nextShape, and nextSpeed. These named properties are preferable to manipulating underscore-prefixed implementation fields in ordinary model code.

Use public updater properties for built-in state as well:

this.colorUpdater = () => this.food.get();
this.thetaUpdater = () => this.mySniffer() + this.wiggle();

These are initialization fragments for a model defining food, mySniffer(), and wiggle(). colorUpdater is available on patches and turtles; turtles also provide thetaUpdater, sizeUpdater, and shapeUpdater. Assign the function, not its evaluated result. For example, use this.colorUpdater = fn, not this._color.setUpdater(fn) or this._color.updater = fn.

The public properties are the model-author interface; underscore-prefixed built-in Stores are implementation details, not required user knowledge. In aligned models, call super.patchStep() or super.turtleStep() to evaluate configured built-in updaters and schedule their results. Patch's base step evaluates colorUpdater. Turtle's base step first schedules trajectory movement, then evaluates thetaUpdater, colorUpdater, sizeUpdater, and shapeUpdater in that order. An unset updater does nothing. Results remain pending until shift. Heading changes therefore affect the subsequent trajectory move, preserving Antz's original ordering.

Do not also evaluate those updaters explicitly: that would run them twice. Evaluation happens per base-step call, not necessarily once per tick; patch visitation can invoke a rule more than once. A subclass that omits its base-step call also omits automatic updater evaluation.

Legacy models retain explicit evaluation and are not changed by this behavior. Custom model-owned Stores, such as food, remain distinct from built-in state.

Do not generalize every property setter to the same behavior. For example, initLocation(x, y) initializes both location Stores, while moveNext(x, y) schedules position. See the implementation of the particular operation.

4.3 Synchronous rules read current and write next

Suppose two interacting values start at left = 1, right = 3, and should swap simultaneously. Read both current values before commitment:

import {Store} from "@nwk/api/store.js";

const left = new Store(1);
const right = new Store(3);
left.next = right.get();
right.next = left.get();
console.assert(left.get() === 1 && right.get() === 3);
left.shift();
right.shift();
console.assert(left.get() === 3 && right.get() === 1);

Using immediate assignments instead would let the second rule observe the first rule's new value. The same issue arises when neighboring patches or interacting turtles share state.

Next-state writes do not by themselves resolve competing writes. If several rules write the same Store's next value, the last assignment wins. If a model needs accumulation or conflict resolution, define it explicitly rather than assuming the scheduler will combine proposals.

4.4 Registration has four separate responsibilities

A Store does not become synchronized merely because it is a field on Main, Patch, or Turtle.

Concern Existing mechanism What it does not imply
Synchronization main.synchronize(...stores) No automatic custom-state reset, patch activation, or output
Patch reporting main.synchAndReport(patch, ...stores) Does not serialize arbitrary custom fields; reported patch data is coordinates/color
Patch activation main.synchAndVisitNext(patch, ...stores) or explicit visitation Does not establish reset ownership
Activation and reporting main.synchReportAndVisitNext(patch, ...stores) Does not create a general custom-state serializer
Reset Owner explicitly restores its custom state Synchronization alone is insufficient

The registration methods install a Store's nextPinger callback. A next-state write marks the Store as touched; Main commits touched Stores during shift. Registration does not itself initialize values or queue an already pending write. Register before scheduling changes.

These registrations replace the same callback slot; they are not additive. Calling plain synchronize() after synchAndReport() on the same Store would remove its report side effect. Avoid re-registering built-in Stores.

Implementation caveat: the combined helpers also assign an InitializePinger member, but Store.initialize() uses initializePinger (lowercase initial letter). Do not rely on the uppercase member as a working initialization listener. Normal initialization writes next and invokes the installed nextPinger; the mismatched member is a separate legacy defect.

Store.create(value, main) constructs a Store, adds it to main.stores, and registers synchronization. It does not make NLSimMain.reset() automatically walk that collection. Reset policy must still be explicit.

4.5 Example: a synchronous patch variable with explicit reset

This Patch subclass cools from ten units to zero and colors itself while warm. It registers one persistent visit per patch, so it does not require active-cell scheduling logic. Use it with a Main that calls initializePatches(CoolingPatch), Project visitMode: "C", a two-entry cell palette, and no cell-start color overrides for this example.

import {NLPatch} from "@nwk/api/nlpatch.js";
import {Store} from "@nwk/api/store.js";

class CoolingPatch extends NLPatch {
    constructor(col, row, main) {
        super(col, row, main);
        this._heat = new Store(10);
    }

    init() {
        this.main.synchronize(this._heat);
        this.color = 1;
    }

    setup() {
        this.main.visitAlways(this);
    }

    get heat() {
        return this._heat.get();
    }

    patchStep() {
        super.patchStep();
        const nextHeat = Math.max(0, this.heat - 1);
        this._heat.next = nextHeat;
        this.nextColor = nextHeat > 0 ? 1 : 0;
    }

    reset() {
        super.reset();
        this._heat.reinitialize();
    }
}

export default CoolingPatch;

Expected behavior: heat is still 10 after the first step rule but becomes 9 after shift. After ten ticks, heat and color are zero. Reset restores heat 10 and color 1 on the same patch object.

The base patch reset only handles color; the custom _heat reset is necessary. Setup repeats the idempotent Set registration after world reset. Color already has built-in reporting registration. _heat is synchronized but is not independently transmitted to the browser. Main can aggregate it and publish it to a configured display if required.

4.6 Example: parameter-driven Main state without reallocating Stores

This Main subclass starts a reserve from an input and consumes one unit per tick. Supply Project input code "RESERVE", display code "AMOUNT", and visitMode: "No" for this nonspatial-rule example. The spatial runtime still needs its usual cell configuration and a supplied Patch class.

import {NLSimMain} from "@nwk/api/nlsimmain.js";
import {Store} from "@nwk/api/store.js";

class ReserveMain extends NLSimMain {
    constructor(Patch) {
        super();
        this.Patch = Patch;
        this._reserve = new Store(0);
    }

    init() {
        super.init();
        this.synchronize(this._reserve);
        this.restoreReserve();
    }

    initCells() {
        this.initializePatches(this.Patch);
    }

    reset() {
        super.reset();
        this.restoreReserve();
    }

    restoreReserve() {
        this._reserve.initialize(this.getParam("RESERVE"));
    }

    coordinate() {
        this._reserve.next = Math.max(0, this._reserve.get() - 1);
    }

    update() {
        this.show("AMOUNT", this._reserve.get());
    }
}

export default ReserveMain;

Allocate once in the constructor, register in one-time init(), and share the parameter-reading operation between init and local reset. This does not replay init, replace the Store, or append listeners.

Changing "RESERVE" alone does not rewrite the reserve. On world reset, Main's local reset() reads the current parameter, restores current/next state, and establishes the new baseline. It does not allocate another Store. The first update with a starting reserve of 3 publishes 2 at time 1.

4.7 Turtle custom state and birth initialization

Allocate custom turtle state once in the constructor after super(id, main). Register it with main.synchronize(). If using the individual turtle reset() operation, either add the Store to the turtle's existing stores array or restore it explicitly; that list does not automatically discover arbitrary fields. Do not assume Main's Store set and Turtle's Store array have interchangeable registration methods.

World reset recreates turtles, so constructor initialization runs on new objects. Births also construct new objects, but built-in values are then assigned from defaults or the parent. Use the birth customization callback for child-specific values rather than depending on initial-population hooks.

The Fng compatibility scenario demonstrates custom water state initialized for the child and split with its parent. Reset correctness also requires discarding references to the old population.

4.8 Listeners, updaters, and scheduling cautions

4.9 State review checklist

The Diffuse/Dla migration provides two additional examples:

For every custom variable, answer:

  1. Who owns it, and does that object survive reset?
  2. Is it a synchronous Store or an intentionally immediate JavaScript field?
  3. Who registers it, and who initializes and resets it?
  4. Does each step read current and write next, or intentionally accumulate next?
  5. Is its owner actually visited by the chosen mode and patch scheduling?
  6. Must it affect color, display output, or patch activation separately?
  7. Can a birth, parameter change, or repeated setup bypass its initialization?
  8. Does reset create stale references or duplicate listeners?

The compatibility tests verify current/next/initial behavior, while shipped-app checks exercise real reset and interaction scenarios. These chapters describe current contracts, not a promise that every legacy helper or every registration path has been repaired.