State, Conditions & Navigation
How the player evaluates variables, JSON Logic conditions, graph edges, mutations, and panel variants at runtime, and how graph traversal decides the next panel.
Branching stories in PanelWave are driven by three cooperating pieces: the variable store (state), JSON Logic (conditions on graph edges), and the flow engine (graph traversal). Panel variants reuse the same variables and JSON Logic to select alternative panel content. This page explains the runtime mechanics; the format itself is specified in Graph, Variables, and Variants.
Variables: scopes, defaults, validation
VariableStoreService keeps values in five scopes matching the schema:
| Scope | Keyed by | Lifetime |
|---|---|---|
global | — | Whole work, current run |
chapter | scopeId (chapter ID) | Per chapter |
page | scopeId (page ID) | Per page |
session | — | Current browser session |
persistent | — | Survives reloads (localStorage key pw-variables-persistent) |
When the host loads another work into a running shell (a new manifest / manifestUrl, or reload()), the global, chapter, page and session scopes are cleared and re-initialized from the new manifest; persistent values stay. Variant overrides (see Variants) are cleared too.
setDefinitions(definitions) registers the manifest's VariableDefinitions and initializes defaults — the shell does this automatically at startup from manifest.variables.definitions. After that, every set() is checked:
- Read-only definitions reject writes (with a console warning; the write is dropped).
- Type validation per definition:
boolean;number/integerwith optionalmin/max;stringwith optional regexpattern;enumagainst the allowed list;date/time/datetimeas strings. Invalid writes are dropped.
How values get set
In order at startup, then at runtime:
- Manifest defaults — each definition's
defaultinitializes its declared scope. - Host seeding — the shell's
initialVariablesinput, then the entitlement adapter'sgetContext(), go through the privilegedseed(): types are still validated, butreadOnlyvariables MAY be populated. This is the channel for externally-verified facts (an account system'suser.age, entitlement flags): declare the variablereadOnly+visibility: 'private'and no hotspot mutation or settings edit can ever change it. - In-story mutations — hotspot
setVariables/goTomutations and edge actions, viaset()(read-only and type guards apply). - Reader edits — the settings modal's Variables tab lists variables that are public (the default when
visibilityis omitted) and not read-only; saved edits apply throughset()and re-resolve conditional content immediately. - Host API —
PlayerShellComponent.setVariable(key, value, scope)at any time (same guards).
Mutations
State changes are expressed as mutations (VariableMutation — { op, var, value?, amount? }) applied through applyMutation(mutation, scope, scopeId?):
op | Effect |
|---|---|
set | Assign value |
increment / decrement | Add/subtract amount (default 1); only applies to numbers |
toggle | Flip a boolean |
append | Push value onto an array (creates the array if needed) |
remove | Filter value out of an array |
clear | Reset to the definition's default (or null) |
Graph edges may carry action?: Mutation[] — mutations applied when the edge is traversed. FlowEngineService.getNextPanel() returns them in its typed NavigationResult.action (Mutation[]), and PlayerShellComponent applies them (chapter-scoped where the definition says so) on every Next navigation, when the reader picks the edge in the branch chooser, and when the reader taps the edge's target in canvas view.
The mutations are applied only after the paywall and age gate let the move through: a move onto a locked panel changes no variables (a counter an edge increments does not grow with every blocked attempt), and a move the age gate holds applies them once the reader passes. If you drive navigation yourself through FlowEngineService, apply result.action with VariableStoreService.applyMutations the same way.
The evaluation context
Conditions never see the scoped store directly. VariableStoreService.createContext(chapterId?, pageId?) flattens the scopes into a single object, merged in this order (later wins on key collisions):
globalsessionpersistentchapter[chapterId]page[pageId]
So a chapter- or page-scoped variable shadows a global of the same name while you are inside that chapter/page.
JSON Logic conditions
Edge conditions are JSON Logic expressions evaluated by evaluateJsonLogic(logic, context) in utils/json-logic-utils.ts (backed by json-logic-js):
undefined/nullcondition →true(an unconditioned edge is always traversable).- A literal boolean is returned as-is.
- Evaluation errors are caught, logged, and return
false— conditions fail closed.
{
"from": "panel-12",
"to": "panel-13-secret",
"condition": { "and": [
{ ">=": [{ "var": "trust" }, 3] },
{ "==": [{ "var": "metTheStranger" }, true] }
]},
"priority": 10
}
The utils module also provides validateJsonLogic(), extractVariableNames() (find every { "var": ... } reference), evaluateAll()/evaluateAny(), and addCustomOperator(). Calling registerPanelWaveOperators() registers convenience operators: in, contains, matches (regex), between, length, and isEmpty.
Graph traversal
FlowEngineService implements navigation over a chapter's Graph (entry: string | string[] plus edges):
Selection rules in getNextPanel():
- Collect all edges with
from === currentPanelId. No edges →{ nextPanelId: null }(the panel is an endpoint). - Keep edges whose
conditionevaluates truthy against the context. - Sort survivors by
prioritydescending — a missing priority counts as0, and the numerically highest priority wins. - Return the first edge's
to,transition, andaction.
Backwards navigation (navigatePrevious in the shell) uses getPreviousPanels() — the from ends of edges pointing to the current panel — and takes the first one. There is no persistent history stack in the active state service.
Chapters without edges (format 1.7 allows an empty edges array): from the player release after 1.2.0 the shell navigates through getNextInChapter() / getPreviousInChapter(), which use the graph as above when the chapter has edges and otherwise follow the chapter's reading order — the entry panel, then the remaining panels in chapter.panels key order, with the work's default transition. work_complete then fires only on the last panel of that order. Chapters with edges keep graph semantics. In 1.2.0 next stopped on the entry panel of such a chapter.
Beyond stepping, the engine offers graph analysis used by tooling and the shell: isEntry/isEndpoint, findEndpoints, hasPath(graph, a, b, maxDepth = 100) and findPath (both BFS, ignoring conditions), hasCycles (DFS with a recursion stack), and getReachablePanels.
Entitlement gating
Navigation is also gated by monetization: before entering a panel the shell asks PaywallService whether the manifest's paywall rules let this reader in (or, when the host set one, its entitlementAdapter.hasAccess(panelId)) and raises the paywall overlay or age gate instead of navigating (from the player release after 1.2.0 also for the entry panel on the initial load, a resumed bookmark and chapter jumps), and FlowEngineService exposes checkPanelEntitlement / checkChapterEntitlement / checkWorkEntitlement, which delegate to EntitlementService (with its 5-minute cache). See Paywall & Entitlement.
Variants
Panel variants are the manifest's PanelVariant objects, resolved at render time by the pure variant-utils helpers (exported from the package):
selectVariant(panel, context)— evaluates each variant's JSON Logicwhenagainst the flattened variable context, in manifest order; the first truthy match is selected. Evaluation errors count as false (fail closed).applyVariantOverrides(panel, variant)— replaces the overridden fields wholesale, restricted to the schema'sPanelPartialkeys (a variant can never swap ids or nest further variants). Returns a new object; the base panel is never mutated.resolvePanelVariant(panel, context, forcedVariantId?)— combines both, with an optional forced selection (a variant id forces that variant,nullforces the base panel) used by the manual toggle. When nothing applies, the same panel reference is returned, keeping identity comparisons andOnPushbindings stable.resolvePanels(panels, context)— resolves a whole chapter's panel record (page and canvas views).
The shell derives an effective panel from the raw manifest state with these helpers and re-resolves whenever the variable context changes — hotspot mutations, edge mutations, and host setVariable() calls all take effect immediately. Autoplay durationMs and video-layer logic read the effective panel, so variants can retime a panel or swap its media.
Reader-facing UI: the toolbar's Alt button appears on panels with variants and cycles a session-scoped shadow override (automatic → each variant → base → automatic); a .variant-active chip shows the applied variant's id whenever one is in effect.
Edges, visibleIf and variants all share one condition language — JSON Logic over the flattened variable context. Dot-namespaced variable ids (path.choice) resolve as nested paths in that context.
Putting it together
A typical interactive beat — the reader clicks a hotspot that represents a choice:
- The hotspot handler applies mutations, e.g.
applyMutation({ op: 'set', var: 'doorChoice', value: 'left' }, 'chapter', chapterId). - The host navigates:
createContext(chapterId)flattens state,getNextPanel()finds the edge whose condition{ "==": [{ "var": "doorChoice" }, "left"] }now passes. - The next panel renders; if it defines variants, the shell re-resolves them against the same variables and renders the effective panel.
- State that should outlive the session (achievements, unlocked endings) lives in the
persistentscope and survives reloads.