Appearance
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
| Situation | Use |
|---|---|
| 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 dominatesSee 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
| Situation | Use |
|---|---|
| Fetch quest, delivery, investigation with stages | Quest system |
| "Talk to the bartender" tracked with a journal UI | Quest 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 logdesc: summary shown at offer time and in the quest loggiver: NPC id; gates the quest in(quest-available:)queriesrequires: expression evaluated at offer time; quest won't surface until truedependsOn: list of quest IDs that must be complete firstreward-item: item template id to give on completion (optionalcountafter id)reward-rel: NPC id and relationship delta applied on completion
Quest states
unavailable → available → active → ready → complete
↘ failedunavailable: defined but not yet surfaced (deps not met, requires not satisfied)available: can be offered by the NPCactive: accepted and in progressready: all stages complete, awaiting formal turn-in at NPCcomplete: finalized and rewards appliedfailed: 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:
_qonly 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 genericAcceptOfferpassage then reads the global. (Computed targets like(action: "Accept_" + _q)also work, since the passage name can be any expression, but you'd need anAccept_<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 stillunavailablewith alldependsOncomplete and itsrequiresmet.(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.activeNpcis 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 changeApply 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.