Triggers

triggers

Conditional 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.

In the editor

"Triggers to prompt the AI to do something in specific situations"

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 .text1,000
  • Trigger condition .value100
  • Trigger effect .text1,000
  • Trigger effect .value100
  • Trigger script field0

Schema

json
{
  "triggers": {
    "<key>": {
      "name": "string",
      "conditions": [
        {
          "type": "story",
          "query": "string"
        }
      ],
      "effects": [
        {
          "type": "story",
          "instruction": "string"
        }
      ],
      "script": "string",
      "recurring": "boolean",
      "scope": "party | player"
    }
  }
}

Example

json
{
  "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.

json
{
  "name": "discover_missing_documents",
  "conditions": [ { "type": "read-boolean", "key": "met_clerk", "operator": "equals", "value": true } ],
  "effects":    [ { "type": "quest-init", "operator": "set", "value": "The Missing Documents" } ],
  "recurring": false
}

When a trigger fires: by default a trigger fires once ever and is then consumed; add "recurring": true to let it fire every turn its conditions are met. Each trigger evaluates in exactly one phase, 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 are greaterThanOrEqual and lessThanOrEqual — no "To" suffix. The engine rejects greaterThanOrEqualTo with a hard validation error. The validator enforces this.

typeextra fieldsoperatorsnotes
game-tick-equals, notEquals, greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual-
player-level-same numeric setFires if ANY party member matches.
player-resourceresource (key)same numeric setFires 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, notContainsFires 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-entityentity (entity name)equals, notEqualsvalue is boolean. More commonly used as an effect to reveal entities than as a condition.
quests-completed-contains, notContainsvalue is quest name string
quest-statusquestId (resolves by key first, then unique quest name)equals, notEquals, contains, notContains, regexvalue is the quest's status: hidden, available, accepted, completed, abandoned, rejected, expired
narrative-event-statuseventId (event key)equals, notEquals, contains, notContains, regexvalue is a narrative event's status: active, completed, or stopped (suspended); inactive is the pre-start state
story-text-equals, notEquals, contains, notContains, regexchecks most recent story output
action-text-equals, notEquals, contains, notContains, regexchecks pending player command
storyquery (string)-evaluates session history - see narrator note below
actionquery (string)-evaluates player action - see narrator note below
read-stringkeyequals, notEquals, contains, notContains, regex-
read-numberkeyequals, notEquals, greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual-
read-booleankeyequals, notEqualsvalue must be JSON boolean true/false, not the string "true"/"false"
read-arraykeycontains, notContainsvalue is string/number/boolean element. Rarely used in practice — prefer read-boolean or read-string for flag and state tracking.
npc-relationshipnpc (NPC key)equals, notEquals, greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqualvalue is the NPC's relationship score (-100 to 100), see Relationship Stages
npc-relationship-stagenpc (NPC key)equals, notEquals, contains, notContainsvalue is a stage name from Relationship Stages or the defaults

Effect types

typeextra fieldsoperatorsnotes
storyinstruction (string)-injects a narrative instruction for the Storyteller
quest-init-setvalue = quest name. Makes hidden quest available.
quest-progressquestId (quest name)-satisfies the quest's main objective (completes it that turn if accepted) with a player-visible status line
quest-completequestId (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-revealquestId, objectiveId-reveals a hidden objective on the quest
quest-objective-completequestId, objectiveId-marks one objective completed
quest-next-step-setquestId, text, source-sets the quest's next-step hint; source is objective or narrative-event
quest-next-step-clearquestId-clears the quest's next-step hint
party-next-step-settext, 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-starteventId (event key)-starts a narrative event
music-track-settrackId (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-setvalue = destination name (teleports party)
party-region-setvalue = destination name
party-location-setvalue = 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-setvalue = destination name
player-resourceresource (key), target (optional)add, subtract, multiply, divide, setapplies 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-traitstarget (optional)set, add, removeApplies 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-entityentity (entity name)set, togglevalue (boolean) is required for set; toggle flips the current state and ignores value.
write-stringkeyset-
write-numberkeyadd, subtract, multiply, divide, set-
write-booleankeyset, togglevalue = boolean
write-arraykeyset, add, removeset replaces array; add appends; remove removes element
npc-relationshipnpc (NPC key)add, subtract, multiply, divide, setchanges the NPC's relationship score; the result is clamped to -100..100
win-gameendScope, 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-gameendScope, othersOutcome (both optional)-ends the game as a loss; endScope and othersOutcome behave as for win-game
end-gameendScope, 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-resource effects apply only to the satisfying player(s).
  • player-traits effects apply only to the satisfying player(s).
  • story effects receive context identifying the affected player(s). The engine surfaces this to the storyteller as a parenthetical -- (This applies specifically to: <player name>) -- so write a normal instruction and let the engine attach who it applies to.
json
{
  "name": "poisoned_pizza",
  "scope": "player",
  "recurring": true,
  "conditions": [
    {
      "type": "action",
      "query": "the player eats the poisoned pizza"
    }
  ],
  "effects": [
    {
      "type": "player-resource",
      "resource": "health",
      "operator": "subtract",
      "value": 10
    },
    {
      "type": "story",
      "instruction": "The pizza was poisoned. Describe the character who ate it suddenly feeling ill."
    }
  ]
}

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?PhaseTiming
YesPlanningAfter the player acts, before story generation
NoStateAfter 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:

IntentWhat it represents
attackDirect attack intended to deal damage
mockAttackAttack not meant to harm (sparring, warning shots)
subdueAttacking to capture without damage
preventAttackStopping someone from attacking (stun, distraction)
evadeDodging, cover, stealth to avoid being targeted
defendCreating protection for self or others
healHealing self or allies
buffEmpowering self or allies
interactNPCMeaningful, specifically directed social interaction -- not basic greetings
readDocumentReading a specific named book or document; target = exact item name
teleportInstantaneous relocation (magic, portals)
fastTravelFast travel menu usage
travelLeaving for a distant location -- requires actual movement verbs. Dialogue about travel ("I need to go there") does NOT trigger this.
moveMoving to a different area within the current location -- requires an explicit nearby destination. Generic repositioning within the same area does NOT trigger this.
sleepAttempting to sleep
acceptQuestQuest acceptance -- surfaces as a UI prompt after the turn ends rather than through prose detection
otherEverything 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-text with story/story-text in the same trigger unless you explicitly want a planning-phase trigger gated by recent story context. Mixing is valid but rarely intentional - the result is a planning-phase trigger that also requires story history to match.

Authoring principles:

  • Prefer mechanical over semantic. Use story or action conditions only when no mechanical condition or story-text/action-text regex can express the same rule. Semantic conditions are evaluated by AI every turn they are reached - they are expensive.
  • Keep triggers small. Most triggers should have 1-3 conditions and 1-2 effects. Stay within the per-trigger effect cap (shown in the Size Limits panel) — extras are silently discarded at apply time.
  • Context triggers are evaluated selectively. Only a subset of context triggers (those using story or action conditions) 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 (no story/action conditions) 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: false by default. Use recurring: true only for ongoing systems: auras, counters that increment every turn, repeated blockers, or persistent narrative guidance. If you find yourself setting recurring on a one-time event, reconsider.
  • story effects are deferred. A story effect 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 story effect at tick 0 does not affect the initial scene. The opening story is generated from storyStart text before triggers run, so any story instruction injected at tick 0 arrives too late and is ignored. Use storyStart text for opening context, or gate the story effect on game-tick greaterThan 0.

  • quest-init value must exactly match the quest's outer key.
  • Use read-* + write-* effects to build gate patterns: set a boolean when a gate passes, then check it in subsequent triggers to avoid re-evaluating expensive story conditions every turn.

Limits

The trigger budgets — per-trigger effect and condition counts, the separate semantic and mechanical trigger caps, the per-trigger JSON size, and the character caps on condition/effect text and value — are listed in the Size Limits panel. The engine enforces them at publish time and again at runtime: trigger-script writeback that would violate any of them has all of its trigger mutations discarded for the phase, and effects beyond the per-trigger cap are silently dropped at apply time.

Gotchas

ANY-match conditions:

player-level, player-resource, and player-traits conditions are satisfied when any character in the party matches. A level gate fires when the first character reaches that level, not when all do; a low-HP trigger fires if any single character is below the threshold. Where the resulting effects land is a separate question — see Targeting a specific player.

{questId}_objective naming convention: A common authoring pattern is to name objective-phase triggers {questId}_objective or {questId}_objective_N (e.g. missing_documents_objective, missing_documents_objective_2) so they are easy to find and group. The name is a label only: gate the trigger on the quest's status yourself if it should not fire before the quest is accepted.

triggerWritable type matching: Read a key with the same type it was written with. triggerWritable storage holds any JSON-serializable value, and the four read-* conditions strict-typecheck what they find, falling back when the stored shape does not match: read-string returns "", read-number returns 0, read-boolean returns false, read-array returns []. A mismatched read therefore yields the fallback rather than the stored value.

Omit embeddingId from story conditions you author by hand. The engine computes and assigns it automatically.

Trigger scripts

Triggers support an optional script field containing JavaScript. Scripts run after conditions pass and before effects apply, giving you full programmatic control over what happens when a trigger fires.

json
{
  "name": "my_trigger",
  "conditions": [],
  "script": "log('tick ' + check({ type: 'game-tick' }))",
  "effects": [],
  "recurring": true
}

conditions, effects, and script can be combined freely. A trigger with no conditions fires every turn. A trigger with no effects and no script does nothing visible, but non-recurring triggers are still consumed.

Execution order within a trigger:

  1. All conditions evaluate (mechanical + semantic)
  2. If conditions pass: script runs (if present), then effects apply

Scripts never run during condition evaluation. 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:

callreturns
check({ type: 'party-realm' })"Mythic Kingdom"
check({ type: 'party-region' })"Darkwood"
check({ type: 'party-location' })"Throne Room"
check({ type: 'party-area' })"West Wing"
check({ type: 'game-tick' })42
check({ type: 'player-level' }){ "Hero": 5, "Mage": 8 }
check({ type: 'player-resource', resource: 'health' }){ "Hero": 20, "Mage": 15 }
check({ type: 'player-traits' }){ "Hero": ["Rogue"], "Mage": ["Noble"] }
check({ type: 'known-entity', entity: 'Shadow Brotherhood' })true
check({ type: 'quests-completed' })["Clear the Road"]
check({ type: 'read-string', key: 'faction' })"Rebels" (or "" if missing)
check({ type: 'read-number', key: 'counter' })3 (or 0 if missing)
check({ type: 'read-boolean', key: 'flag' })true (or false if missing)
check({ type: 'read-array', key: 'items' })["sword"] (or [] if missing)
check({ type: 'story-text' })most recent story text (raw)
check({ type: 'action-text' })array of player action inputs (raw)
check({ type: 'story' })most recent story text (raw, no AI evaluation)
check({ type: 'action' })array of player action inputs (raw, no AI evaluation)

Note: story-text and action-text return the raw text directly. story and action also return raw text inside check() -- they do not trigger AI semantic evaluation when called from a trigger script. LLM semantic evaluation of story/action conditions happens only against declared typed conditions in the trigger definition (the engine evaluates those separately), never inside script-side check() calls. Inside a trigger script, all four return raw text regardless of operator. Use /pattern/.test(check({ type: '...' })) for regex matching.

With an operator, returns true or false (same logic as typed conditions - player-level, player-resource, player-traits return true if ANY character matches). The regex operator returns undefined in check() - use /pattern/.test(check({ type: '...' })) instead.

storage - a plain object that persists across turns. Supports strings, numbers, booleans, arrays, and nested objects. Read with storage.myKey, write with storage.myKey = value. Typed triggers can also read and write storage via read-* / write-* conditions and effects.

Warning (storage serialization): storage is JSON round-tripped between turns. Strings, numbers, booleans, null, plain arrays, and plain (arbitrarily nested) objects survive as written. A few types survive the script boundary but are coerced by the round-trip: Date becomes an ISO string, while RegExp, Map, Set, TypedArray, functions, and symbol values become {} or null; NaN and Infinity store as null. Two cases instead reject the entire phase's storage writes and revert to the pre-turn snapshot: a BigInt value, or a circular reference. Reassigning storage itself to a non-object (array, null, a primitive) resets it to {} for the turn, and symbol keys drop silently at the clone boundary. Stick to JSON-shaped data; convert dates and regex sources to strings before writing.

effects - the trigger's typed effects array, pre-populated before the script runs. Scripts can add, modify, or remove effects before they apply. Only effects within the per-trigger cap apply (extras are ignored). Only valid effect shapes are applied - malformed effects are silently dropped.

javascript
effects.push({ type: 'story', instruction: 'Something happens.' })
effects.push({ type: 'player-resource', resource: 'health', operator: 'add', value: 10 })
effects[0] = { type: 'story', instruction: 'Replaced.' }
effects.length = 0  // remove all effects

skip - set skip = true to prevent all effects from applying. Also prevents the trigger from being counted as fired, so non-recurring triggers will fire again next turn. 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.

javascript
triggers['villain_defeated'].conditions[0].query = 'the villain has been defeated'
triggers['Other Trigger'].effects.push({ type: 'story', instruction: '...' })

info - engine version info. info.engineVersion returns the engine version number (e.g. 33). info.semanticVersion returns the semantic version string (e.g. '0.33.0'). Useful for branching on version when the engine changes.

log / console - log('hello') and console.log('hello') both write to /logs. The trigger name is automatically prefixed. console.warn, console.error, and console.info also work (all go to the same log).

Limits (per phase - state and planning each get independent budgets):

  • 500 milliseconds total execution time shared across all scripts in the same phase. 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 no Function constructor.

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:

javascript
const hp = check({ type: 'player-resource', resource: 'health' })
if (!Object.values(hp).some(v => v < 10)) { skip = true }

OR logic across conditions

Typed conditions are AND-only; use a script for OR:

javascript
const hasTrait = check({ type: 'player-traits', operator: 'contains', value: 'Noble' })
const hasQuest = check({ type: 'quests-completed', operator: 'contains', value: 'Earn the Writ' })
if (!hasTrait && !hasQuest) { skip = true }

Dynamic storage counter

javascript
storage.turnCount = (storage.turnCount || 0) + 1

Track visited locations

javascript
if (!storage.visited) { storage.visited = [] }
const loc = check({ type: 'party-location' })
if (!storage.visited.includes(loc)) { storage.visited.push(loc) }

Rewrite a trigger condition dynamically

Update another trigger's semantic query based on current state:

javascript
const villain = storage.currentVillain || 'the dark lord'
triggers['villain_defeated'].conditions[0].query = villain + ' has been defeated'

Replace an effect dynamically

Swap an effect based on turn count:

javascript
const tick = check({ type: 'game-tick' })
effects[0] = { type: 'story', instruction: 'Turn ' + tick + ': the world shifts.' }

Self-delete after firing

Removes the trigger 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:

javascript
delete triggers['Arrive Forest Village']

Cascade cleanup

When a quest-init trigger fires, also delete the intermediate briefing trigger. By the time the quest-init fires, the intermediate has already delivered its narrative beat and set its flag - it will never fire again, so removing it shrinks the evaluation list:

javascript
// intermediate already served its purpose; remove it
if (triggers['Village Crisis Briefing']) {
  delete triggers['Village Crisis Briefing']
}
// self-delete this trigger too
delete triggers['Discover Village Attack']

Suppress a recurring trigger conditionally

Silence a trigger under specific circumstances (e.g. a name-request trigger while the player is operating under an alias):

javascript
if (check({ type: 'read-boolean', key: 'using_alias' })) { skip = true }

Authoring patterns

Worked trigger recipes. For script-heavy custom mechanics (reputation trackers, status-effect timers, day/night cycles, race evolution), see Scripting Patterns.

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:

TriggerConditionsEffects
start_[location]party-location + tick > 0story (scene-setting) + write-boolean flag = true
discover_[quest_slug]read-boolean (flag) + story (AI query)quest-init

Step 1 fires when the player arrives and sets the stage. Step 2 only fires once the AI confirms the player has spoken with the relevant NPC, witnessed the crisis, or otherwise encountered the hook organically in the fiction.

story condition query - write it as a plain English question describing what "has been discovered." Examples:

  • "The player has spoken with the archivist or been told about the missing documents"
  • "The player has observed the creature claiming the cavern approach as territory"
  • "The injured survivor has made contact and shared their account of what happened"

Keep queries specific enough that false positives are unlikely. The story condition matches against session history - vague queries produce false positives.

recurring: false on both triggers. They should each fire once.

For quest chains: Use quests-completed contains "Quest Name" as the condition. Add a tick gate (tick > 1) to avoid same-turn chain firing.

Opening a story on a discovered hook

One way to open: let the player find the story rather than meet it. Quests activate from triggers as the player encounters the hook in the fiction, and the opening scene stays free of the characters they are meant to seek out. Nothing below is required -- a world that opens with a named NPC greeting the player, or with a quest already available, is authored differently on purpose.

  • Activate quests from triggers. startingQuests flips its quests to available the moment the session opens; leaving it [] and firing quest-init from a story condition instead means the quest arrives when the player runs into its hook.
  • Keep sought-after NPCs out of the opening area. A character standing in the locationAreas opening zone is met immediately, every run. Placing them elsewhere makes finding them part of play.
  • Watch what the opening area connects to. An adjacent area one paths hop away is a step from the opening, so moving an NPC next door changes little.
  • A different currentLocation separates more firmly than a different area, if the point is that the player travels to reach them.

Naming convention:

Use snake_case throughout — all lowercase, words separated by underscores. Space-separated names work but produce ugly output in logs and are inconsistent with the rest of the schema.

Trigger key patternPurpose
[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]_completeFires when a quest chain reaches its conclusion; writes a completion flag
[system]_initTick-0 or tick-1 trigger that initializes counters and booleans for an ongoing system
[system]_counterRecurring trigger that increments a number each turn a condition is met
json
{
  "start_the_capital": {
    "name": "start_the_capital",
    "recurring": false,
    "conditions": [
      { "type": "party-location", "operator": "equals", "value": "The Capital" },
      { "type": "game-tick", "operator": "greaterThan", "value": 0 }
    ],
    "effects": [
      {
        "type": "story",
        "instruction": "The player arrives in the capital. Establish the political atmosphere - the council's competing agendas, the guild's visible presence, and an undercurrent of unease about certain facts being kept quiet. Introduce the possibility of encountering the archivist early."
      },
      { "type": "write-boolean", "key": "arrived_the_capital", "operator": "set", "value": true }
    ]
  },
  "discover_missing_documents": {
    "name": "discover_missing_documents",
    "recurring": false,
    "conditions": [
      { "type": "read-boolean", "key": "arrived_the_capital", "operator": "equals", "value": true },
      { "type": "story", "query": "The player has spoken with the archivist or been told about the missing documents" }
    ],
    "effects": [
      { "type": "quest-init", "operator": "set", "value": "The Missing Documents" }
    ]
  }
}

This is the two-step quest discovery pattern. The arrival trigger (start_the_capital) sets the scene and writes a boolean flag - it does not fire quest-init. A separate trigger (discover_missing_documents) watches for the flag and uses a story condition to ask the AI: "has the player actually encountered the quest hook?" Only when both are true does the quest become available. The result: quests surface naturally from conversation and exploration instead of landing in the player's lap the moment they step through a door.

The game-tick > 0 on the arrival trigger prevents it firing at tick 0 when the story starts at that location, giving the opening scene room to breathe. The story effect reads like brief director's notes to the AI - set tone, name the relevant NPC, point toward the hook. Keep these short; they inject into a single turn.

quest-init should almost always be paired with a story effect. The quest-init effect makes the quest mechanically available, but without a story effect on the same trigger, the player will see a quest card appear with no narrative lead-in. Use the story effect to deliver the scene beat that explains why the quest just surfaced.

Persistent nudge variant. If the hook might not naturally come up on the turn the player arrives, use a recurring: true prompt trigger instead of recurring: false. Add a quests-completed notContains "Quest Name" condition as a stop guard so it stops nudging once the quest is discovered.


Counter and threshold pattern

For systems that accumulate over time — reputation, renown, training progress, faction pressure — a three-trigger architecture is the standard pattern:

  1. Init trigger (recurring: false, game-tick equals 1): sets the counter to 0 at session start
  2. Increment trigger (recurring: true, condition = event that should increment): runs write-number add 1 each time the event occurs
  3. Threshold trigger (recurring: false, read-number greaterThanOrEqual N): fires the consequence when the counter reaches the target
json
{
  "renown_init": {
    "name": "renown_init",
    "recurring": false,
    "conditions": [
      { "type": "game-tick", "operator": "equals", "value": 1 }
    ],
    "effects": [
      { "type": "write-number", "key": "renown_score", "operator": "set", "value": 0 }
    ]
  },
  "renown_increase": {
    "name": "renown_increase",
    "recurring": true,
    "conditions": [
      { "type": "story", "query": "The player completed a notable deed or was publicly recognised for an achievement" },
      { "type": "read-number", "key": "renown_score", "operator": "lessThan", "value": 3 }
    ],
    "effects": [
      { "type": "write-number", "key": "renown_score", "operator": "add", "value": 1 }
    ]
  },
  "renown_tier_1": {
    "name": "renown_tier_1",
    "recurring": false,
    "conditions": [
      { "type": "read-number", "key": "renown_score", "operator": "greaterThanOrEqual", "value": 1 },
      { "type": "player-traits", "operator": "notContains", "value": "Known Figure" }
    ],
    "effects": [
      { "type": "player-traits", "operator": "add", "value": "Known Figure" },
      { "type": "story", "instruction": "The player has begun to develop a reputation. NPCs who would plausibly have heard of their deeds now recognise the name." }
    ]
  }
}

The read-number lessThan 3 guard on the increment trigger prevents the counter running beyond its useful range. The player-traits notContains guard on the threshold trigger prevents the trait being added twice if the trigger somehow evaluates more than once. Both guards are standard practice.

Resetting variant. For systems that should fire periodically rather than once, add a fourth trigger that resets the counter after the threshold fires: read-number greaterThanOrEqual Nwrite-number set 0. This turns "fires once when N is reached" into "fires every time N accumulates."


Reactive story response (recurring)

The simplest useful recurring trigger carries no flags, counters, or quests at all: a story condition watches for something the player does in the fiction, and a story effect tells the narrator how to react. Because it is recurring: true and stateless, it fires every time the condition matches, for the whole session - the right shape for a "whenever the player does X, the world reacts with Y" behaviour the narrator tends to forget or handle inconsistently.

This example makes NPCs answer the player's text messages, a behaviour the narrator does not reliably produce on its own:

json
{
  "cell_phone_text_response": {
    "name": "cell_phone_text_response",
    "recurring": true,
    "conditions": [
      {
        "type": "story",
        "query": "The player character sends a text message, SMS, or cell phone message to someone"
      }
    ],
    "effects": [
      {
        "type": "story",
        "instruction": "The recipient of the text message sends a response. The response should be in character for the NPC, reflecting their personality, current mood, and relationship with the sender. The response arrives after a delay appropriate to the character — some reply instantly, others take their time. Include the message content naturally in the narration."
      }
    ]
  }
}

Why it works. The story condition is phrased with synonyms ("text message, SMS, or cell phone message") so semantic matching catches the action however the player writes it. The story effect reads as director's notes - it sets the behaviour (NPC replies in character, after a realistic delay) without scripting the content, leaving the narrator to author the actual reply. No write-boolean flag is used because the trigger is meant to fire repeatedly rather than once.

Adding a guard. If the reaction should happen only once, or should stop after some point, add a guard condition - a read-boolean flag, a quests-completed check, or a counter - exactly as in the two patterns above. Stateless recurring is only correct when the reaction genuinely should recur every time.

Common patterns

PatternHow 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 gaterecurring: false, one location/region condition, one story effect. Fires once on arrival to set the scene.
Action-response blockeraction-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 incrementA 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 escalationread-number greaterThanOrEqual threshold → fires an escalation effect (quest-init, story note, trait change). Chain thresholds at different values for multi-stage escalation.
CounterThree triggers: (1) gate sets counter to 0, (2) recurring: true increment reads gate + story condition, (3) threshold trigger reads the counter and fires.
State machinewrite-string sets state (inactive / active / completed); read-string checks state in subsequent triggers.
Semantic gateA 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 chainquests-completed contains "Quest A" condition → quest-init effect for "Quest B".
Forced movementA 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.