Discoveries and effects authoring model
Discoveries vs. variables
Discoveries = append-only narrative facts ("you found the key", "you met the barista"). Once granted, never removed. If something needs to be revoked or counted, it's a variable, not a discovery — "right in the name, it's a variable state."
Variables = mutable quantitative or boolean state. Counts, currency, mood, anything that can decrease, multiply, or branch on >= 50.
Decision rule for authors:
- Will this thing ever change once set? → variable
- Is it a permanent fact that should appear in a "fact log" or unlock UI? → discovery
Where they live
Discoveries are granted on choice.grants_discoveries[] (per-choice canonical). The scene-level event.grants_discoveries is preserved in data but exposed in the editor as a read-only summary of all incoming choice grants ("Discoveries Granted on Entry"), with an "Always granted on entry" row for legacy values.
Effects (variable mutations like gold += 50) live in event.effects[] / choice.effects[]. They are never evaluated inside content text — {coffee += 1} will not mutate state, only {coffee} reads. The Variable Editor modal includes an explicit anti-pattern hint.
Choice gating UI
Each choice (regular EventEditor + DialogueEditor's active response panel) has two dropdown pickers below Choice Discoveries / Choice Effects:
- Show only if player has →
choice.show_if.all(must have ALL of these) - Hide if player has →
choice.show_if.none(must NOT have ANY of these)
Both use the DiscoveryPicker component, which pulls from the canonical set of discovery names walked from the entire game via getAllDiscoveryNames(events). Stale chips (referencing a discovery that no longer exists anywhere) render with a warning style + tooltip.
OR-mode (show_if.any) is intentionally NOT surfaced. Keep authoring simple — authors who need OR can hand-edit JSON. Don't add a mode toggle "for completeness."
Why
- Per-choice canonical: when two dialogue branches converge on one scene, each branch needs to grant different discoveries. Scene-level can't express that.
- Effects-not-inline: idempotence (text gets rendered for preview/play/re-render — inline mutations cause phantom increments) and ordering (effects run on entry vs. exit; inline interleaves unpredictably with reads). Twine itself separates them — SugarCube's
<<set>>vs.<<= $gold>>, Harlowe's(set:)vs.$gold. - Append-only discoveries: simple contract, narrative coherence ("if you discover something you don't just forget it"), and removable-flag use cases belong in variables anyway.
- Picker over free-text for gating: gating must reference an EXISTING discovery; free-text invites typos that silently break the gate.
How to apply
- New UI for granting/mutating: per-choice first, scene-level only as derived display.
- Resist requests to support inline mutation in content — surface the anti-pattern, point at the Effects field.
EffectsEditor(variable ▾ + op ▾ + value) is the canonical effect-row composer; reuse wherever effects are authored.DiscoveryPicker(chip list + dropdown of canonical names) is the canonical pattern for referencing discoveries;TagInputis the canonical pattern for introducing them (grants_discoveries).- When adding new discovery-related conditions (e.g. event-level prerequisites), default to "all" and "none" only — don't expose "any" until a real authoring need surfaces.