Pattern cookbook — common narrative-logic recipes

Parlance gives you a small, sharp toolkit: conditions (predicates over state), effects (state changes), self-declaring dialogue offers (each dialogue says who it is offered to, when, and at what priority), and per-node / per-choice showIf gates. Almost every recurring narrative-logic problem is a specific arrangement of those four things.

This cookbook is the arrangements. Each recipe names a problem writers hit in every narrative engine, shows the Parlance way to solve it, and — under Also known as — points at how Ink, Yarn Spinner, Twine, and Ren'Py spell the same idea, so a writer arriving from another tool can find the pattern by the name they already know.

None of this is new engine capability. It is the vocabulary already in schema/common.schema.json (conditions and effects) and schema/dialogue.schema.json (nodes, showIf, next, onEnter, and the offer object), used on purpose. When a recipe leans on a rule with a sharp edge, the edge is called out in Pitfalls — most of them are things that have actually bitten someone here.

The four primitives, in one breath

Everything below is built out of exactly these.


1. Say-it-once (state-gated re-entry) — the flagship

Problem. The first time the player meets the Warden he recruits them: a whole scene. Every visit after, they just want the one useful line — the checkpoint code, today's orders. You do not want to replay the recruitment.

Recipe. Two dialogues and one flag, each offering for the same character. The intro fires a set_flag as its side effect; the intro's offer.when is gated on that flag being unset, so once it fires the intro stops being eligible and the shorter dialogue — the fallback, which loses to the intro's more specific gate only while the intro is eligible — wins.

// dlg_warden_recruit — offered while unmet, and records the scene on its way out
{
  "id": "dlg_warden_recruit",
  "speakerId": "npc_warden",
  "offer": {
    "when": { "type": "not", "of": { "type": "flag", "flag": "met_warden", "value": true } }
  },
  "entry": "node_open",
  "nodes": [
    {
      "id": "node_sworn_in", "isEnd": true,
      "onEnter": [ { "type": "set_flag", "flag": "met_warden", "value": true } ],
      "text": "'Then you're one of us. Report to the checkpoint.'"
    }
  ]
}
// dlg_warden_brief — the fallback (offer with no `when`), wins forever after
{ "id": "dlg_warden_brief", "speakerId": "npc_warden", "offer": {}, "entry": "…", "nodes": [ … ] }

Now: first talk → met_warden is false → the recruit offer is eligible and its when (specificity 1) outranks the bare fallback (specificity 0) → recruit plays → on its way out it sets met_warden. Every later talk → the recruit offer's when fails → only the fallback dlg_warden_brief (the short informational one) is eligible.

Pitfalls.

Also known as. Ink: a {knot > 0} seen-check, or a VAR met = false set once. Yarn: once / visited("warden"). Twine: (if: not visited()) (Harlowe) / <<if visited() is 1>> (SugarCube). Ren'Py: if not persistent.met_warden: or renpy.seen_label().


2. Skip the setup (node-level showIf)

Problem. Same goal as recipe 1, but you want to keep it in one dialogue — the first three beats are scene-setting you only want once; the menu at the bottom is evergreen.

Recipe. Mark the dialogue replayable. Make the setup beats interstitial nodes (text + next, no choices) and gate each with showIf. When the gate fails the beat is skipped and flow jumps to next — landing on the evergreen menu.

{ "replayable": true, "entry": "node_establish", "nodes": [
  {
    "id": "node_establish",
    "showIf": { "type": "not", "of": { "type": "flag", "flag": "seen_office", "value": true } },
    "text": "The office reeks of cold coffee. She doesn't look up.",
    "next": "node_office_seen"
  },
  {
    "id": "node_office_seen",
    "onEnter": [ { "type": "set_flag", "flag": "seen_office", "value": true } ],
    "text": "'Well? What do you want?'",
    "next": "node_menu"
  },
  { "id": "node_menu", "text": "…", "choices": [ /* evergreen menu */ ] }
] }

Pitfalls.

Also known as. Ink once-only gather / { ... } once-only alternatives. Yarn <<once>>. Ren'Py content guarded by a seen flag inside one label.


3. Event memory (world reacts to what you did)

Problem. The player sabotages the relay in a cutscene or a conversation. Later, an unrelated character should already know — comment on it, treat the player differently.

Recipe. The action writes a flag; anyone, anywhere, reads it. Effects and conditions share one global state, so a set_flag in one dialogue is visible to every showIf, offer.when, quest gate, and ending in the project.

// in the sabotage dialogue
"effects": [ { "type": "set_flag", "flag": "relay_sabotaged", "value": true } ]
// a bystander's dialogue, days later — a beat that only exists if it happened
{
  "id": "node_gossip",
  "showIf": { "type": "flag", "flag": "relay_sabotaged", "value": true },
  "text": "'Heard the relay went dark. That was you, wasn't it.'",
  "next": "node_menu"
}

Pitfalls.

Also known as. Emily Short's "world model as shared state"; Ink/Yarn global VARs read across knots/nodes; Twine story variables ($relay_sabotaged); Ren'Py module-level default relay_sabotaged = False.


4. Knowledge unlock (learning X opens a door elsewhere)

Problem. You cannot ask the Broker about the "sealed letter" until someone has told you it exists. Once you know, the option should appear on its own.

Recipe. Learning the fact sets a knowledge flag; the option that requires the fact carries a matching choice.showIf. This is event memory (recipe 3) pointed at the player's knowledge rather than the world's state.

// the informant's line grants the knowledge
"effects": [ { "type": "set_flag", "flag": "knows_letter", "value": true } ]
// the Broker's menu — this choice is hidden until you know
{
  "id": "ch_ask_letter",
  "showIf": { "type": "flag", "flag": "knows_letter", "value": true },
  "text": "Ask about the sealed letter.",
  "goto": "node_letter"
}

Pitfalls.

Also known as. Elder Scrolls topic lists; Ace Attorney "evidence"; Disco Elysium thoughts; Ink knows_X = true; Yarn knowledge variables.


5. One-shot option (a choice that spends itself)

Problem. "Pocket the ledger" should be offered once. After the player takes it, the option must be gone — not greyed, gone — even on a replayable dialogue.

Recipe. The choice both fires a flag and hides on that same flag. Its effects set it; its showIf requires it unset.

{
  "id": "ch_pocket_ledger",
  "showIf": { "type": "not", "of": { "type": "flag", "flag": "took_ledger", "value": true } },
  "text": "Pocket the ledger while she's turned away.",
  "effects": [
    { "type": "set_flag", "flag": "took_ledger", "value": true },
    { "type": "give_item", "item": "burned_ledger" }
  ],
  "goto": "node_pocketed"
}

Pitfalls.

Also known as. Ink once-only * choices; Yarn a choice inside <<if not $took>>; Twine (link:) that (set:)s then vanishes; Ren'Py menu option "Take it" if not taken:.


6. Hub-and-spoke topic menu

Problem. An interrogation or a shopkeeper: a central menu, the player picks a topic, hears it, comes back to the menu, and topics they've exhausted stop cluttering it.

Recipe. A hub node whose choices goto topic nodes; each topic ends by routing next back to the hub. Mark a topic done with a flag and hide its choice on that flag. Add an unconditional "That's all" exit and, optionally, an "anything else?" beat.

{ "id": "node_hub", "text": "'Ask what you like.'", "choices": [
  {
    "id": "ch_topic_bridge",
    "showIf": { "type": "not", "of": { "type": "flag", "flag": "asked_bridge", "value": true } },
    "text": "The bridge — who controls it?",
    "effects": [ { "type": "set_flag", "flag": "asked_bridge", "value": true } ],
    "goto": "node_bridge"
  },
  { "id": "ch_leave", "text": "That's all for now.", "goto": "node_bye" }
] }
{ "id": "node_bridge", "text": "'The Order holds it. For now.'", "next": "node_hub" }

Pitfalls.

Also known as. Ink weave with a gather acting as the hub; Yarn a node the options jump back to; Twine a central passage; the classic RPG "conversation topics" wheel.


7. Gating & prerequisites (hard gate vs. soft gate)

Problem. Some content requires a prerequisite. Sometimes you want it invisible until earned (a surprise); sometimes you want it visible but locked (a signpost — "come back when you're stronger").

Recipe. Same condition, two placements.

// hard gate — needs standing with the Order AND the badge
{
  "id": "ch_enter_vault",
  "showIf": { "type": "all", "of": [
    { "type": "reputation", "faction": "faction_a", "op": ">=", "value": 20 },
    { "type": "item", "item": "order_badge", "has": true }
  ] },
  "text": "Show the badge and step into the vault.",
  "goto": "node_vault"
}

// soft gate — same condition, shown locked when it fails
{
  "id": "ch_enter_vault",
  "showIf": { "type": "item", "item": "order_badge", "has": true },
  "whenLocked": "show",
  "lockedText": "[Requires the Order's badge]",
  "text": "Show the badge and step into the vault.",
  "goto": "node_vault"
}

Pitfalls.

Also known as. Ink * {condition} [choice]; Yarn <<if>> around an option; Ren'Py "Option" if condition:; Twine conditional (link:).


8. Reputation thresholds (tone shifts with standing)

Problem. The same guard is hostile to strangers, curt to the tolerated, and warm to allies — and you don't want to write that fork inside every line.

Recipe. Give each tone its own dialogue, and let each offer for the guard. The gate on each is the reputation band it covers; the fallback (no when) catches strangers. Faction reputation conditions do the selecting; adjust_reputation effects move the needle elsewhere. Order in the file is irrelevant — specificity, then value, decides.

// dlg_guard_ally
{ "id": "dlg_guard_ally", "speakerId": "npc_guard",
  "offer": { "when": { "type": "reputation", "faction": "faction_a", "op": ">=", "value": 30 } }, "entry": "…", "nodes": [ … ] }
// dlg_guard_known
{ "id": "dlg_guard_known", "speakerId": "npc_guard",
  "offer": { "when": { "type": "reputation", "faction": "faction_a", "op": ">=", "value": 10 } }, "entry": "…", "nodes": [ … ] }
// dlg_guard_cold — the fallback: strangers and enemies
{ "id": "dlg_guard_cold", "speakerId": "npc_guard", "offer": {}, "entry": "…", "nodes": [ … ] }

Pitfalls.

Also known as. Ren'Py "points" systems; Fallout/Elder Scrolls disposition tiers; any if rep > N tone gate.


9. Relationship track (per-character warmth)

Problem. One companion should remember how you personally have treated them, independent of faction politics.

Recipe. adjust_relationship on the choices that matter; relationship conditions to read it. Structurally identical to reputation (recipe 8) but keyed to a character id, and unclamped — a character declares no range, and an absent key reads as 0.

// a kind choice
"effects": [ { "type": "adjust_relationship", "character": "npc_contact", "delta": 5 } ]
// a beat that only warm friends get
{
  "id": "node_confides",
  "showIf": { "type": "relationship", "character": "npc_contact", "op": ">=", "value": 15 },
  "text": "She lowers her voice. 'Can I trust you with something?'",
  "next": "node_secret"
}

Pitfalls.

Also known as. Ren'Py affection points → ending; BioWare approval/loyalty; Persona social links.


10. Quest as a state machine

Problem. "Find the Contact" moves through stages — offered, accepted, in progress, resolved — and dialogue, objectives, and endings all need to know where it stands.

Recipe. Model the quest with ordered stages. advance_quest moves it forward from an effect; quest conditions read it by stage order (>= means "at or past"). Objective showIf and offer.when gates key off the same stages, so the whole world stays in sync with one write.

// accepting the job, in dialogue
"effects": [ { "type": "advance_quest", "quest": "task_find_contact", "toStage": "stg_active" } ]
// a line that only makes sense once the job is live but not yet done
{
  "id": "node_progress",
  "showIf": { "type": "all", "of": [
    { "type": "quest", "quest": "task_find_contact", "op": ">=", "stage": "stg_active" },
    { "type": "quest", "quest": "task_find_contact", "op": "<",  "stage": "stg_done" }
  ] },
  "text": "'Any sign of them yet?'",
  "next": "node_hub"
}

Pitfalls.

Also known as. RPG Maker quest switches/variables; Ink quest LIST state machines; the universal available → active → complete/failed lifecycle.


11. Counters, thresholds & "you've asked enough"

Problem. Some things count: visit three shrines, ask the same nosy question twice and the NPC gets annoyed, buy five and unlock a discount.

Recipe. adjust_counter to tally; a counter condition to branch on the total.

// each visit
"onEnter": [ { "type": "adjust_counter", "counter": "shrines_lit", "delta": 1 } ]
// the payoff, anywhere
{
  "id": "node_blessing",
  "showIf": { "type": "counter", "counter": "shrines_lit", "op": ">=", "value": 3 },
  "text": "The air changes. Something has noticed.",
  "next": "node_hub"
}

Pitfalls.

Also known as. Ink knot read-counts ({knot}); Yarn visited_count(); Twine (history:) length; any n += 1 tally.


12. Salience (most-specific line wins)

Problem. A greeting should reflect the most relevant current fact: mid-quest? just betrayed them? raining? Otherwise, a default. You don't want to hand-branch all combinations.

Recipe. This is what offers do by default — no arrangement needed. Give each variant its own dialogue offering for the character, gated by the fact it needs, plus one fallback with no when. resolveCharacterDialogue picks the most specific eligible offer automatically; the fallback wins only when nothing more specific applies.

// each is a dialogue offering for npc_wren — order in the file is irrelevant
{ "id": "dlg_wren_betrayed", "speakerId": "npc_wren",
  "offer": { "when": { "type": "flag", "flag": "betrayed_wren", "value": true } }, "entry": "…", "nodes": [ … ] }
{ "id": "dlg_wren_onquest", "speakerId": "npc_wren",
  "offer": { "when": { "type": "quest", "quest": "task_prove_worth", "op": ">=", "stage": "stg_active" } }, "entry": "…", "nodes": [ … ] }
{ "id": "dlg_wren_warm", "speakerId": "npc_wren",
  "offer": { "when": { "type": "relationship", "character": "npc_wren", "op": ">=", "value": 20 } }, "entry": "…", "nodes": [ … ] }
{ "id": "dlg_wren_default", "speakerId": "npc_wren", "offer": {}, "entry": "…", "nodes": [ … ] }

Pitfalls.

Also known as. Fallen London / StoryNexus storylets and "salience"; Valve's Left 4 Dead / Dota response rules (most-specific matching context wins); Versu.


13. Remembered detail (echo it back with text variables)

Problem. The player named their ship, or chose the red door. Later you want a character to say the specific thing back — not "your choice," but "the Kestrel."

Recipe. set_text writes a named string slot; {var} in any player-facing string interpolates it. Text variables are substitution slots — they are not readable by a condition; to branch, set a flag alongside (recipe 15).

// at the naming beat
"effects": [ { "type": "set_text", "variable": "ship_name", "value": "the Kestrel" } ]
// much later
{ "id": "node_callback", "text": "'They say {ship_name} runs the blockade nightly. That you?'" }

Pitfalls.

Also known as. Ink/Yarn string variables in text ({ship_name}); Twine $shipName printed in a passage; Ren'Py "[ship_name]" interpolation.


14. Push vs. pull — deciding the next conversation

Problem. After the Broker's deal, the player should have a new scene queued when they next talk to whoever's relevant. Who decides which conversation plays?

Recipe. Two mechanisms, deliberately different:

Prefer push for a scripted "next beat happens here"; prefer pull (offers) for "whatever fits the current state."

Pitfalls.

Also known as. Ink -> divert / a scheduled knot vs. a {condition: -> knot} selector; Yarn <<jump>> vs. a node picked by <<if>>; a state machine's explicit transition vs. a rule that fires on a matched condition.


15. Branch, remember, converge (the callback)

Problem. A fork early on — spare or execute the prisoner — should reunite into shared scenes, but pay off later with a line that remembers which way you went.

Recipe. At the fork, each branch sets a distinguishing flag; the branches then converge (goto/next to the same node). Later, an interstitial showIf beat (or a whole offered dialogue gated on the flag) reads it and delivers the callback.

// the fork
{ "id": "ch_spare", "text": "Let them go.",
  "effects": [ { "type": "set_flag", "flag": "spared_prisoner", "value": true } ],
  "goto": "node_after" }
// the payoff, an act later
{ "id": "node_callback",
  "showIf": { "type": "flag", "flag": "spared_prisoner", "value": true },
  "text": "'Word is you showed mercy at the gate. People remember that.'",
  "next": "node_hub" }

Pitfalls.

Also known as. Ink variables set in one branch, tested in a later gather; Ren'Py flags that steer the epilogue; the universal "your choices matter" callback.


16. Skill-check fork (and letting failure through)

Problem. Persuade the guard. A pass and a fail should lead somewhere — and failure usually shouldn't be a dead stop.

Recipe. Wrap a choice in a check. Active rolls against a difficulty and routes to onSuccess / onFailure nodes. Passive reveals or hides the option against a threshold (a stat gate that shows the player why). Convey cost/identity with kind. Make a check easier or harder in a given situation with modifiers — each a when condition and a bonus added to the total when it holds (they sum; negative = harder):

{
  "id": "ch_persuade",
  "text": "Talk your way past him.",
  "check": { "mode": "active", "skill": "rhetoric", "difficulty": 12,
             "onSuccess": "node_waved_through", "onFailure": "node_rebuffed", "kind": "priced",
             "modifiers": [
               { "when": { "type": "item", "item": "guild_seal", "has": true }, "bonus": 3, "label": "Guild seal" },
               { "when": { "type": "flag", "flag": "drunk", "value": true }, "bonus": -2 }
             ] }
}

Pitfalls.

Also known as. Disco Elysium white/red checks; Fallout SPECIAL dialogue checks; Ren'Py if renpy.random...; any [Persuade] / [Strength] tagged option.


17. Item as key (the inventory is the memory)

Problem. A door needs the rusted key; a fence only deals if you're carrying the goods. You could track it with a flag — but you're already tracking the item.

Recipe. Gate on the item condition directly; give_item / take_item are the writes. The demo Broker's "buy it outright" choice does exactly this — it's showIf on holding stash_valuables, and its onEnter does take_item then give_item.

{
  "id": "ch_unlock",
  "showIf": { "type": "item", "item": "rusted_key", "has": true },
  "text": "Try the rusted key in the lock.",
  "effects": [ { "type": "take_item", "item": "rusted_key" } ],
  "goto": "node_opened"
}

Pitfalls.

Also known as. Every adventure-game "use key on door"; Ink/Yarn an inventory list + membership test; Twine an inventory datamap.


18. Rotating flavor (a line that doesn't repeat itself)

Problem. A doorman has five idle greetings; hearing the same one every visit reads as a machine. You want variety across visits.

Recipe. Parlance has no inline variant syntax and no RNG in the data — variety is authored as a counter you bump on entry plus a chain of interstitial showIf beats, each gated on a counter band. This gives a sequence (each line once, then hold on the last), which is the version most worth having.

{ "id": "node_greet_tick", "onEnter": [ { "type": "adjust_counter", "counter": "doorman_seen", "delta": 1 } ],
  "text": "The doorman looks up from his ledger.", "next": "node_greet_1" },
{ "id": "node_greet_1", "showIf": { "type": "counter", "counter": "doorman_seen", "op": "==", "value": 1 },
  "text": "'New face. State your business.'", "next": "node_greet_2" },
{ "id": "node_greet_2", "showIf": { "type": "counter", "counter": "doorman_seen", "op": "==", "value": 2 },
  "text": "'You again.'", "next": "node_greet_n" },
{ "id": "node_greet_n", "showIf": { "type": "counter", "counter": "doorman_seen", "op": ">=", "value": 3 },
  "text": "'Go on through.'", "next": "node_menu" }   // holds for every later visit

Every greeting's next is the next greeting, not the menu, and only the last one continues to node_menu. A skipped node continues at its own next, so the chain is what carries a later visit past the greetings it has outgrown. Visit 1 plays greeting 1, visit 2 skips greeting 1 and plays greeting 2, and visit 3 onward skips both and plays node_greet_n. The chain also means every greeting must be gated: an ungated node_greet_n would play after greeting 1 or 2 as well, since both flow into it.

Pitfalls.

Also known as. Ink alternatives — {a|b|c} sequence, {&a|b|c} cycle, {!a|b|c} once-through, {~a|b|c} shuffle; Skyrim idle-chatter pools; Left 4 Dead barks.


19. Engine cue on a line (camera shake, a sound, a mood)

Problem. The door slams and the camera should shake; the keeper snaps and her portrait should switch to angry. That is the engine's work, but it has to fire at this line, in order with the story's own effects.

Recipe. Two hand-offs, both opaque to Parlance (0.15). A one-shot action is an engine effect in the node's onEnter (or a choice's effects). A property of the line that the engine reads while presenting it is a tag.

{ "id": "node_slam", "text": "The door slams behind you.",
  "tags": ["mood:startled"],
  "onEnter": [
    { "type": "set_flag", "flag": "door_shut", "value": true },
    { "type": "engine", "command": "shake", "args": [0.5] },
    { "type": "engine", "command": "play_sfx", "args": ["door_slam"] }
  ],
  "next": "node_keeper" }

Declare the commands your engine handles in rules.json ("engine": { "commands": { "shake": { "args": 1 }, "play_sfx": { "args": 1 } } }), so a typo is a validator warning instead of a silent no-op.

Pitfalls.

Also known as. Yarn <<shake 0.5>> custom commands and #hashtags; Ink EXTERNAL functions and # tags; Ren'Py with vpunch / play sound.


What Parlance deliberately doesn't model (and how to fake it)

Authors coming from Ink, Yarn, Twine, or Ren'Py will reach for a few things that aren't in the contract. None are oversights — each has a reason, and each has a workaround in the vocabulary above.

Choosing between the recipes — a cheat sheet

You want to… Reach for Recipe
Not replay a whole intro scene offer gated on a "met" flag + a fallback offer 1
Skip a preamble inside one replayable dialogue node showIf + next, flag on the surviving beat 2
Let the world react to an off-screen deed set_flag, read from anywhere 3
Unlock a topic once the player learns it knowledge flag on a choice.showIf 4
Offer something exactly once choice that sets a flag and hides on it 5
A returnable topic menu hub node + next-back spokes + exhaustion flags 6
Require a prerequisite (hidden or signposted) condition on choice.showIf (hard) vs. the same plus whenLocked: "show" + lockedText (soft; or a re-test node / passive check) 7, 16
Shift tone by standing one offer per band on reputation / relationship 8, 9
Track a multi-stage quest advance_quest + quest (stage-order) conditions 10
Count things / "asked enough" adjust_counter + counter condition 11
Pick the most relevant line automatically offers ranked by specificity (salience) 12
Quote the player's specific choice back set_text + {var} 13
Script the next conversation explicitly set_active_dialogue (push) vs. offers (pull) 14
Pay off an early fork much later flag at the fork, showIf at the payoff 15
Fork on a skill / let failure through active/passive check, kind: priced 16
Gate on carrying an object item condition + give_item/take_item 17
Vary a repeated line across visits adjust_counter + counter-band showIf beats 18
Shake the camera / play a sound / set a mood on a line engine effect (an action) or a line tag (a property) 19

Two rules that cut across every recipe