Skip to content

State

The state is the single source of truth of your game: a plain, serializable object you define, wrapped by the engine in a reactive proxy. This page covers what that buys you and the one rule you must respect.

A plain object, typed by you

ts
interface State {
  coins: number;
  generators: number;
  upgrades: string[];
}

const engine = createEngine<State>({
  initialState: { coins: 0, generators: 0, upgrades: [] },
});

Because it is plain data:

  • saving is trivialSaveManager serializes it with JSON.stringify;
  • loading is trivialengine.load assigns the saved object back;
  • testing is trivial — build a state literal, run systems on it, assert.

Keep it JSON-safe: no functions, class instances, Map/Set, or undefined values you care about. Mechanics follow the same rule for the data they store in your state (jobs, active boosts, collection entries are all plain JSON).

Reactivity: dirty keys, not virtual DOM

engine.state is a proxy around your object. Writing a key marks it dirty — but only if the value actually changed (Object.is comparison):

ts
engine.state.coins += 1; // marks "coins" dirty
engine.state.coins += 0; // same value -> nothing marked

Once per frame, the engine flushes the dirty set to its subscribers. The DOM renderer uses it to re-run only the bindings whose actually read keys changed. There is no diffing and no virtual DOM — just a set of changed key names.

The one rule: top-level keys only

Only top-level key writes are tracked. Mutating inside a nested object or array changes the data but marks nothing dirty:

ts
// ❌ invisible to reactivity: "upgrades" is never marked dirty
engine.state.upgrades.push("auto-clicker");

// ✅ reassign: the top-level write marks "upgrades" dirty
engine.state.upgrades = [...engine.state.upgrades, "auto-clicker"];

This is a deliberate trade-off: shallow tracking is fast, predictable and imposes no wrappers on your data. The mechanics respect the same convention — their set* accessors reassign (state.jobs = [...]) so reactivity keeps working.

Intentional in-place mutation

High-frequency counters that no binding needs to observe per-change (a crafting job's elapsed, a boost's remaining, a producer's cycle progress) are mutated in place on purpose by the mechanics: they change every tick and re-rendering on each write would be wasteful. UIs read them through per-frame bindings (renderer.addFrame) or helper methods like progressFraction.

Reading state from the UI

Bindings receive no arguments — they close over what they need and read engine.state (or the store) inside the getter, so dependency tracking sees the reads:

ts
renderer.add(bindText(coinsEl, () => Math.floor(engine.state.coins).toString()));

Organizing larger states

Group mechanic data under dedicated keys, and hand the mechanics accessors to their slice:

ts
interface State {
  resources: ResourceBag;
  crafting: { jobs: CraftingJob[] };
  boosts: { active: Record<string, ActiveBoost> };
  completedProjects: string[];
}

Reassigning a slice (state.crafting = { ...state.crafting, jobs }) marks the crafting key dirty, so bindings that read state.crafting re-run. The mechanics' set* options do exactly this — you just point them at the right key.

Common pitfalls

  • push/splice/property writes on nested data — nothing re-renders. Reassign the top-level key.
  • Storing non-JSON values — they will silently vanish or corrupt the save. Keep runtime-only objects (registries, managers) outside the state; mechanics already do.
  • Reading state outside a binding and caching it in the DOM — you lose reactivity. Read inside the binding getter.