> **How to read this page.** Only what it says Voyage requires, rejects or enforces is a rule, and `POST /validate` is what settles those. Everything else is authoring guidance: one worked way of doing it, not a specification. Do not apply guidance to a world unless the human asks for it. ## Example ```json { "triggerDefinitions": { "discover_ambush_plot": { "name": "discover_ambush_plot", "recurring": false, "conditions": [ { "type": "party-location", "operator": "equals", "value": "The Wayside Inn" }, { "type": "story", "query": "the player overhears the patrons planning an ambush on the road" } ], "effects": [ { "type": "quest-init", "operator": "set", "value": "The Road Ambush" }, { "type": "story", "instruction": "The hushed conversation in the corner makes the danger on the road ahead unmistakable." } ] } } } ``` A complete trigger: a mechanical gate (`party-location`) and a semantic gate (`story`) combined with AND, firing two effects when both pass. It surfaces a quest only once the player has actually encountered the hook in play. ## How triggers work A trigger is a named entry in the `triggerDefinitions` map at the top level of the world. Quest triggers (`questTriggers`) sit inside the party, at `parties[""].questTriggers`, and take the same conditions and effects. Its outer map key must be **byte-identical to its inner `name`** (snake_case by convention) — the engine matches triggers by key, so a mismatch is a validation error; build the map as `{ trigger["name"]: trigger }`. Each one is a small rule with two required parts: a list of **`conditions`** and a list of **`effects`**. Every turn the engine checks each trigger; when **all** of a trigger's conditions pass, **all** of its effects fire. That is the whole model — everything else is detail about what you can put in those two lists. ```json { "name": "discover_missing_documents", "conditions": [ { "type": "read-boolean", "key": "met_clerk", "operator": "equals", "value": true } ], "effects": [ { "type": "quest-init", "operator": "set", "value": "The Missing Documents" } ], "recurring": false } ``` **When a trigger fires:** by default a trigger fires **once ever** and is then consumed; add `"recurring": true` to let it fire every turn its conditions are met. Each trigger evaluates in exactly one phase per turn. Without a `phase` field the first matching rule decides: an `action` or `action-text` condition sends it to the **planning phase** (with the player actions, before the story is written); a `story` or `story-text` condition sends it to the **state phase** (after the story is written); an `action-success-level` condition sends it to planning, after action outcomes and combat damage resolve; anything else runs in state. `phase: "planning"` puts a purely mechanical trigger in planning too; see [Phases and timing](#phases-and-timing). **What a trigger can gate on (conditions):** whether recent story or the player's action matches a natural-language `query` (AI-judged); the party's current `realm` / `region` / `location` / `area`; any character's `level`, a `resource` value, a skill level, or this turn's action success level; whether an `entity` (NPC, faction, location, …) is known to the player; which `traits` the party holds or which quests they have completed; a quest's or [narrative event](/world/narrativeEvents)'s current status; an NPC's relationship, health, type, whereabouts, or whether it is in the scene, the party, or combat; and any value you previously stored. Conditions combine with AND — for OR logic or arithmetic you use a script (below). **Mechanical checks run first.** A trigger's AI-judged conditions (`story` and `action`) are evaluated only after all of its structural conditions — location, level, resource, traits, known entities, quest status, stored flags — have passed. Those AI checks draw on a limited per-turn budget (see the Size Limits panel), so give a trigger that relies on one as many structural conditions as apply; its AI check then runs only when the trigger is genuinely in play. **What a trigger can do (effects):** start, advance, or complete a quest (`quest-init`, `quest-progress`, `quest-complete`); reveal or complete quest objectives and set next-step hints; start a [narrative event](/world/narrativeEvents); move the party (`party-location` and friends); change a `resource`, level, XP, skill, or attribute (party-wide or only the players who tripped the trigger); change an NPC's relationship, health, party membership, portrait, or placement; pin or release a music family; end the game; grant or remove `traits`; mark an entity known or unknown; inject a one-time `story` instruction into narration; and read/write boolean, string, number, and array values to persistent storage. A trigger applies at most a capped number of effects (see the Size Limits panel). Triggers are deterministic: if the conditions pass, the effects happen, every time. (Every quest you write needs at least one trigger that fires its `quest-init`.) **Scripts — for logic the two lists can't express:** a trigger may also carry an optional **`script`** (JavaScript) for things like counters, OR-logic across conditions, derived math, conditionally skipping effects, or reading and rewriting other triggers. The script runs after the conditions pass and before the effects apply, and can read live game state with `check()`, keep data across turns in `storage`, and add to or rewrite the `effects` array. See **[Trigger Scripts](#trigger-scripts)** below for the full capability and limits reference, and **[Scripting Patterns](/appendix/scripting-patterns)** for worked recipes. Reach for a script only when plain conditions and effects genuinely can't do the job. The rest of this page is the detailed reference for each condition type, effect type, and the scripting API. ## Reference The most common use is surfacing quests - a player arrives at a location, conditions pass, and the trigger fires `quest-init` to add the quest to their journal. Triggers also manage world state: writing boolean flags to remember that an event happened, injecting one-time narration, and chaining quests when a previous one completes. Every quest you write needs at least one trigger pointing to it. **Triggers vs. narrator instructions:** Triggers are deterministic - if conditions are met, effects fire without exception. Narrator instructions in `aiInstructions` are probabilistic - the narrator decides whether to act on them based on context, and can miss complex multi-step logic. The AI, by contrast, reads the whole fiction the structured layer can't see - inventory, currency, relationships - so reach those through a `story`/`action` condition or effect, and mirror what must be exact into `storage`. Use triggers for anything that must be mechanically guaranteed (quest activation, state gates, key-locked progression). Use narrator instructions for dynamic or flavorful consequences that don't need to be exact (resource consequences, NPC mood shifts, ambient world reactions). ### Condition types > **📋 Note (`npc` selectors):** An `npc` field usually names one NPC, but it also accepts a selector. Conditions take `anySceneNPC` or `anyPartyNPC`; effects take `allSceneNPCs`, `allPartyNPCs` or `satisfyingNPCs`. Using one in the other's slot is a validation error, and `npc-portrait` takes a named NPC only. `satisfyingNPCs` acts on the NPCs the trigger's own condition selector matched, so the trigger needs a condition using `anySceneNPC` or `anyPartyNPC`; without one it is a validation error. An NPC whose key or name *is* one of these five claims that selector for itself, so no trigger can address the group with it. > **📋 Note (`numeric operator names`):** The numeric operators are `greaterThanOrEqual` and `lessThanOrEqual` — no "To" suffix. The engine rejects `greaterThanOrEqualTo` with a hard validation error. The validator enforces this. | type | extra fields | operators | notes | |---|---|---|---| | `game-tick` | - | equals, notEquals, greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual | - | | `day` | - | same numeric set | value is compared with the party's day counter, which is 1 in a new world | | `time-of-day` | - | equals, notEquals, contains, notContains, regex | value is compared with the party's time of day, which is empty in a new world | | `player-level` | - | same numeric set | Fires if ANY party member matches. | | `skill-value` | `skill` (skill key) | same numeric set | value is the skill level to compare against | | `player-resource` | `resource` (key) | same numeric set | Fires if ANY party member matches. Rarely used as a condition in practice — most worlds manage resource thresholds through `usageInstructions` prose rather than triggers. | | `player-traits` | - | contains, notContains | Fires if ANY party member has the trait. | | `player-item` | `item` ([item type](/world/itemTypes) key) | equals, notEquals, greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual | value is a count of the item type to compare against | | `party-realm` | - | equals, notEquals, contains, notContains, regex | - | | `party-region` | - | equals, notEquals, contains, notContains, regex | - | | `party-location` | - | equals, notEquals, contains, notContains, regex | - | | `party-area` | - | equals, notEquals, contains, notContains, regex | - | | `story-start` | - | equals, notEquals, contains, notContains, regex | value is a [story start](/world/storyStarts) key: the story start the game began from | | `known-entity` | `entity` (entity name) | equals, notEquals | value is boolean. More commonly used as an **effect** to reveal entities than as a condition. | | `quests-completed` | - | contains, notContains | value is quest name string | | `quest-status` | `questId` (resolves by key first, then unique quest name) | equals, notEquals, contains, notContains, regex | value is the quest's status: `hidden`, `planned`, `available`, `accepted`, `completed`, `abandoned`, `rejected`, `expired` | | `narrative-event-status` | `eventId` (event key) | equals, notEquals, contains, notContains, regex | value is a [narrative event](/world/narrativeEvents)'s status: `active`, `completed`, or `stopped` (suspended); `inactive` is the pre-start state | | `story-text` | - | equals, notEquals, contains, notContains, regex | checks most recent story output | | `action-text` | - | equals, notEquals, contains, notContains, regex | checks pending player command | | `action-success-level` | - | equals, notEquals, greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual | value is one of `impossible`, `critical failure`, `failure`, `mixed results`, `basic success`, `success`, `great success`, `critical success`, in that order -- the comparison operators rank them. Cannot share a trigger with `story` or `story-text` (hard validation error). | | `story` | `query` (string) | - | evaluates session history - see narrator note below | | `action` | `query` (string) | - | evaluates player action - see narrator note below | | `read-string` | `key` | equals, notEquals, contains, notContains, regex | - | | `read-number` | `key` | equals, notEquals, greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual | - | | `read-boolean` | `key` | equals, notEquals | **`value` must be JSON boolean `true`/`false`, not the string `"true"`/`"false"`** | | `read-array` | `key` | contains, notContains | value is string/number/boolean element. Rarely used in practice — prefer `read-boolean` or `read-string` for flag and state tracking. | | `npc-relationship` | `npc` (NPC key) | equals, notEquals, greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual | value is the NPC's relationship score (-100 to 100), see [Relationship Stages](/mechanics/relationshipStages) | | `npc-relationship-stage` | `npc` (NPC key) | equals, notEquals, contains, notContains | value is a stage name from [Relationship Stages](/mechanics/relationshipStages) or the defaults | | `npc-health` | `npc` (NPC key) | equals, notEquals, greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual | value is the NPC's health as a flat number | | `npc-health-percent` | `npc` (NPC key) | equals, notEquals, greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual | value is the NPC's health as a percentage of its maximum, so it reads the same for any health pool | | `npc-in-scene` | `npc` (NPC key) | equals, notEquals | value is a boolean | | `npc-in-party` | `npc` (NPC key) | equals, notEquals | value is a boolean | | `npc-in-combat` | `npc` (NPC key) | equals, notEquals | value is a boolean | | `npc-type` | `npc` (NPC key) | equals, notEquals, contains, notContains, regex | value is an [npcTypes](/world/npcTypes) key | | `npc-realm` | `npc` (NPC key) | equals, notEquals, contains, notContains, regex | value is a [realm](/world/realms) key | | `npc-region` | `npc` (NPC key) | equals, notEquals, contains, notContains, regex | value is a [region](/world/regions) key | | `npc-location` | `npc` (NPC key) | equals, notEquals, contains, notContains, regex | value is a [location](/world/locations) key | | `npc-area` | `npc` (NPC key) | equals, notEquals, contains, notContains, regex | value is an area key | ### Effect types | type | extra fields | operators | notes | |---|---|---|---| | `story` | `instruction` (string) | - | injects a narrative instruction for the Storyteller | | `quest-init` | - | set | value = quest name. Makes hidden quest available. | | `quest-progress` | `questId` (quest name) | - | satisfies the quest's main objective (completes it that turn if accepted) with a player-visible status line | | `quest-complete` | `questId` (quest name) | - | same as `quest-progress` but silent — no player-visible status line; use when a narrative event or other effect already communicates it | | `quest-objective-reveal` | `questId`, `objectiveId` | - | reveals a hidden [objective](/world/quests) on the quest | | `quest-objective-complete` | `questId`, `objectiveId` | - | marks one [objective](/world/quests) completed | | `quest-next-step-set` | `questId`, `text`, `source` | - | sets the quest's next-step hint; `source` is `objective` or `narrative-event` | | `quest-next-step-clear` | `questId` | - | clears the quest's next-step hint | | `party-next-step-set` | `text`, `source` | - | sets a party-wide next-step hint; `source` is `objective` or `narrative-event` | | `party-next-step-clear` | - | - | clears the party-wide next-step hint | | `narrative-event-start` | `eventId` (event key) | - | starts a [narrative event](/world/narrativeEvents) | | `music-track-set` | `trackId` (track family key) | - | pins music to a family from [Gameplay Music Settings](/other/gameplayMusicSettings), overriding contextual selection until cleared | | `music-track-clear` | - | - | releases a pinned family and returns music to contextual selection | | `party-realm` | - | set | value = destination name (teleports party) | | `party-region` | - | set | value = destination name | | `party-location` | - | set | value = destination name. **Cascade:** automatically updates the party's region, realm, coordinates, and area to match the destination location. You rarely need to set `party-region` or `party-realm` separately when moving a party to a specific location - `party-location` handles all of it. | | `party-area` | - | set | value = destination name | | `location-image` | `location` (location key), `area` (optional) | set | changes the [location](/world/locations)'s image to `value`; `area` names one of its areas | | `day` | - | add, subtract, set | changes the party's day counter | | `time-of-day` | - | set | sets the party's time of day; value is one of `dawn`, `morning`, `noon`, `afternoon`, `evening`, `night`, `midnight` | | `player-resource` | `resource` (key), `target` (optional) | add, subtract, multiply, divide, set | applies to the whole party by default; set `target` to `satisfyingPlayers` to affect only the player who tripped the trigger, or `allPlayers` for the whole party | | `player-level` | `target` (optional) | add, subtract, set | changes player level and applies the normal level-up rewards for every level gained: each resource's maximum scales to the new level through its `gainPerLevel` and the current value fills to match, while attribute points, ability points and milestone choices arrive unspent for the player to allocate; `value` is a whole number of 0 or more; `target` as for `player-resource` | | `player-experience` | `target` (optional) | add, subtract, set | changes player XP; `value` is a whole number of 0 or more; `target` as for `player-resource` | | `player-skill` | `skill` (skill key), `target` (optional) | add, subtract, set | changes the level of a skill the character already has; `value` is a whole number of 0 or more; `target` as for `player-resource` | | `player-attribute` | `attribute` (one of `attributeSettings.attributeNames`), `target` (optional) | add, subtract, set | changes an attribute score; `value` is a whole number of 0 or more; `target` as for `player-resource` | | `player-traits` | `target` (optional) | set, add, remove | Applies to the whole party by default; set `target` to `satisfyingPlayers` to affect only the player who tripped the trigger, or `allPlayers` for the whole party. `add` appends one trait; `remove` removes one trait; `set` replaces all traits. Adding or removing a trait automatically applies or reverses its attribute, skill, and resource modifiers; if a granted trait carries a skill modifier for a skill the character does not have yet, that skill is created so the bonus always takes effect. | | `item-add` | `item` ([item type](/world/itemTypes) key), `quantity` (optional), `target` (optional) | - | gives the item type; `target` is `allPlayers` or `satisfyingPlayers` | | `item-remove` | `item` ([item type](/world/itemTypes) key), `quantity` (optional), `target` (optional) | - | takes the item type; `target` is `allPlayers` or `satisfyingPlayers` | | `known-entity` | `entity` (entity name) | set, toggle | `value` (boolean) is required for `set`; `toggle` flips the current state and ignores `value`. | | `write-string` | `key` | set | - | | `write-number` | `key` | add, subtract, multiply, divide, set | - | | `write-boolean` | `key` | set, toggle | value = boolean | | `write-array` | `key` | set, add, remove | set replaces array; add appends; remove removes element | | `npc-relationship` | `npc` (NPC key) | add, subtract, multiply, divide, set | changes the NPC's relationship score; the result is clamped to -100..100 | | `npc-health` | `npc` (NPC key) | add, subtract, multiply, divide, set | clamps to the NPC's health range and shows in the combat log; a death it causes resolves later in the same turn | | `npc-in-party` | `npc` (NPC key) | set, toggle | adds or removes a companion; a dead NPC cannot be recruited | | `npc-portrait` | `npc` (NPC key) | set | value is an image URL Voyage hosts, or a key in the NPC's `portraits`. Takes one named NPC, never a selector | | `npc-placement` | `npc` (NPC key), `operation`, and `location` + `area` on `moveTo` | - | `moveHere` brings the NPC to the party, `moveAway` sends them off-stage and keeps their record, `moveTo` places them at a location and area. A downed NPC cannot be moved | | `location-hidden` | `location` (location key) | set, toggle | sets or toggles the [location](/world/locations)'s `hidden` flag; `set` takes a boolean value, `toggle` takes none | | `quest-accept` | - | set | value is a [quest](/world/quests) key; accepts it outright and makes it active | | `quest-plan` | - | set | value is a [quest](/world/quests) key; reserves it without surfacing it to players | | `win-game` | `endScope`, `othersOutcome` (both optional) | - | ends the game as a win. `endScope`: `game` (the default when omitted) ends the whole game, `players` ends only the satisfying players' arcs (matched by the trigger's `scope`) while the game continues for others. `othersOutcome` (`won` / `lost` / `ended`) is the outcome the other, non-satisfying players receive in a game-wide ending | | `lose-game` | `endScope`, `othersOutcome` (both optional) | - | ends the game as a loss; `endScope` and `othersOutcome` behave as for `win-game` | | `end-game` | `endScope`, `othersOutcome` (both optional) | - | ends the game neutrally; `endScope` and `othersOutcome` behave as for `win-game` | > **📋 Note (item clauses):** `item` in `player-item`, `item-add` and `item-remove` is an [itemTypes](/world/itemTypes) key. Only keys match: an item's `name` does not. A key with no item type behind it is a warning, not an error: the world still loads, and the clause points at no item type. ### Targeting a specific player By default a trigger is **party-scoped**: it fires if **any** player satisfies its conditions, then applies its effects to **every** party member. Add a top-level `scope` of `player` to make it player-scoped. It still fires when any player satisfies the conditions, but applies effects, where possible, only to the satisfying player(s): - `player-resource`, `player-traits`, `player-level`, `player-experience`, `player-skill`, and `player-attribute` effects apply only to the satisfying player(s). - `story` effects receive context identifying the affected player(s). The engine surfaces this to the storyteller as a parenthetical -- `(This applies specifically to: )` -- so write a normal instruction and let the engine attach who it applies to. ```json { "name": "poisoned_pizza", "scope": "player", "recurring": true, "conditions": [ { "type": "action", "query": "the player eats the poisoned pizza" } ], "effects": [ { "type": "player-resource", "resource": "health", "operator": "subtract", "value": 10 }, { "type": "story", "instruction": "The pizza was poisoned. Describe the character who ate it suddenly feeling ill." } ] } ``` If the conditions pass but no player can be targeted, the trigger is suppressed: no effects run and a non-recurring trigger is not consumed. With `scope: "player"`, the health loss and the narration land only on the player who ate the pizza. Leave `scope` off (or `party`) for party-wide behaviour. **Per-effect target override.** `scope` is usually enough. When one effect must differ, set `target` on that `player-resource`, `player-traits`, or player progression effect: - `target: "satisfyingPlayers"` applies only to the player(s) who matched -- even on an otherwise party-scoped trigger. - `target: "allPlayers"` applies to the whole party -- even on a player-scoped trigger. Reach for `scope` first for the trigger-wide "who is this about"; use `target` only when a single effect should differ from the rest. ### Phases and timing Every trigger evaluates in exactly one phase. Without a `phase` field, the first matching row decides: | Trigger has | Phase | Timing | |---|---|---| | an `action` or `action-text` condition | Planning | With the player actions, before the story is written | | a `story` or `story-text` condition | State | After the story is written | | an `action-success-level` condition | Planning | After action outcomes and combat damage resolve, before the story is written | | none of these | State | After the story is written | A planning trigger's `story` effects reach this turn's narration; a state trigger's effects shape the next turn. `action` and `action-text` conditions only read real player inputs, never host or DM story directions. Each phase has its own independent 500 ms shared script budget; a turn that uses both phases gets two separate budgets that do not combine. **Pinning the phase explicitly.** A trigger can carry a top-level `phase`. `"planning"` moves a purely mechanical trigger into the planning phase, after action outcomes and combat damage, so its effects land before this turn's story: reacting to combat damage, resource math, or skill-check results. A `planning` trigger with a `story` or `action` condition is a hard validation error (`story-text` and `action-text` count as mechanical). `"state"` is accepted but changes nothing. **Condition evaluation cost:** - Mechanical conditions (geographic, tick, level, resource, read-*) check immediately. - Semantic conditions (`story`, `action`) use AI evaluation - they are expensive. > **⚠️ Warning:** Do not mix `action`/`action-text` with `story`/`story-text` in the same trigger unless you explicitly want a planning-phase trigger gated by recent story context. Mixing is valid but rarely intentional - the result is a planning-phase trigger that also requires story history to match. `action-success-level` is the exception: combining it with `story` or `story-text` in one trigger is a hard validation error, not merely unusual. **Authoring principles:** - **Prefer mechanical over semantic.** Use `story` or `action` conditions only when no mechanical condition or `story-text`/`action-text` regex can express the same rule. Semantic conditions are evaluated by AI every turn they are reached - they are expensive. - **Keep triggers small.** Most triggers should have 1-3 conditions and 1-2 effects. Stay within the per-trigger effect cap (shown in the Size Limits panel) — extras are silently discarded at apply time. - **Semantic checks are budgeted.** The number of `story`/`action` checks per turn is capped (see the Size Limits panel), and they only run once a trigger's mechanical conditions pass. Mechanical triggers need no AI call and don't count against the budget. - **The trigger bank has collection-level limits.** The engine enforces maximum trigger counts at publish time. Very large trigger banks may hit these limits and have mutations discarded. Prefer surgical triggers over broad catch-alls. - **`recurring: false` by default.** Use `recurring: true` only for ongoing systems: auras, counters that increment every turn, repeated blockers, or persistent narrative guidance. If you find yourself setting recurring on a one-time event, reconsider. - **`story` effects follow the phase.** A planning trigger's `story` effect reaches this turn's narration; a state trigger's arrives after the story is written and shapes the next turn. - **Most effects apply within the same tick** - exceptions are listed below. **Mutating semantic query strings:** Semantic conditions (`story`, `action`) are AI-evaluated: the engine compares the query string against session history (`story`) or the pending player action (`action`) and decides if the meaning matches. A script can rewrite a query; the old embedding is cleared and a new one is generated on the next turn, so the rewritten query takes effect from then. **Notes:** - `recurring: false` → fires once and never again. `recurring: true` → fires every turn conditions are met, including tick 0. > **🐛 Common issue:** A `story` effect at tick 0 does not affect the initial scene. The opening story is generated from `storyStart` text before triggers run, so any `story` instruction injected at tick 0 arrives too late and is ignored. Mechanical effects are unaffected: a level, resource or attribute change at tick 0 lands before the player's first action. Use `storyStart` text for opening context, or gate the story effect on `game-tick greaterThan 0`. - `quest-init` value must exactly match the quest's outer key. - Use `read-*` + `write-*` effects to build gate patterns: set a boolean when a gate passes, then check it in subsequent triggers to avoid re-evaluating expensive `story` conditions every turn. ### Limits The trigger budgets — per-trigger effect and condition counts, the separate semantic and mechanical trigger caps, the per-trigger JSON size, and the character caps on condition/effect `text` and `value` — are listed in the Size Limits panel. The engine enforces them at publish time and again at runtime: trigger-script writeback that would violate any of them has all of its trigger mutations discarded for the phase, and effects beyond the per-trigger cap are silently dropped at apply time. ### Gotchas **ANY-match conditions:** `player-level`, `player-resource`, and `player-traits` **conditions** are satisfied when **any** character in the party matches. A level gate fires when the first character reaches that level, not when all do; a low-HP trigger fires if any single character is below the threshold. Where the resulting effects land is a separate question — see [Targeting a specific player](#targeting-a-specific-player). **`{questId}_objective` naming convention:** A common authoring pattern is to name objective-phase triggers `{questId}_objective` or `{questId}_objective_N` (e.g. `missing_documents_objective`, `missing_documents_objective_2`) so they are easy to find and group. The name is a label only: gate the trigger on the quest's status yourself if it should not fire before the quest is accepted. **`triggerWritable` type matching:** Read a key with the same type it was written with. `triggerWritable` storage holds any JSON-serializable value, and the four `read-*` conditions strict-typecheck what they find, falling back when the stored shape does not match: `read-string` returns `""`, `read-number` returns `0`, `read-boolean` returns `false`, `read-array` returns `[]`. A mismatched read therefore yields the fallback rather than the stored value. > Omit `embeddingId` from `story` conditions you author by hand. The engine computes and assigns it automatically. ## Trigger scripts Triggers support an optional `script` field containing JavaScript. Scripts run after conditions pass and before effects apply, giving you full programmatic control over what happens when a trigger fires. ```json { "name": "my_trigger", "conditions": [], "script": "log('tick ' + check({ type: 'game-tick' }))", "effects": [], "recurring": true } ``` `conditions`, `effects`, and `script` can be combined freely. A trigger with no conditions fires every turn. A trigger with no effects and no script does nothing visible, but non-recurring triggers are still consumed. **Execution order within a trigger:** 1. All conditions evaluate (mechanical + semantic) 2. If conditions pass: script runs (if present), then effects apply Scripts never run during condition evaluation. The phase a script runs in follows the trigger's conditions and `phase` field (see [Phases and timing](#phases-and-timing)), not anything the script does. ### What scripts can access `check(condition)` - reads game state using the same condition format as typed triggers. Without an operator, returns the raw value: | call | returns | |---|---| | `check({ type: 'party-realm' })` | `"Mythic Kingdom"` | | `check({ type: 'party-region' })` | `"Darkwood"` | | `check({ type: 'party-location' })` | `"Throne Room"` | | `check({ type: 'party-area' })` | `"West Wing"` | | `check({ type: 'game-tick' })` | `42` | | `check({ type: 'player-level' })` | `{ "Hero": 5, "Mage": 8 }` | | `check({ type: 'player-resource', resource: 'health' })` | `{ "Hero": 20, "Mage": 15 }` | | `check({ type: 'player-traits' })` | `{ "Hero": ["Rogue"], "Mage": ["Noble"] }` | | `check({ type: 'known-entity', entity: 'Shadow Brotherhood' })` | `true` | | `check({ type: 'quests-completed' })` | `["Clear the Road"]` | | `check({ type: 'read-string', key: 'faction' })` | `"Rebels"` (or `""` if missing) | | `check({ type: 'read-number', key: 'counter' })` | `3` (or `0` if missing) | | `check({ type: 'read-boolean', key: 'flag' })` | `true` (or `false` if missing) | | `check({ type: 'read-array', key: 'items' })` | `["sword"]` (or `[]` if missing) | | `check({ type: 'story-text' })` | most recent story text (raw) | | `check({ type: 'action-text' })` | array of player action inputs (raw) | | `check({ type: 'story' })` | most recent story text (raw, no AI evaluation) | | `check({ type: 'action' })` | array of player action inputs (raw, no AI evaluation) | > **📋 Note:** `story-text` and `action-text` return the raw text directly. `story` and `action` also return raw text inside `check()` -- they do **not** trigger AI semantic evaluation when called from a trigger script. LLM semantic evaluation of story/action conditions happens only against declared typed conditions in the trigger definition (the engine evaluates those separately), never inside script-side `check()` calls. Inside a trigger script, all four return raw text regardless of operator. Use `/pattern/.test(check({ type: '...' }))` for regex matching. NPC conditions work in `check()` too: a named NPC returns a bare value (or `undefined` when no NPC matches), while `anySceneNPC` / `anyPartyNPC` return an object keyed by NPC key. With an operator, returns `true` or `false` (same logic as typed conditions - `player-level`, `player-resource`, `player-traits` return `true` if ANY character matches). The `regex` operator returns `undefined` in `check()` - use `/pattern/.test(check({ type: '...' }))` instead. `storage` - a plain object that persists across turns. Supports strings, numbers, booleans, arrays, and nested objects. Read with `storage.myKey`, write with `storage.myKey = value`. Typed triggers can also read and write storage via `read-*` / `write-*` conditions and effects. > **⚠️ Warning (`storage` serialization):** `storage` is JSON round-tripped between turns. Strings, numbers, booleans, `null`, plain arrays, and plain (arbitrarily nested) objects survive as written. A few types survive the script boundary but are coerced by the round-trip: `Date` becomes an ISO string, while `RegExp`, `Map`, `Set`, `TypedArray`, functions, and symbol *values* become `{}` or `null`; `NaN` and `Infinity` store as `null`. Two cases instead reject the **entire phase's** storage writes and revert to the pre-turn snapshot: a `BigInt` value, or a circular reference. Reassigning `storage` itself to a non-object (array, `null`, a primitive) resets it to `{}` for the turn, and symbol *keys* drop silently at the clone boundary. Stick to JSON-shaped data; convert dates and regex sources to strings before writing. `effects` - the trigger's typed effects array, pre-populated before the script runs. Scripts can add, modify, or remove effects before they apply. Only effects within the per-trigger cap apply (extras are ignored). Only valid effect shapes are applied - malformed effects are silently dropped. ```javascript effects.push({ type: 'story', instruction: 'Something happens.' }) effects.push({ type: 'player-resource', resource: 'health', operator: 'add', value: 10 }) effects[0] = { type: 'story', instruction: 'Replaced.' } effects.length = 0 // remove all effects ``` `skip` - set `skip = true` to prevent all effects from applying. Also prevents the trigger from being counted as fired, so non-recurring triggers will fire again next turn. Storage and trigger-set writes still persist. Defaults to `false` each script run. `triggers` - the full triggers object. Scripts can read, modify, add, or delete any trigger, including themselves. Other scripts in the same phase can read your changes. Changes take effect on the next turn. Validated against the trigger limits once every script in the phase has run; if any check fails, all trigger changes from scripts in that phase are discarded. ```javascript triggers['villain_defeated'].conditions[0].query = 'the villain has been defeated' triggers['Other Trigger'].effects.push({ type: 'story', instruction: '...' }) ``` `info` - engine version info. `info.engineVersion` returns the engine version number (e.g. `33`). `info.semanticVersion` returns the semantic version string (e.g. `'0.33.0'`). Useful for branching on version when the engine changes. `log` / `console` - `log('hello')` and `console.log('hello')` both write to `/logs`. The trigger name is automatically prefixed. `console.warn`, `console.error`, and `console.info` also work (all go to the same log). **Limits** (per phase - state and planning each get independent budgets): - 500 milliseconds total execution time shared across all scripts in the same phase. Once it is used up, remaining scripts in that phase are skipped and their typed effects apply unmodified. - 16 MB base memory per isolate, plus headroom proportional to the total trigger-set size. Scripts that exceed it are terminated for the rest of the phase. - Scripts run in a sandboxed isolate: no Node.js built-ins (`require`, `process`, `Buffer`, `setTimeout`) and no `Function` constructor. **Error handling:** Script errors are logged as `trigger-script-error` with the trigger name as prefix. The trigger's typed effects apply unmodified, other triggers keep running within the budget, and `storage` writes made before the error persist. ### Snippets #### Skip effects conditionally Only apply a heal when someone is actually wounded: ```javascript const hp = check({ type: 'player-resource', resource: 'health' }) if (!Object.values(hp).some(v => v < 10)) { skip = true } ``` #### OR logic across conditions Typed conditions are AND-only; use a script for OR: ```javascript const hasTrait = check({ type: 'player-traits', operator: 'contains', value: 'Noble' }) const hasQuest = check({ type: 'quests-completed', operator: 'contains', value: 'Earn the Writ' }) if (!hasTrait && !hasQuest) { skip = true } ``` #### Dynamic storage counter ```javascript storage.turnCount = (storage.turnCount || 0) + 1 ``` #### Track visited locations ```javascript if (!storage.visited) { storage.visited = [] } const loc = check({ type: 'party-location' }) if (!storage.visited.includes(loc)) { storage.visited.push(loc) } ``` #### Rewrite a trigger condition dynamically Update another trigger's semantic query based on current state: ```javascript const villain = storage.currentVillain || 'the dark lord' triggers['villain_defeated'].conditions[0].query = villain + ' has been defeated' ``` #### Replace an effect dynamically Swap an effect based on turn count: ```javascript const tick = check({ type: 'game-tick' }) effects[0] = { type: 'story', instruction: 'Turn ' + tick + ': the world shifts.' } ``` #### Self-delete after firing Removes the trigger permanently. A fired non-recurring trigger is already skipped, so this matters most for a recurring trigger you want to retire, and it keeps the trigger set under its count limits: ```javascript delete triggers['Arrive Forest Village'] ``` #### Cascade cleanup When a quest-init trigger fires, also delete the intermediate briefing trigger. By the time the quest-init fires, the intermediate has already delivered its narrative beat and set its flag, so removing it only keeps the trigger set small: ```javascript // intermediate already served its purpose; remove it if (triggers['Village Crisis Briefing']) { delete triggers['Village Crisis Briefing'] } // self-delete this trigger too delete triggers['Discover Village Attack'] ``` #### Suppress a recurring trigger conditionally Silence a trigger under specific circumstances (e.g. a name-request trigger while the player is operating under an alias): ```javascript if (check({ type: 'read-boolean', key: 'using_alias' })) { skip = true } ``` ## Authoring patterns Worked trigger recipes. For script-heavy custom mechanics (reputation trackers, status-effect timers, day/night cycles, race evolution), see [Scripting Patterns](/appendix/scripting-patterns). ### Natural quest discovery (two-step pattern) **Rule:** Arrival triggers should set the scene and write a boolean flag. A separate `discover_*` trigger should fire `quest-init` - but only after the player has actually encountered the quest hook through play. **The problem with single-step arrival triggers:** If `quest-init` fires the moment the player arrives at a location, the quest appears in their journal before they have exchanged a single word with the quest-giver. It breaks immersion and makes the world feel scripted. **The two-step solution:** | Trigger | Conditions | Effects | |---|---|---| | `start_[location]` | `party-location` + `tick > 0` | `story` (scene-setting) + `write-boolean` flag = true | | `discover_[quest_slug]` | `read-boolean` (flag) + `story` (AI query) | `quest-init` | Step 1 fires when the player arrives and sets the stage. Step 2 only fires once the AI confirms the player has spoken with the relevant NPC, witnessed the crisis, or otherwise encountered the hook organically in the fiction. **`story` condition query** - write it as a plain English question describing what "has been discovered." Examples: - `"The player has spoken with the archivist or been told about the missing documents"` - `"The player has observed the creature claiming the cavern approach as territory"` - `"The injured survivor has made contact and shared their account of what happened"` Keep queries specific enough that false positives are unlikely. The `story` condition matches against session history - vague queries produce false positives. **`recurring: false`** on both triggers. They should each fire once. **For quest chains:** Use `quests-completed contains "Quest Name"` as the condition. Add a tick gate (`tick > 1`) to avoid same-turn chain firing. **Opening a story on a discovered hook** One way to open: let the player find the story rather than meet it. Quests activate from triggers as the player encounters the hook in the fiction, and the opening scene stays free of the characters they are meant to seek out. Nothing below is required -- a world that opens with a named NPC greeting the player, or with a quest already available, is authored differently on purpose. - **Activate quests from triggers.** `startingQuests` flips its quests to `available` the moment the session opens; leaving it `[]` and firing `quest-init` from a `story` condition instead means the quest arrives when the player runs into its hook. - **Keep sought-after NPCs out of the opening area.** A character standing in the `locationAreas` opening zone is met immediately, every run. Placing them elsewhere makes finding them part of play. - **Watch what the opening area connects to.** An adjacent area one `paths` hop away is a step from the opening, so moving an NPC next door changes little. - **A different `currentLocation` separates more firmly than a different area**, if the point is that the player travels to reach them. **Naming convention:** Use `snake_case` throughout — all lowercase, words separated by underscores. Space-separated names work but produce ugly output in logs and are inconsistent with the rest of the schema. | Trigger key pattern | Purpose | |---|---| | `[location]_init` or `start_[location]` | First arrival at a location (tick > 0); sets scene + boolean flag | | `arrive_[location]_*` | Subsequent arrivals at same location (tick > 3, tick > 5); sets additional flags | | `[quest]_quest_init` or `discover_[quest_slug]` | Story-condition trigger; fires `quest-init` when hook is encountered | | `[quest]_chain_N` or `chain_[quest_slug]` | `quests-completed` chain trigger; numbered suffix for multi-step chains | | `[quest]_complete` | Fires when a quest chain reaches its conclusion; writes a completion flag | | `[system]_init` | Tick-0 or tick-1 trigger that initializes counters and booleans for an ongoing system | | `[system]_counter` | Recurring trigger that increments a number each turn a condition is met | ```json { "start_the_capital": { "name": "start_the_capital", "recurring": false, "conditions": [ { "type": "party-location", "operator": "equals", "value": "The Capital" }, { "type": "game-tick", "operator": "greaterThan", "value": 0 } ], "effects": [ { "type": "story", "instruction": "The player arrives in the capital. Establish the political atmosphere - the council's competing agendas, the guild's visible presence, and an undercurrent of unease about certain facts being kept quiet. Introduce the possibility of encountering the archivist early." }, { "type": "write-boolean", "key": "arrived_the_capital", "operator": "set", "value": true } ] }, "discover_missing_documents": { "name": "discover_missing_documents", "recurring": false, "conditions": [ { "type": "read-boolean", "key": "arrived_the_capital", "operator": "equals", "value": true }, { "type": "story", "query": "The player has spoken with the archivist or been told about the missing documents" } ], "effects": [ { "type": "quest-init", "operator": "set", "value": "The Missing Documents" } ] } } ``` **This is the two-step quest discovery pattern.** The arrival trigger (`start_the_capital`) sets the scene and writes a boolean flag - it does **not** fire `quest-init`. A separate trigger (`discover_missing_documents`) watches for the flag and uses a `story` condition to ask the AI: "has the player actually encountered the quest hook?" Only when both are true does the quest become available. The result: quests surface naturally from conversation and exploration instead of landing in the player's lap the moment they step through a door. The `game-tick > 0` on the arrival trigger prevents it firing at tick 0 when the story starts at that location, giving the opening scene room to breathe. The `story` effect reads like brief director's notes to the AI - set tone, name the relevant NPC, point toward the hook. Keep these short; they inject into a single turn. **`quest-init` should almost always be paired with a `story` effect.** The `quest-init` effect makes the quest mechanically available, but without a `story` effect on the same trigger, the player will see a quest card appear with no narrative lead-in. Use the `story` effect to deliver the scene beat that explains why the quest just surfaced. **Persistent nudge variant.** If the hook might not naturally come up on the turn the player arrives, use a `recurring: true` prompt trigger instead of `recurring: false`. Add a `quests-completed notContains "Quest Name"` condition as a stop guard so it stops nudging once the quest is discovered. --- ### Counter and threshold pattern For systems that accumulate over time — reputation, renown, training progress, faction pressure — a three-trigger architecture is the standard pattern: 1. **Init trigger** (`recurring: false`, `game-tick equals 1`): sets the counter to 0 at session start 2. **Increment trigger** (`recurring: true`, condition = event that should increment): runs `write-number add 1` each time the event occurs 3. **Threshold trigger** (`recurring: false`, `read-number greaterThanOrEqual N`): fires the consequence when the counter reaches the target ```json { "renown_init": { "name": "renown_init", "recurring": false, "conditions": [ { "type": "game-tick", "operator": "equals", "value": 1 } ], "effects": [ { "type": "write-number", "key": "renown_score", "operator": "set", "value": 0 } ] }, "renown_increase": { "name": "renown_increase", "recurring": true, "conditions": [ { "type": "story", "query": "The player completed a notable deed or was publicly recognised for an achievement" }, { "type": "read-number", "key": "renown_score", "operator": "lessThan", "value": 3 } ], "effects": [ { "type": "write-number", "key": "renown_score", "operator": "add", "value": 1 } ] }, "renown_tier_1": { "name": "renown_tier_1", "recurring": false, "conditions": [ { "type": "read-number", "key": "renown_score", "operator": "greaterThanOrEqual", "value": 1 }, { "type": "player-traits", "operator": "notContains", "value": "Known Figure" } ], "effects": [ { "type": "player-traits", "operator": "add", "value": "Known Figure" }, { "type": "story", "instruction": "The player has begun to develop a reputation. NPCs who would plausibly have heard of their deeds now recognise the name." } ] } } ``` The `read-number lessThan 3` guard on the increment trigger prevents the counter running beyond its useful range. The `player-traits notContains` guard on the threshold trigger prevents the trait being added twice if the trigger somehow evaluates more than once. Both guards are standard practice. **Resetting variant.** For systems that should fire periodically rather than once, add a fourth trigger that resets the counter after the threshold fires: `read-number greaterThanOrEqual N` → `write-number set 0`. This turns "fires once when N is reached" into "fires every time N accumulates." --- ### Reactive story response (recurring) The simplest useful recurring trigger carries no flags, counters, or quests at all: a `story` condition watches for something the player does in the fiction, and a `story` effect tells the narrator how to react. Because it is `recurring: true` and stateless, it fires every time the condition matches, for the whole session - the right shape for a "whenever the player does X, the world reacts with Y" behaviour the narrator tends to forget or handle inconsistently. This example makes NPCs answer the player's text messages, a behaviour the narrator does not reliably produce on its own: ```json { "cell_phone_text_response": { "name": "cell_phone_text_response", "recurring": true, "conditions": [ { "type": "story", "query": "The player character sends a text message, SMS, or cell phone message to someone" } ], "effects": [ { "type": "story", "instruction": "The recipient of the text message sends a response. The response should be in character for the NPC, reflecting their personality, current mood, and relationship with the sender. The response arrives after a delay appropriate to the character — some reply instantly, others take their time. Include the message content naturally in the narration." } ] } } ``` **Why it works.** The `story` condition is phrased with synonyms ("text message, SMS, or cell phone message") so semantic matching catches the action however the player writes it. The `story` effect reads as director's notes - it sets the behaviour (NPC replies in character, after a realistic delay) without scripting the content, leaving the narrator to author the actual reply. No `write-boolean` flag is used because the trigger is meant to fire repeatedly rather than once. **Adding a guard.** If the reaction should happen only once, or should stop after some point, add a guard condition - a `read-boolean` flag, a `quests-completed` check, or a counter - exactly as in the two patterns above. Stateless recurring is only correct when the reaction genuinely should recur every time. ### Shop purchase A purchase is one recurring trigger: an `action` condition for the request, a `player-item` condition checking the price is held, an `item-remove` for the price and an `item-add` for the goods. Both items are [itemTypes](/world/itemTypes) keys, and `satisfyingPlayers` keeps the exchange to the player who made it. ```json { "buy_healing_potion": { "name": "buy_healing_potion", "recurring": true, "conditions": [ { "type": "action", "query": "The player buys a healing potion from the merchant." }, { "type": "player-item", "item": "gold_coin", "operator": "greaterThanOrEqual", "value": 10 } ], "effects": [ { "type": "item-remove", "item": "gold_coin", "quantity": 10, "target": "satisfyingPlayers" }, { "type": "item-add", "item": "healing_potion", "quantity": 1, "target": "satisfyingPlayers" }, { "type": "story", "instruction": "The merchant takes the coins and hands over a healing potion." } ] } } ``` ### Common patterns | Pattern | How to wire it | |---|---| | **Session initialization (tick 0)** | `game-tick equals 0`, `recurring: false` → fires once at game start. Sets initial storage values and boolean flags. **No `story` effect here** - a tick-0 story instruction does not reach the initial scene (use `storyStart` text or a tick 1+ trigger). For a wider early-game window use `game-tick lessThanOrEqual N`. | | **Natural quest discovery (two-step)** | Step 1: arrival trigger sets scene + `write-boolean` flag. Step 2: a separate trigger checks `read-boolean` (flag) + `story` (AI judges whether the player met the quest-giver or witnessed the hook) → fires `quest-init`. Quests feel earned rather than handed out. Use when you need state persistence between events. | | **Simple gate** | `recurring: false`, one location/region condition, one `story` effect. Fires once on arrival to set the scene. | | **Action-response blocker** | `action-text` regex matches a forbidden or tutorial action → `story` effect redirects or blocks. `recurring: true` to persist, `recurring: false` for a one-time tutorial. Evaluates in the planning phase, so the response is immediate. | | **Gate plus counter increment** | A gate trigger sets a `write-boolean` true; a second `recurring: true` trigger reads that boolean + any other condition → `write-number add 1`; a third reads the counter at a threshold → fires the main effect and optionally resets the counter. | | **Threshold or escalation** | `read-number greaterThanOrEqual` threshold → fires an escalation effect (quest-init, story note, trait change). Chain thresholds at different values for multi-stage escalation. | | **Counter** | Three triggers: (1) gate sets counter to 0, (2) `recurring: true` increment reads gate + `story` condition, (3) threshold trigger reads the counter and fires. | | **State machine** | `write-string` sets state (`inactive` / `active` / `completed`); `read-string` checks state in subsequent triggers. | | **Semantic gate** | A cheap `story-text` regex gate sets `write-boolean` → true, then add `read-boolean` as the first condition on the expensive `story` AI condition so it is not re-evaluated every turn. | | **Quest chain** | `quests-completed contains "Quest A"` condition → `quest-init` effect for "Quest B". | | **Forced movement** | A condition gates an action the player should not complete; a `party-location` effect relocates the party with no input. E.g. stranded with no boat, an `action`-conditioned trigger matching any attempt to leave → `party-location` back to shore. Native travel handles player-chosen movement ([Realm travel](/world/realms#realm-travel)); triggers handle movement the engine imposes. Full recipe: [Gated Area Lock](/appendix/scripting-patterns#gated-area-lock-worked-example). | ## Fields ### name The trigger's display name; must be byte-identical to the outer map key. ### conditions Conditions that must ALL pass for the trigger to fire. ### effects Effects applied when all conditions pass; the engine caps how many apply per firing and drops the excess (see the Size Limits panel). ### script JavaScript run in a sandboxed VM after the trigger's conditions pass and before its effects apply; it can mutate storage, rewrite effects, mutate other triggers, or set `skip = true` to drop the firing. Scripts share a 500 ms budget per phase; once exhausted, remaining scripts are skipped and effects apply unmodified. Prefer declarative conditions and effects when they suffice. ### recurring When true the trigger can fire every turn; when false or omitted it fires once. ### scope Omitted, the trigger is party-scoped: it fires when any player satisfies its conditions and applies effects to every party member. Set `"player"` to make it player-scoped: it still fires on any satisfying player, but `player-resource`, `player-traits`, and player progression effects apply only to the satisfying player(s), and `story` effects receive context naming who they apply to. See [Targeting a specific player](#targeting-a-specific-player). ### phase Optional. `"planning"` runs a purely mechanical trigger in the planning phase, after action outcomes and combat damage, so its effects land before this turn's story. `"state"` is accepted but changes nothing; omitted, the phase follows from the conditions (see [Phases and timing](#phases-and-timing)). A `planning` trigger with a `story` or `action` condition is a hard validation error.