Triggers
triggerDefinitionsConditional 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 a trigger for anything that must mechanically happen.
"World events that can be triggered by certain conditions"
- Editor location
- Files → Mechanics → Advanced → Triggers
- Editor type
- Edit in Studio + View JSON
- 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
Schema
{
"triggerDefinitions": {
"<key>": {
"name": "string",
"conditions": [
{
"type": "story",
"query": "string"
}
],
"effects": [
{
"type": "story",
"instruction": "string"
}
],
"script": "string",
"recurring": "boolean",
"scope": "party | player",
"phase": "planning | state"
}
}
}Example
{
"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.
{
"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.
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'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; 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 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 (
npcselectors): Annpcfield usually names one NPC, but it also accepts a selector. Conditions takeanySceneNPCoranyPartyNPC; effects takeallSceneNPCs,allPartyNPCsorsatisfyingNPCs. Using one in the other's slot is a validation error, andnpc-portraittakes a named NPC only.satisfyingNPCsacts on the NPCs the trigger's own condition selector matched, so the trigger needs a condition usinganySceneNPCoranyPartyNPC; 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 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 | - |
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 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 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'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 |
npc-relationship-stage | npc (NPC key) | equals, notEquals, contains, notContains | value is a stage name from Relationship Stages 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 key |
npc-realm | npc (NPC key) | equals, notEquals, contains, notContains, regex | value is a realm key |
npc-region | npc (NPC key) | equals, notEquals, contains, notContains, regex | value is a region key |
npc-location | npc (NPC key) | equals, notEquals, contains, notContains, regex | value is a location 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 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 |
location-image | location (location key), area (optional) | set | changes the location'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 key), quantity (optional), target (optional) | - | gives the item type; target is allPlayers or satisfyingPlayers |
item-remove | item (item type 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's hidden flag; set takes a boolean value, toggle takes none |
quest-accept | - | set | value is a quest key; accepts it outright and makes it active |
quest-plan | - | set | value is a quest 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):
iteminplayer-item,item-addanditem-removeis an itemTypes key. Only keys match: an item'snamedoes 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, andplayer-attributeeffects 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."
}
]
}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-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.action-success-levelis the exception: combining it withstoryorstory-textin one trigger is a hard validation error, not merely unusual.
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.
- Semantic checks are budgeted. The number of
story/actionchecks 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: 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 follow the phase. A planning trigger'sstoryeffect 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
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. Mechanical effects are unaffected: a level, resource or attribute change at tick 0 lands before the player's first action. 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. The phase a script runs in follows the trigger's conditions and phase field (see 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-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.
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 (
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. 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.
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 noFunctionconstructor.
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:
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 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:
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:
// 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.
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 keys, and satisfyingPlayers keeps the exchange to the player who made it.
{
"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); 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, 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.
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). A planning trigger with a story or action condition is a hard validation error.