Appearance
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 trivial —
SaveManagerserializes it withJSON.stringify; - loading is trivial —
engine.loadassigns 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 markedOnce 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.
Related
- Rendering with the DOM — how bindings consume dirty keys.
- Saving your game — how the state becomes a save file.
ReactiveStoreAPI.