Scripting Patterns
The recipe book: typed triggers and JavaScript combined into complete, working mechanics. The Triggers page is the reference; these are the end-to-end builds.
Note: This is the recipe book. The Triggers page is the reference — every condition and effect type, the Trigger Scripts API, and the short official snippets. The recipes here combine typed triggers and JavaScript into a complete, working mechanic end to end, and assume that reference vocabulary (
check(),effects.push(),skip,storage).
Mechanics that go beyond the schema's native support. The narrator handles many things implicitly - status effects, faction attitudes, equipment friction - but anything requiring exact numeric thresholds, persistent counters, or guaranteed enforcement needs scripts. The patterns below cover the design choice (what to author with) and full worked recipes (how to wire it up).
Custom Mechanics Patterns
The table below covers the full range: some entries are narrator-interpreted by default (the narrator handles them without any instruction), others require explicit construction via description, effects, or aiInstructions. For narrator-interpreted entries, the DIY path is only needed if you want precise mechanical control beyond the narrator's defaults.
| What you want | Native schema support | DIY path |
|---|---|---|
| Initiative / turn order | No native field - default behaviour is whatever the narrator improvises | generateActionInfo custom for a named initiative system with a visible turn order. Script-driven alternative: persist a shuffled combatant list in storage, advance it with a recurring: true trigger, and push story effects each round. See Script Examples for Common Mechanics. |
| Status effects (blind, stun, poison, fear) | No native tracking - narrator persistence across turns is ad-hoc | Ability or item description/effects text for precise mechanical control: exact damage per turn, exact turn count, stacking rules, specific cure conditions. Script-driven alternative: store a turn counter in storage, decrement it with a recurring: true trigger, and push a story effect each tick until it reaches zero. See Script Examples for Common Mechanics. |
| Equipment use restrictions (class-based) | No native field - explicit rules in aiInstructions.generateActionInfo are required for hard enforcement | aiInstructions.generateActionInfo Equipment Restrictions for hard enforcement (e.g. "Mages cannot wield medium or heavy weapons - refuse the action outright") |
| Faction / reputation tracking | No schema field. Without explicit construction the narrator may improvise faction attitudes from context, but persistence is not guaranteed | Two options: (1) Custom resource + usageInstructions for a visible player-facing bar with narrator-driven gain/loss. (2) Script-driven tracker: write-number effects on event triggers modify storage.* counters; a recurring: true monitor trigger script categorizes values into named bands and pushes story effects via effects.push() when a band changes. Option 2 is invisible to the player but supports precise multi-faction threshold logic and triggered narrative consequences. See Faction Reputation Tracker (Worked Example) below. |
| Short rest resource recovery | restRechargeMultiplier (global fraction) | usageInstructions prose - describe class-specific or conditional partial recovery |
| Conditional resource recovery | None | usageInstructions prose ("cannot recover inside the Scar Zone"; "only recovers if the player meditates"). Script-driven alternative: gate recovery on a read-boolean condition stored in storage (e.g. in_safe_zone) and push a story effect explaining why recovery is blocked or allowed. See Script Examples for Common Mechanics. |
| Custom damage type side effects (poison condition, burn, freeze) | None - damageTypes only registers the type name | Ability/item description text + generateActionInfo describing secondary effects per type. Script-driven alternative: ability script sets a storage flag (e.g. storage.apply_poison = true); a recurring: true monitor trigger reads the flag, initialises a turn counter, and pushes a story effect. Decrement the counter each tick as with status effects. See Script Examples for Common Mechanics. |
| Calendar / time tracking | Tick counter only | Custom resource with rechargeRate: 1 (ticks up each turn) + usageInstructions defining in-world time conversion. Script-driven alternative: read the engine tick with check({ type: 'game-tick' }), track a time_period string in storage, and push a story effect only when the period changes. See Script Examples for Common Mechanics. |
| Multiclassing / hybrid builds | No dedicated field | Multiple trait requirements on abilities; aiInstructions describing interaction rules |
| Passive always-on abilities | cooldown: 0 + description written as a persistent condition ("the bearer permanently...") | Script-driven enforcement: a recurring: true trigger with no condition pushes a story effect every tick reinforcing the passive rule. More reliable than relying on the narrator remembering the ability description across a long session. Stackable: each passive gets its own trigger. See Script Examples for Common Mechanics. |
Faction Reputation Tracker (Worked Example)
A script-driven multi-faction standing system. Uses storage.* directly for numeric scores, a recurring: true monitor trigger to detect band changes, and effects.push() to inject narrator instructions when standing shifts. No custom resource required - nothing is shown to the player.
Architecture:
- Init trigger (
recurring: false, gated ongame-tick > 0) - initializes all faction scores and band labels instoragevia script; sets astanding_init_doneboolean viawrite-booleaneffect so the monitor trigger can gate on it cleanly. The tick gate avoids the tick-0 case wherestoryeffects do not reach the initial scene. - Monitor trigger (
recurring: true, gates onstanding_init_done) - runs every tick; script compares current score to stored band label; if the band changed, updates the label and pushes astoryeffect instructing the narrator how all NPCs of that faction should now behave. Usesskip = truewhen no band changed to suppress the trigger entirely. - Event triggers - standard triggers for quest completions, location arrivals, key NPC interactions etc., each with a
write-number add Neffect on the relevant faction's storage key. No script required on these. - Consequence triggers -
read-number lessThanOrEqual / greaterThanOrEqualthreshold checks that fire one-timestoryeffects for major faction events (assassination orders, alliance offers, trade embargoes).
Init trigger script:
if (storage.standing_kingdom === undefined) {
storage.standing_kingdom = 0;
storage.standing_empire = 0;
storage.standing_guild = 0;
storage.standing_cult = 0;
storage.threshold_kingdom = 'neutral';
storage.threshold_empire = 'neutral';
storage.threshold_guild = 'neutral';
storage.threshold_cult = 'neutral';
}Replace kingdom, empire, guild, cult with your world's faction keys. One standing_* number and one threshold_* string per faction.
Monitor trigger script:
const band = (v) => {
if (v >= 50) return 'allied';
if (v >= 10) return 'cooperative';
if (v >= -9) return 'neutral';
if (v >= -49) return 'hostile';
return 'war';
};
const factions = [
{ standing: 'standing_kingdom', threshold: 'threshold_kingdom', name: 'The Kingdom' },
{ standing: 'standing_empire', threshold: 'threshold_empire', name: 'The Empire' },
{ standing: 'standing_guild', threshold: 'threshold_guild', name: 'The Guild' },
{ standing: 'standing_cult', threshold: 'threshold_cult', name: 'The Cult' }
];
const posture = {
allied: 'has moved to open alliance - active cooperation and goodwill at all levels',
cooperative: 'now maintains a cautiously cooperative stance',
neutral: 'has settled into a wait-and-see position - no active hostility, no commitment',
hostile: 'is now working actively against the player - expect obstruction and quiet aggression',
war: 'has entered total opposition - coordinated strikes and open aggression should be expected'
};
let fired = false;
for (const f of factions) {
const current = storage[f.standing] ?? 0;
const prev = storage[f.threshold] ?? band(current);
const now = band(current);
if (now !== prev) {
storage[f.threshold] = now;
if (!fired) {
effects.push({ type: 'story', instruction: f.name + ' ' + posture[now] + '. Adjust how all ' + f.name + ' NPCs and agents behave this scene and going forward.' });
fired = true;
}
}
}
if (!fired) { skip = true; }Notes:
- Only one band-change notification fires per tick (the
firedflag). If two factions cross bands simultaneously, the second is caught the following tick. skip = truesuppresses the trigger entirely when no band changed - the narrator receives no instruction and the turn is unaffected.storage.*is written directly in scripts, butwrite-booleanandwrite-numbereffects on the init and event triggers keep the gate logic clean and don't require scripts on those triggers.- Band thresholds are symmetric for readability but can be asymmetric (e.g. hostile requires -50 to enter but -30 to exit) - just track the label separately from the number.
Calibrating increment values (N):
The bands span a total range of roughly 100 points (-50 to +50). Neutral alone is 18 points wide (-9 to +9); hostile and cooperative are 40 points each. N on each event trigger should be sized relative to that scale and to how many triggers of the same tier will realistically fire in a session.
A practical approach is to define three tiers before writing any event triggers:
- Minor (+2 to +3) - brief NPC interactions, small favors, incidental help
- Moderate (+6 to +8) - completing a side task, defending a faction member, a notable act of goodwill
- Major (+12 to +15) - completing a faction quest arc, a significant sacrifice on their behalf
Then audit total possible gain per tier: if 6 minor triggers all fire they contribute +12 to +18 combined. A single major adds another +12 to +15. That gives a realistic ceiling per session before approaching the allied threshold at 50 - which is the intended shape. If all triggers firing in one session can push standing from neutral to allied, the increments are too large.
Gated Area Lock (Worked Example)
Keep a location off-limits until the player earns access, then let them in. This is the forced-movement case: the engine moves the party, the player has no say. The basic lock needs no script -- two recurring triggers and typed effects do it.
Method 1 -- hard backstop (state-phase bounce). A recurring trigger detects the party at the forbidden location while the gate is unmet and relocates them with a story beat:
{
"block_sealed_vault": {
"name": "block_sealed_vault",
"recurring": true,
"conditions": [
{ "type": "party-location", "operator": "equals", "value": "The Sealed Vault" },
{ "type": "player-traits", "operator": "notContains", "value": "Vault Keycard" }
],
"effects": [
{ "type": "story", "instruction": "The way into the vault is barred. Narrate the refusal; the player does not get inside." },
{ "type": "party-location", "operator": "set", "value": "Town Square" }
]
}
}The gate (here a Vault Keycard trait) can be any condition: a read-boolean flag, read-array notContains, quests-completed, a player-resource threshold, or player-level. When the player meets it, the second condition fails and the block stops firing.
Method 2 -- soft pre-empt (planning-phase intercept). Catch the attempt before they arrive so it reads as a wall, not a teleport glitch. Use an action (AI semantic) condition -- action-text regex does not fire travel/move intents:
{
"intercept_sealed_vault": {
"name": "intercept_sealed_vault",
"recurring": true,
"conditions": [
{ "type": "action", "query": "The player is attempting to travel, fast-travel, teleport, or move to The Sealed Vault." },
{ "type": "player-traits", "operator": "notContains", "value": "Vault Keycard" }
],
"effects": [
{ "type": "story", "instruction": "The player tries to reach the vault and cannot. The way is barred. Narrate the obstacle; they do not arrive." }
]
}
}Optional -- return them where they were. Method 1 sends the player to a fixed location. To bounce them back to wherever they came from instead, add a recurring tracker and a script on the block trigger:
// tracker trigger (no conditions): remember the last allowed location
const loc = check({ type: 'party-location' });
if (loc !== 'The Sealed Vault') storage.safe_loc = loc;
// block trigger script: relocate to the remembered spot
effects.push({ type: 'party-location', operator: 'set', value: storage.safe_loc || 'Town Square' });Caveats:
- A
party-locationset cascades region/realm/area and can show a brief two-turn state-loading flicker (harmless). storyeffects are deferred to the next narration, so the Method 1 bounce reads a beat late. Method 2 covers the in-the-moment "you can't get there".- Keep the location
known: falseuntil unlocked (flip it with aknown-entityeffect) so it is not even an option on the map. Hidden, plus the bounce, plus the intercept, is as close to a hard lock as the engine allows.
Living World: Locations Change While You're Away (Worked Example)
Pattern contributed by Purplejump.
Places the party has left should feel like they kept living. A narrator can't reliably track how long it has been since you visited somewhere, so returns tend to read as static. This counts the absence deterministically and hands the narrator a one-time, scaled cue to describe what has changed — while leaving the specifics to the fiction.
Architecture — two core triggers, plus an optional accelerator:
world_register(recurring: true, condition:story"Is the party in an inhabited settlement — a village, town, city, or other place with residents?") — records inhabited locations so only lived-in places drift.
if (!storage.away) storage.away = {};
const loc = check({ type: 'party-location' });
if (loc && !(loc in storage.away)) storage.away[loc] = 0;world_tick(recurring: true, no conditions) — ages every place you are not at, and when you return somewhere that drifted, fires a change scaled to how long you were gone, then resets it.
const loc = check({ type: 'party-location' });
if (!storage.away) storage.away = {};
for (const key in storage.away) { // age everywhere you're NOT
if (key !== loc) storage.away[key] = (storage.away[key] || 0) + 1;
}
if (loc in storage.away) { // back somewhere that drifted?
const away = storage.away[loc];
storage.away[loc] = 0;
const keep = ' Show the change only through what is physically seen, keep the place true to its core identity and purpose, keep it non-destructive, and never state a specific timeframe.';
if (away >= 750) effects.push({ type: 'story', instruction: 'The party returns after a long absence. Large but fitting changes: buildings raised or gone, a shift in who holds sway, new groups established, familiar faces missing or grown.' + keep });
else if (away >= 250) effects.push({ type: 'story', instruction: 'The party returns after a good while. Moderate changes: a building raised or torn down, a recent local event, arrivals and departures.' + keep });
else if (away >= 75) effects.push({ type: 'story', instruction: 'The party returns after a little while. Small changes: construction underway, restocked stalls, a newcomer or two.' + keep });
else skip = true;
}world_timeskip(optional;recurring: true, condition:story"Has a notable span of in-world time just passed — roughly a week or more, a time skip, or a long journey?") — adds a big jump to every away-location so an in-fiction time skip ages the places you left, not just real turns.
const loc = check({ type: 'party-location' });
if (!storage.away) storage.away = {};
for (const key in storage.away) {
if (key !== loc) storage.away[key] = (storage.away[key] || 0) + 250;
}Tuning: at +1/turn the thresholds are roughly 75 / 250 / 750 turns for small / moderate / large — dial them to taste. In worlds with frequent time-skips, world_timeskip is what actually drives the change; in slower worlds, lower the thresholds so ordinary travel is enough.
Notes:
- The
storycues are deliberately generic so the narrator fills the specifics from context; thekeepguardrail (physical-only, identity-preserving, no explicit timeframe) is what keeps returns feeling organic instead of jarring. skip = trueon an uneventful return keeps the trigger silent;storagewrites still persist.- One semantic time detector, not four.
storyconditions are LLM-evaluated and only a subset run each tick, so a single "has notable time passed?" query is both cheaper and more dependable than separate day / week / month / year detectors. - All the per-turn work in
world_tickis mechanical (nostory/actioncondition), so it never competes for the semantic-trigger budget.
Variant — the place you stay in stirs to life. The inverse: track a separate storage.stay counter that resets when your location changes and increments while you stay put; when it crosses a threshold, push a "a small local event unfolds" cue and reset. Same shape, fired while present rather than on return.
Race Evolution (Worked Example)
Permanently swap one race trait for another and deliver the transformation as a present-tense scene interrupt. Uses a two-turn split: the swap fires first, and the narrator describes it the following turn against the already-updated character state.
Architecture -- two one-shot triggers, both self-deleting. The swap trigger applies the trait change and sets two flags; the narrate trigger fires the following turn off one of them. The race_evolved flag is the permanent record; race_evolution_narrate is the one-turn delivery signal. Adapt the conditions to whatever gates the evolution in your world (level threshold, quest completed, resource milestone, narrative flag, or any combination).
"race_evolution_swap": {
"name": "race_evolution_swap",
"conditions": [
{ "type": "player-level", "operator": "greaterThanOrEqual", "value": 10 },
{ "type": "quests-completed", "operator": "contains", "value": "Trial of the Ashen Flame" },
{ "type": "read-boolean", "key": "race_evolved", "operator": "equals", "value": false }
],
"effects": [
{ "type": "player-traits", "operator": "remove", "value": "Human" },
{ "type": "player-traits", "operator": "add", "value": "Ashborn" },
{ "type": "write-boolean", "key": "race_evolved", "operator": "set", "value": true },
{ "type": "write-boolean", "key": "race_evolution_narrate", "operator": "set", "value": true }
],
"script": "delete triggers['race_evolution_swap'];"
},
"race_evolution_narrate": {
"name": "race_evolution_narrate",
"conditions": [
{ "type": "read-boolean", "key": "race_evolution_narrate", "operator": "equals", "value": true }
],
"effects": [
{ "type": "story", "instruction": "The character has just permanently transformed into an Ashborn. Interrupt the current scene to describe the physical change unfolding: ash-grey skin, ember light behind the eyes, the faint smell of spent flame. Make it visceral and present-tense; the character feels it happening. This is not a background event, it is the scene. After the transformation is complete, continue from where the story was." }
],
"script": "delete triggers['race_evolution_narrate'];"
}Branching paths (player choice)
For worlds where multiple evolution paths exist and the player selects one at the threshold, a single universal selector trigger handles all branches. No separate trigger per path is needed; the script does the routing.
Three triggers: a gate that presents the choice, a universal selector that reads the player's input and applies the correct swap, and the narration delivery.
Trigger 1 -- present choice (one-shot, state phase):
"race_evolution_gate": {
"name": "race_evolution_gate",
"conditions": [
{ "type": "player-level", "operator": "greaterThanOrEqual", "value": 10 },
{ "type": "read-boolean", "key": "race_evolved", "operator": "equals", "value": false },
{ "type": "read-boolean", "key": "evolution_pending", "operator": "equals", "value": false }
],
"effects": [
{ "type": "write-boolean", "key": "evolution_pending", "operator": "set", "value": true },
{ "type": "story", "instruction": "Pause the scene. Tell the player their character has reached the threshold of transformation and must now choose a path. Present the options clearly: Ashborn (fire and ash), Frostborn (cold and stillness), Stormborn (lightning and motion). Wait for their choice before continuing." }
],
"script": "delete triggers['race_evolution_gate'];"
}Trigger 2 -- universal selector (recurring, planning phase):
"race_evolution_select": {
"name": "race_evolution_select",
"recurring": true,
"conditions": [
{ "type": "read-boolean", "key": "evolution_pending", "operator": "equals", "value": true },
{ "type": "action", "query": "The player has chosen one of the available evolution paths by name or clear intent." }
],
"effects": [],
"script": "const input = (check({ type: 'action-text' }) || []).slice(-1)[0] || '';\nconst paths = {\n 'ashborn': 'Ashborn',\n 'frostborn': 'Frostborn',\n 'stormborn': 'Stormborn',\n};\nconst chosen = Object.entries(paths).find(([key]) => new RegExp(key, 'i').test(input));\nif (chosen) {\n effects.push({ type: 'player-traits', operator: 'remove', value: 'Human' });\n effects.push({ type: 'player-traits', operator: 'add', value: chosen[1] });\n effects.push({ type: 'write-boolean', key: 'evolution_pending', operator: 'set', value: false });\n effects.push({ type: 'write-boolean', key: 'race_evolved', operator: 'set', value: true });\n effects.push({ type: 'write-boolean', key: 'race_evolution_narrate', operator: 'set', value: true });\n delete triggers['race_evolution_select'];\n} else {\n skip = true;\n}"
}The action semantic condition routes this trigger to the planning phase so the response is immediate. If the player's input does not match any path, skip = true prevents the trigger from consuming itself and it retries next turn. Adding a new evolution path requires only a new entry in the paths object.
Note: The
actionsemantic condition is intentionally broad -- it fires whenever the AI judges that a choice was made, and the script's regex is the real gate. If the AI fires the trigger on ambiguous input but no regex key matches,skip = trueis set and the turn passes silently with no visible effect. This is harmless in practice, but keep the regex keys specific enough that a clear player choice always produces a match.
Trigger 3 -- narration delivery (one-shot, state phase): identical to the single-path version above. The story instruction should reference the chosen form by name; since the swap has already applied, the character sheet reflects the new race and the narrator can read it directly. A generic instruction works:
"race_evolution_narrate": {
"name": "race_evolution_narrate",
"conditions": [
{ "type": "read-boolean", "key": "race_evolution_narrate", "operator": "equals", "value": true }
],
"effects": [
{ "type": "story", "instruction": "The character has just permanently transformed into their chosen evolved form. Interrupt the current scene to describe the physical change as it happens -- draw from the character sheet to name the new race and shape the sensory details accordingly. Make it visceral and present-tense; the character feels it happening. This is not a background event, it is the scene. After the transformation is complete, continue from where the story was." }
],
"script": "delete triggers['race_evolution_narrate'];"
}Script Examples for Common Mechanics
Script triggers unlock precise mechanical control for patterns that are otherwise narrator-interpreted. The examples below illustrate the concept behind each pattern -- the trigger names, storage keys, and numeric values are placeholders to make the logic readable, not prescriptions for how to implement them in a real world.
Status Effect with Duration Countdown
Trigger: Poison Tick -- recurring: true, condition read-number poison_turns greaterThan 0. Set storage.poison_turns = N from an ability or item script when the condition is inflicted. To also clear an is_poisoned flag when the counter expires, push a write-boolean effect inside the zero-check.
storage.poison_turns -= 1;
if (storage.poison_turns > 0) {
effects.push({ type: 'story', instruction: 'The player takes ongoing poison damage this turn (' + storage.poison_turns + ' turns remaining).' });
} else {
storage.poison_turns = 0;
effects.push({ type: 'story', instruction: 'The poison runs its course. The player takes the final tick of damage and the poisoned condition clears.' });
}Passive Ability Enforcement
Trigger: Passive Enforcer -- recurring: true, no conditions. Set storage.passive_regen = 5 from the ability or init trigger when the passive is granted. Set it to 0 to remove it without deleting the trigger.
const amount = storage.passive_regen ?? 0;
if (amount > 0) {
effects.push({ type: 'story', instruction: 'Regeneration passive: the character recovers ' + amount + ' HP at the start of this turn before any other actions resolve.' });
} else {
skip = true;
}For a timed buff (N turns, then expire) -- same pattern with a decrement and a condition gate. Trigger: Haste Buff -- recurring: true, condition read-number haste_turns greaterThan 0. Set storage.haste_turns = 5 from the ability that grants it; the condition prevents the trigger firing once it reaches zero.
storage.haste_turns -= 1;
if (storage.haste_turns > 0) {
effects.push({ type: 'story', instruction: 'Haste is active (' + storage.haste_turns + ' turns remaining): the character acts first this turn and moves at double speed.' });
} else {
storage.haste_turns = 0;
effects.push({ type: 'story', instruction: 'Haste has expired. Normal action speed resumes.' });
}Calendar / Day-Night Cycle
Trigger: Time Advance -- recurring: true, no conditions. TICKS_PER_DAY controls how many turns make up one in-world day -- 24 means each tick represents roughly one hour. check({ type: 'game-tick' }) returns the engine's own tick counter so no manual counter is needed. skip = true prevents a story push on every tick when the period hasn't changed.
const TICKS_PER_DAY = 24;
const tick = check({ type: 'game-tick' });
const hour = tick % TICKS_PER_DAY;
const prev = storage.time_period || '';
var now = '';
if (hour < 6) now = 'night';
else if (hour < 12) now = 'morning';
else if (hour < 18) now = 'afternoon';
else now = 'evening';
if (now !== prev) {
storage.time_period = now;
effects.push({ type: 'story', instruction: 'It is now ' + now + '. Adjust lighting, ambient activity, and NPC availability accordingly.' });
} else {
skip = true;
}Named Initiative / Turn Order
Two triggers: Combat Init (recurring: false, story condition fires when combat starts) builds and shuffles the order. Combat Advance (recurring: true, read-boolean combat_active condition) steps through it each turn. Replace the combatants array with the actual participants for each encounter.
const combatants = ['Player', 'Enemy A', 'Enemy B'];
for (var i = combatants.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var temp = combatants[i];
combatants[i] = combatants[j];
combatants[j] = temp;
}
storage.initiative = combatants;
storage.initiative_index = 0;
effects.push({ type: 'story', instruction: 'Combat begins. Initiative order: ' + combatants.join(' → ') + '. Start with ' + combatants[0] + '.' });const order = storage.initiative ?? [];
if (order.length > 0) {
const idx = (storage.initiative_index + 1) % order.length;
storage.initiative_index = idx;
effects.push({ type: 'story', instruction: 'It is now ' + order[idx] + "'s turn." });
} else {
skip = true;
}Damage Type Side Effect Application
Two triggers working together: an ability trigger sets storage.pending_poison when the condition is inflicted; Poison Application Monitor (recurring: true) picks it up and feeds the Status Effect countdown trigger above. Stacking works naturally -- each hit adds to pending_poison before the monitor resolves it into the active counter.
storage.pending_poison = (storage.pending_poison ?? 0) + 3;if ((storage.pending_poison ?? 0) > 0) {
storage.poison_turns = (storage.poison_turns ?? 0) + storage.pending_poison;
storage.pending_poison = 0;
effects.push({ type: 'story', instruction: 'Poison has been applied. Target is now poisoned for ' + storage.poison_turns + ' turns.' });
} else {
skip = true;
}Notes:
effects.push()is the only way to dynamically add effects from a script. Staticeffectsarray entries are pre-populated before the script runs; pushed entries are appended. Only effects within the per-trigger cap apply total - both static and pushed combined (the cap is listed in Size Limits).skip = truesuppresses all effects and prevents a non-recurring trigger from being consumed - use it when a recurring trigger has nothing to do this tick.storage.*persists across ticks within a session. It is the correct place for any value a script needs to remember between turns.- Do not use
returnat the top level of a script - scripts do not run inside a function body. Useif/elseorskip = trueto control flow instead. Math.random()works. A destructuring swap ([a, b] = [b, a]) is valid syntax, but a line beginning with[joins the statement above it when that line has no semicolon, which silently corrupts the swap -- ordinary JavaScript, not a script restriction. Use a temp variable, as in the Named Initiative example above, or end the previous line with;.
Narrator-driven state changes: well-placed aiInstructions prose can shape mechanical outcomes, not just narrative ones - but reliability varies by task. Instructions in generateActionInfo govern action resolution and are the more reliable place for resource cost rules; instructions in generateStory compete for attention with the full narrative and are less reliable. For anything that must always happen, use a trigger. For dynamic consequences that tolerate occasional misses, prose instructions in the right task are a viable fallback. See the full breakdown under aiInstructions.
Using damageTypes as an AI Context Channel
damageTypes is an array of strings used by vulnerabilities, resistances, and immunities. The validator accepts any string in this array - the codec only checks that the value is a string, not that it names a real damage type. Some authors use this as a side channel for injecting full instruction blocks into combat-related AI context; this is an unsupported pattern. Use aiInstructions for rules that must fire in combat.