Skip to content

RPG Systems

Dice and skill checks, quests, NPC state and economy, status effects, and how they fit together.

Reference: NPCs, Relationships, Stats & Skills, Quests & Achievements, Events, Tags, Status & Dice, and Shops & Currency.

Dice Rolling and Skill Checks

Ana provides two distinct resolution systems; choose whichever matches the narrative moment.

(check:): simple threshold check

Use when skill simply determines whether an action is possible. Returns true or false. No randomness.

ana
(if: (check: $skill.lockpicking, 40) and (has: $inv, "lockpick"))[
    You pick the lock cleanly.
](else:)[
    You can't manage it.
]

(dice:): probabilistic rolls with optional stat modifiers

Use when you want variance even within a skill tier, or when showing the roll adds drama.

Basic roll, no stat:

ana
(set: _roll to (dice: 6))
You rolled a _roll.

Roll with stat modifier: A stat variable with registered bounds (declared via (declare: $skill.persuasion, 0), bounds auto-applied from (skill-range:)) is converted to a modifier using linear interpolation against the configured modifier scale (default −5 to +5). A persuasion of 75/100 gives a +3 modifier.

ana
(set: _result to (dice: 20, $skill.persuasion))
You rolled _result (includes your persuasion bonus).

Pass/fail in one expression:

ana
(if: (dice: 20, $skill.persuasion, 15))[
    He relents.
](else:)[
    He refuses.
]

Logging the roll and result separately:

ana
(set: _roll to (dice: 20, $skill.persuasion))
(set: _pass to _roll >= 15)

Persuasion check: rolled _roll.

(if: _pass)[
    He relents. "Fine. You win."
](else:)[
    He shakes his head.
]

When to use which

SituationUse
Hard gate: the player simply can or can't(check:)
Luck plays a role, but skill still matters(dice: N, $stat, threshold)
Pure chance with no stat input(dice: N) with manual threshold
Showing the roll number to the player(dice: N, $stat) stored in _roll

Configuring the modifier scale

In GameInit, call (dice-range:) to set how wide the modifier range is:

ana
:: GameInit @system(init)

(dice-range: -5, 5)    // default — moderate stat influence
(dice-range: -2, 2)    // mild — luck dominates
(dice-range: -10, 10)  // strong — skill dominates

See Dice Rolling in the reference for the full modifier formula and examples.

Quest System

Quests track multi-stage tasks with optional NPC givers, gating conditions, and item or relationship rewards. Use quests when a task has a distinct start, middle, and end that the player should be able to track. For simple one-off flags ("did the player do X?"), a plain event array is sufficient.

When to use quests vs. event flags

SituationUse
Fetch quest, delivery, investigation with stagesQuest system
"Talk to the bartender" tracked with a journal UIQuest system
"Player has read the letter" (no tracking needed)Event flag ($player.events)
"Met Kyle" (binary, no stages)Event flag

Defining a quest

Call (quest-define:) in GameInit. Stages are added afterward with (quest-stage:) in insertion order.

ana
// GameInit
(quest-define: "find_thief",
    title, "The Missing Goods",
    desc, "Look into the theft at the docks.",
    giver, "bartender",
    reward-item, "silver_coin", 3,
    reward-rel, "bartender", 15)

(quest-stage: "find_thief", "search",   "Search the alley for clues.")
(quest-stage: "find_thief", "confront", "Confront the suspect at the docks.")

Kwargs:

  • title: display name shown in the quest log
  • desc: summary shown at offer time and in the quest log
  • giver: NPC id; gates the quest in (quest-available:) queries
  • requires: expression evaluated at offer time; quest won't surface until true
  • dependsOn: list of quest IDs that must be complete first
  • reward-item: item template id to give on completion (optional count after id)
  • reward-rel: NPC id and relationship delta applied on completion

Quest states

unavailable → available → active → ready → complete
                                 ↘ failed
  • unavailable: defined but not yet surfaced (deps not met, requires not satisfied)
  • available: can be offered by the NPC
  • active: accepted and in progress
  • ready: all stages complete, awaiting formal turn-in at NPC
  • complete: finalized and rewards applied
  • failed: failed via (quest-fail:)

The NPC offer pattern

ana
:: BartenderTalk

(each: _q in (quest-available: "bartender"))[
    (quest-offer: _q)
    // Capture the loop's temp _q into a global (preEffect), then act on it.
    (link: "I'll look into it")[
        (set: $world.selectedQuest to _q)
        (action: AcceptOffer)
    ]
    (link: "Not right now")[(update: BartenderMain)]
]

:: AcceptOffer
(quest-start: $world.selectedQuest)
(update: BartenderMain)

Two things to note in that loop:

  • _q only exists during this render. The (set: $world.selectedQuest to _q) inside the link body is a preEffect: because it references a temp variable, it's evaluated now and applied at click time. A single generic AcceptOffer passage then reads the global. (Computed targets like (action: "Accept_" + _q) also work, since the passage name can be any expression, but you'd need an Accept_<id> passage per quest. The capture-into-a-global pattern is usually cleaner.)
  • (quest-available: "npcId") returns quests where the giver matches and either the quest was flagged with (quest-unlock:), or it's still unavailable with all dependsOn complete and its requires met. (quest-offer:) renders the quest title and description as prose, and you write your own accept/decline links.

Accepting and advancing

ana
:: AcceptFindThief

(quest-start: "find_thief")
(notify: "Quest accepted: The Missing Goods")
(goto: MainStreet)

:: Alley_SearchResult

(quest-advance: "find_thief")    // moves to "confront" stage

(if: (quest-stage-is: "find_thief", confront))[
    You found a torn piece of cloth. Time to confront someone.
]

:: Docks_Confront

(quest-advance: "find_thief")    // all stages done → sets status to 'ready'
(notify: "Return to the bartender.")

When (quest-advance:) is called past the last stage, the quest moves to ready. The player can still explore; the quest stays ready until they return to the NPC.

Turning in

ana
:: BartenderTalk

(if: (quest-is: "find_thief", ready))[
    "Did you find anything?"
    (link: "Tell him what you found")[(action: Bar_TurnIn)]
]

:: Bar_TurnIn

(quest-turnin: "find_thief")
"You've done well. Here's your payment."
(notify: "Quest complete: The Missing Goods")

(quest-turnin:) applies rewards and fires @on(questComplete). Use (quest-complete:) instead for quests that complete automatically (story beats, exploration triggers) without a formal hand-in.

Quest log UI

ana
:: QuestLog

@zone(modal)
(quest-log:)
(link: "Close")[(modal-close:)]

(quest-log:) renders an HTML grid: active quests at top (with current stage description), ready-to-turn-in quests in a separate section, completed quests below. Wire the quest log to a keybind in GameInit:

ana
(keybind-define: "quests", key, "q", passage, "QuestLog")

Reacting to quest completion

ana
:: Global_QuestHandler @on(questComplete)

(set: _q to (quest-event-id:))

(if: _q is "find_thief")[
    (add: $world.crimesSolved, 1)
]

@on(questComplete) and @on(questFail) fire for every quest. Use (quest-event-id:) to identify which one triggered the handler.

NPC State & Economy

NPC approach pattern, (load-npc:), (print:), and the shop system.

The approach pattern

NPCs are present at a location, but that doesn't mean the player is talking to them. The engine separates presence (NPC is at this location) from engagement (player is actively speaking to them).

  • On goto: The NPC sidebar is reset to empty. $world.activeNpc is cleared to "". No NPC info is shown just from entering a location.
  • On update / approach: Calling (load-npc:) sets $world.activeNpc, populates the NPC sidebar with their portrait, name, and relationship tier, and injects available quest links.

This means walking into the bar shows no sidebar; only approaching the bartender opens the panel.

ana
// BarScene (goto target — sidebar stays empty on entry)
:: BarScene [location:bar]

(img: bar_evening.jpg)

@zone(options)
(link: "Approach the bar")[(update: Bar_BartenderGreet)]
(link: "Scan the crowd")[(update: Bar_CrowdScan)]
(link: "Leave")[(goto: MainStreet)]

// Bar_BartenderGreet (update — sidebar populates here)
:: Bar_BartenderGreet

(load-npc: "bartender")

He wipes the bar without looking up.

@zone(options)
(link: "Ask about the rumors")[(update: Bar_Rumors)]
(link: "Order a drink")[(update: Bar_OrderMenu)]

Rendering NPC data inline: (print:)

NPC properties can be displayed inline as $npc.bartender.name for static ids. When the id is in a temp variable (a loop, a filter result), use (print:) with (get:):

ana
(set: _npcs to (filter: $npc.location is $world.location))
(each: _id in _npcs)[
    (print: (get: $npc, _id, "name")) — (print: (tier: _id))
]

(print:) evaluates any expression and renders its value as prose in the current zone. It works with any expression that returns a value: (get:), (tier:), (shop-price:), (random:), etc. Null values are silently suppressed.

The shop system

Shops are defined in GameInit and referenced by string id. The pattern is:

Define → Add items → Set hours → Browse and buy in passage

ana
// GameInit
(shop-define: "bar_shop", merchant, "bartender", markup, 0.1)
(shop-add: "bar_shop", "beer",    price, 5)
(shop-add: "bar_shop", "whiskey", price, 20, stock, 3)
(shop-hours: "bar_shop", open, "morning", close, "night")

// Bar_OrderMenu — the buy screen
:: Bar_OrderMenu

(if: (shop-is-open: "bar_shop"))[
    (shop-browse: "bar_shop")
](else:)[
    The bar isn't taking orders right now.
]

@zone(options)
(link: "Buy a beer")[(action: Bar_BuyBeer)]
(link: "Never mind")[(update: Bar_BartenderGreet)]

:: Bar_BuyBeer

(shop-buy: "bar_shop", "beer")

Price display. To show a price before the player commits to buying:

ana
A beer costs (print: (shop-price: "bar_shop", "beer")) gold.

Stock-gated options. To hide a link when an item is out of stock:

ana
(if: (shop-stock: "bar_shop", "whiskey") > 0)[
    (link: "Buy whiskey")[(action: Bar_BuyWhiskey)]
]

Conditional items. To hide an item from the browse grid based on a story flag:

ana
(shop-add: "general_store", "treasure_map",
    price, 100,
    requires, $world.events contains "heard_about_map")

Time-of-day shop hours

When (time-advance:) changes $world.timeOfDay, shops with matching hours auto-open or auto-close. No polling or manual calls needed. If a shop has no hours registered, use (shop-open:) / (shop-close:) in story passages or event handlers.

ana
:: OnNight @on(dayAdvance)

(if: $world.timeOfDay is "night")[
    (shop-close: "market_stall")
    (notify: "The market has closed for the night.")
]

Selling to shops

Players can sell items back. The sell price is item.value × (1 − discount). If no discount is set on the shop, the player gets the full base value.

ana
(link: "Sell your lockpick")[(action: Bar_SellLockpick)]

:: Bar_SellLockpick

(shop-sell: "bar_shop", "lockpick")

Status Effects

Named conditions that can modify stats, apply recurring damage/healing, or simply flag a state. Define in GameInit, apply and remove in passage logic.

Defining and applying

ana
// GameInit — define with an optional flat modifier, dot (recurring), or as a flag only
(status-define: "poisoned",
    name, "Poisoned",
    description, "Taking damage each turn.",
    dot, $player.health, -5,
    duration, 3)

(status-define: "inspired",
    name, "Inspired",
    description, "Persuasion is heightened.",
    effect, $skill.persuasion, 15)    // flat: applied now, reversed on removal

(status-define: "handcuffed",
    name, "Handcuffed",
    description, "Unable to use your hands.")   // flag only — no stat change

Apply with (status-apply:), remove early with (status-remove:). Duration-based statuses expire automatically each (time-advance:) step.

ana
(status-apply: "poisoned")
(status-remove: "poisoned")

(if: (status-has: "handcuffed"))[
    You can't reach the keys while cuffed.
]

Displaying active statuses

(status-active:) returns the IDs of all currently active statuses. Use (status-name:) and (status-desc:) to read the display name and description you defined, with no hardcoded strings needed.

ana
// In a character panel passage:
(each: _id in (status-active:))[
    (status-name: _id)
]

// With tooltip description via HTML:
(each: _id in (status-active:))[
    (html: "<span title='" + (status-desc: _id) + "'>" + (status-name: _id) + "</span>")
]

(status-name:) falls back to a proper-cased version of the ID ("badly-poisoned""Badly Poisoned") if the status was not defined with (status-define:).

See Status Effects for modifier types, duration math, and the $status.* variable path.