Triggers
triggersConditional automations that fire effects when their conditions all pass. The primary mechanism for quest activation, state flags, world-mutation, and one-time narrator instructions. Triggers are deterministic where narrator instructions are probabilistic -- use triggers for anything that must mechanically happen.
"Triggers to prompt the AI to do something in specific situations"
- Editor location
- Files → Mechanics → Advanced → Triggers
- Editor type
- Edit in Studio + View JSON
- API endpoint
/sections/triggers.json/sections/triggers.md- Size limits
- Mechanical triggers (count) — 4,000
- Semantic triggers (count) — 500
- Semantic checks per turn (+5 per active quest) — 15
- Per-trigger size (compact JSON) — 10,000
- Per-trigger conditions (count) — 5
- Per-trigger effects (count) — 10
- Trigger condition
.text— 1,000 - Trigger condition
.value— 100 - Trigger effect
.text— 1,000 - Trigger effect
.value— 100 - Trigger
scriptfield — 0
Schema
{
"triggers": {
"<key>": {
"name": "string",
"conditions": [
{
"type": "story",
"query": "string"
}
],
"effects": [
{
"type": "story",
"instruction": "string"
}
],
"script": "string",
"recurring": "boolean",
"scope": "party | player"
}
}
}Example
{
"triggers": {
"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 triggers map. 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.
{
"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, chosen by its conditions: if it has an action or action-text condition it runs in the planning phase (after the player acts, before the story is written), otherwise in the state phase (after the story is written).
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 or a resource value; 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's current status; 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; move the party (party-location and friends); change a resource (party-wide or only the player who tripped the trigger); 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 below for the full capability and limits reference, and 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 (
numeric operator names): The numeric operators aregreaterThanOrEqualandlessThanOrEqual— no "To" suffix. The engine rejectsgreaterThanOrEqualTowith a hard validation error. The validator enforces this.
| type | extra fields | operators | notes |
|---|---|---|---|
game-tick | - | equals, notEquals, greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual | - |
player-level | - | same numeric set | Fires if ANY party member matches. |
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. |
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 | - |
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, available, accepted, completed, abandoned, rejected, expired |
narrative-event-status | eventId (event key) | equals, notEquals, contains, notContains, regex | value is a narrative event'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 |
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 |
npc-relationship-stage | npc (NPC key) | equals, notEquals, contains, notContains | value is a stage name from Relationship Stages or the defaults |
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 on the quest |
quest-objective-complete | questId, objectiveId | - | marks one objective 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 |
music-track-set | trackId (track family key) | - | pins music to a family from Gameplay Music Settings, 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 |
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-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. |
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 |
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 |
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-resourceeffects apply only to the satisfying player(s).player-traitseffects apply only to the satisfying player(s).storyeffects receive context identifying the affected player(s). The engine surfaces this to the storyteller as a parenthetical --(This applies specifically to: <player name>)-- so write a normal instruction and let the engine attach who it applies to.
{
"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."
}
]
}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 or player-traits 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, decided by its conditions:
Has an action or action-text condition? | Phase | Timing |
|---|---|---|
| Yes | Planning | After the player acts, before story generation |
| No | State | After the story is generated |
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.
Each player turn runs two separate AI calls in sequence. Understanding this explains why trigger phase matters.
Planning phase - A lightweight intent classifier runs first, before story generation. It reads the player's input and classifies the action into a structured intent type. Triggers with action or action-text conditions evaluate here, which is why they respond immediately rather than a turn late.
State phase - The story narrator runs second. All other triggers evaluate here, after narration context is available.
Note: The planning-phase classifier maps every player action to one of the following intent types. This is the signal set the engine uses internally:
| Intent | What it represents |
|---|---|
attack | Direct attack intended to deal damage |
mockAttack | Attack not meant to harm (sparring, warning shots) |
subdue | Attacking to capture without damage |
preventAttack | Stopping someone from attacking (stun, distraction) |
evade | Dodging, cover, stealth to avoid being targeted |
defend | Creating protection for self or others |
heal | Healing self or allies |
buff | Empowering self or allies |
interactNPC | Meaningful, specifically directed social interaction -- not basic greetings |
readDocument | Reading a specific named book or document; target = exact item name |
teleport | Instantaneous relocation (magic, portals) |
fastTravel | Fast travel menu usage |
travel | Leaving for a distant location -- requires actual movement verbs. Dialogue about travel ("I need to go there") does NOT trigger this. |
move | Moving to a different area within the current location -- requires an explicit nearby destination. Generic repositioning within the same area does NOT trigger this. |
sleep | Attempting to sleep |
acceptQuest | Quest acceptance -- surfaces as a UI prompt after the turn ends rather than through prose detection |
other | Everything else: talking, gesturing, aiming, waiting, doing nothing |
The travel / move split is strict. The classifier deliberately errs on the side of caution -- only fires movement intents when there is high confidence the player is actually moving, not just discussing it.
acceptQuest surfaces as a UI prompt after the turn ends -- the player clicks to confirm rather than accepting through prose.
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-textwithstory/story-textin 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.
Authoring principles:
- Prefer mechanical over semantic. Use
storyoractionconditions only when no mechanical condition orstory-text/action-textregex 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.
- Context triggers are evaluated selectively. Only a subset of context triggers (those using
storyoractionconditions) are evaluated by the LLM each tick — the engine picks the most relevant ones rather than evaluating every context trigger on every turn. Mechanical triggers (nostory/actionconditions) are evaluated without an LLM call and don't compete for this budget. Keep context triggers specific so they rank highly when relevant. - 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: falseby default. Userecurring: trueonly 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.storyeffects are deferred. Astoryeffect does not rewrite the current turn's narration - it influences the following narration. Do not use it expecting immediate output in the same 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. Set the query string once at authoring time and leave it stable; mutating it from a script during gameplay is unreliable.
Notes:
recurring: false→ fires once and never again.recurring: true→ fires every turn conditions are met, including tick 0.
Common issue: A
storyeffect at tick 0 does not affect the initial scene. The opening story is generated fromstoryStarttext before triggers run, so anystoryinstruction injected at tick 0 arrives too late and is ignored. UsestoryStarttext for opening context, or gate the story effect ongame-tick greaterThan 0.
quest-initvalue 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 expensivestoryconditions 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.
{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
embeddingIdfromstoryconditions 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.
{
"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:
- All conditions evaluate (mechanical + semantic)
- If conditions pass: script runs (if present), then effects apply
Scripts never run during condition evaluation. Triggers that have action or action-text conditions run in the planning phase rather than the state phase -- this is determined by the trigger's typed conditions, 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-textandaction-textreturn the raw text directly.storyandactionalso return raw text insidecheck()-- 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-sidecheck()calls. Inside a trigger script, all four return raw text regardless of operator. Use/pattern/.test(check({ type: '...' }))for regex matching.
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 (
storageserialization):storageis 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:Datebecomes an ISO string, whileRegExp,Map,Set,TypedArray, functions, and symbol values become{}ornull;NaNandInfinitystore asnull. Two cases instead reject the entire phase's storage writes and revert to the pre-turn snapshot: aBigIntvalue, or a circular reference. Reassigningstorageitself 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.
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 effectsskip - 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. 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 before saving (size and count limits apply, but scripts can set trigger shapes the editor would reject) - if validation fails, all trigger changes from scripts in the same phase are discarded.
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. If one script uses all the time, remaining scripts in that phase are skipped (their typed effects still apply). Scripts that exceed the limit are killed mid-execution and their changes discarded.
- 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 noFunctionconstructor.
Error handling: Script errors (syntax, runtime, timeout) are logged and the script is skipped. Typed effects still apply. storage and triggers changes from a failed script are discarded. Errors appear in /logs with type trigger-script-error.
Snippets
Skip effects conditionally
Only apply a heal when someone is actually wounded:
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:
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
storage.turnCount = (storage.turnCount || 0) + 1Track visited locations
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:
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:
const tick = check({ type: 'game-tick' })
effects[0] = { type: 'story', instruction: 'Turn ' + tick + ': the world shifts.' }Self-delete after firing
Removes the trigger from the runtime evaluation list permanently. Boolean flags in conditions already prevent re-firing, but the engine still evaluates conditions each tick even when nothing happens. Self-deletion eliminates that overhead:
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 - it will never fire again, so removing it shrinks the evaluation list:
// 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):
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.
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.
startingQuestsflips its quests toavailablethe moment the session opens; leaving it[]and firingquest-initfrom astorycondition 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
locationAreasopening 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
pathshop away is a step from the opening, so moving an NPC next door changes little. - A different
currentLocationseparates 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 |
{
"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:
- Init trigger (
recurring: false,game-tick equals 1): sets the counter to 0 at session start - Increment trigger (
recurring: true, condition = event that should increment): runswrite-number add 1each time the event occurs - Threshold trigger (
recurring: false,read-number greaterThanOrEqual N): fires the consequence when the counter reaches the target
{
"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:
{
"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.
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); triggers handle movement the engine imposes. Full recipe: Gated Area Lock. |
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 and player-traits effects apply only to the satisfying player(s), and story effects receive context naming who they apply to. See "Targeting a specific player" below.