Skip to content

Quests & Achievements

Structured narrative progression: multi-stage quests with rewards, and binary/tiered achievements.

Quest System

Structured narrative progression: multi-stage tasks with NPC offer flows, rewards, and reactive $quest.* signals.


Signals

Each defined quest automatically gets a $quest.<id> signal. Its value is one of:

ValueMeaning
"unavailable"Not yet offered to the player (the starting state)
"available"Flagged with (quest-unlock:); the giver offers it but it hasn't started
"active"Started, and the quest has no stages defined
"<stageId>"Started with stages; the value is the current stage id, not "active"
"ready"All stages done; waiting for the player to turn it in to the giver
"complete"Finished
"failed"Failed
ana
(if: $quest.find_thief is "search")[The alley is quiet...]
(if: $quest.find_thief is "complete")[Old Bill thanks you warmly.]

⚠️ Staged quests never hold "active". For a quest with stages, the signal holds the current stage id, so (if: $quest.find_thief is "active") silently never matches. To check "is this quest underway?" regardless of stages, use (quest-is: "find_thief", active), which returns true for any started quest. Only compare against "active" directly if you know the quest has no stages.

The state machine: unavailable((quest-unlock:))available((quest-start:))active/<stageId>((quest-advance:) past the last stage)ready((quest-turnin:))complete. (quest-fail:) moves any non-terminal state to failed; (quest-complete:) jumps straight to complete. The computed offer path (requires/dependsOn) lets the giver offer an unavailable quest without ever passing through available; see (quest-available:).


(quest-define: "id", title, "...", desc, "...", [kwargs])

Defines a quest. Call in @system(init).

Required: "id" (string), title, desc

Optional kwargs:

KwargTypeDescription
giverstringNPC id that offers the quest
requiresexprLazy condition; quest only appears when true
dependsOnarrayQuest IDs that must be complete first
reward-itemstring [, n]Award N items (default 1) on completion
reward-relstring, numberAward relationship delta on completion
ana
(quest-define: "find_thief",
  title,     "The Missing Coin",
  desc,      "Someone stole from Old Bill. Find out who.",
  giver,     "bartender",
  requires,  ($rel.bartender > 20),
  dependsOn, ["meet_bartender"],
  reward-item, "gold_coin", 5,
  reward-rel,  "bartender", 15
)

Multiple reward-item and reward-rel kwargs can be combined in one definition.


(quest-stage: "questId", "stageId", "description")

Adds a stage to an existing quest (call after (quest-define:), in order). Stages execute in insertion order.

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

State management

(quest-unlock: "id") → boolean

Flags a quest as available (unavailableavailable). Once flagged, the quest's giver NPC offers it on the next visit, regardless of the quest's requires condition. Use this for story-driven availability: something happens, and now the quest can be picked up. Returns false if the quest isn't in the unavailable state.

This is the explicit counterpart to the computed offer path: if you set up requires/dependsOn, the giver offers the quest automatically when those are satisfied, and you never need (quest-unlock:). Reach for (quest-unlock:) when the trigger is a scripted event rather than a state condition.

ana
// After the player overhears a rumor:
(quest-unlock: "find_thief")
// Next time they talk to the bartender, the quest appears in (quest-available:).

(quest-start: "id") → boolean

Starts a quest (unavailable or availableactive). Updates $quest.id. Returns true on success. Returns false if the quest is already started or its prerequisites (dependsOn, requires) are not met.

ana
(if: (quest-start: "find_thief") is false)[
    The quest has already started or prerequisites not met.
]

(quest-advance: "id") → boolean

Advances to the next stage. When past the last stage, the quest transitions to ready status, which means "waiting for turn-in", not complete. The quest is not complete yet; only (quest-turnin:) or (quest-complete:) moves it to complete. Returns true if something changed.

Stageless quests: A quest defined without any (quest-stage:) calls goes directly to ready status when (quest-start:) is called, since there are no stages to advance through. Use (quest-turnin:) or (quest-complete:) to finalize it.

(quest-complete: "id") → boolean

Force-state macro. Completes the quest regardless of its current status (even if failed or still active). Applies rewards and fires @on(questComplete). Returns false if already complete. This is a state management tool; use it for quests that complete automatically (story beats, exploration milestones). For normal quest flow that requires returning to an NPC, use (quest-turnin:) instead.

(quest-turnin: "id") → boolean

Finalizes a quest that is in ready state: all stages are complete and the player is formally handing it in. Applies rewards and fires @on(questComplete). Returns false if the quest is not in ready state. This is the intended path for quests requiring NPC turn-in.

ana
(if: (quest-is: "the_missing", ready))[
    (link: "Tell him what you found")[(action: Bar_TurnIn)]
]

:: Bar_TurnIn
(quest-turnin: "the_missing")
"Good work. Here's what I promised."

(quest-fail: "id") → boolean

Force-state macro. Fails the quest regardless of its current status (even if complete). Fires @on(questFail). Returns false if already failed. This is a state management tool; it overrides whatever status the quest is currently in. Use (quest-advance:) / (quest-turnin:) for normal quest flow.


NPC offer flow

(quest-available: "npcId") → string[]

Returns quest IDs that are currently available from that NPC. A quest qualifies when its giver is this NPC and either it was explicitly flagged with (quest-unlock:) (status available), or it is still unavailable but all its dependsOn quests are complete and its requires condition is met right now.

ana
(each: _q in (quest-available: "bartender"))[
  (quest-offer: _q)
  (link: "Accept")[(set: $world.selectedQuest to _q)(action: AcceptThisQuest)]
  (link: "Not now")[(goto: BartenderMain)]
]

(quest-offer: "id")

Renders the quest title and description as prose in the current zone. Authors write their own accept/decline links.

ana
// Bartender passage
(if: (quest-is: "find_thief", unavailable))[
  "Can you help me find who stole from me?"
  (quest-offer: "find_thief")
  (link: "Yes, I'll look into it")[(goto: AcceptFindThief)]
  (link: "Sorry, can't help")[(goto: BartenderMain)]
]

// AcceptFindThief passage
(quest-start: "find_thief")
(goto: BartenderMain)

Query macros

(quest-status: "id") → string

Returns the canonical status string: "unavailable", "available", "active", "ready", "complete", or "failed". "ready" means all stages are complete and the quest is awaiting formal turn-in. Always one of these six values.

(quest-is: "id", status) → boolean

Sugar for (quest-status: "id") is status.

ana
(if: (quest-is: "find_thief", active))[You're on the case.]

(quest-stage-is: "id", stageId) → boolean

Returns true if the quest is active and currently at the given stage.

ana
(if: (quest-stage-is: "find_thief", search))[Search the alley...]

(quest-list:) → string[]

(quest-list: filter) → string[]

Returns quest IDs filtered by status. Filter values: active (default, includes both active and ready), ready, complete, failed, available, all.

ana
(each: _q in (quest-list: active))[
  Active: (quest-title: _q)
]

Display helpers

(quest-title: "id") → string

Returns the quest's display title.

(quest-desc: "id") → string

Returns the quest's description.

(quest-stage-desc: "id") → string

Returns the description of the currently active stage (empty string if no active stage).

(quest-event-id:) → string

Returns the quest ID that triggered the current @on(questComplete) or @on(questFail) handler. Use this instead of a temp var since the handler runs in its own scope.

ana
:: QuestCompleteHandler @on(questComplete)

(set: _q to (quest-event-id:))
(notify: "Quest complete: " + (quest-title: _q))

Quest log UI

The quest log is implemented as a passage template; copy templates/quest_log.ana to passages/ui/ and customize. It uses (quest-list:), (quest-title:), (quest-stage-desc:), and (quest-desc:) to build the UI.

The Q key opens QuestLog by default (engine keybind quests).


Events

EventFires when
@on(questComplete)Any quest is completed via (quest-complete:) or (quest-turnin:)
@on(questFail)Any quest is failed via (quest-fail:)

Use (quest-event-id:) inside the handler to identify which quest fired the event.


Customizes the text prefix for quest offer and turn-in links injected by (load-npc:). Both kwargs are optional; omit either to keep the default.

ana
// GameInit — change the ★ prefix to something else
(quest-link-format: offer, "📋 ", turnin, "✓ ")

The links injected by (load-npc:) appear in the options zone when the NPC has available or ready quests. Default prefix for offer links is "★ " and for turn-in links is "✓ ".

Achievement System

Tracks player accomplishments. Supports binary (condition-met) achievements and counted/tiered achievements (reach 10, then 25, then 50 of something). Achievements are checked automatically on every (time-advance:), or manually with (achieve-check:).

Open the achievements screen with the A key (default), or call (goto: AchievementsScreen).


(achieve-define: "id", name, "...", desc, "...", condition, <expr>)

(achieve-define: "id", name, "...", desc, "...", count, <expr>, tiers, N, N, ...)

Defines an achievement. Call in GameInit. Key-value pairs after the id can appear in any order.

KeyValueNotes
namestringDisplay name
descstringShort description
hiddenbooleantrue hides name/desc until unlocked
conditionexpressionFor binary achievements; unlocks when truthy
countexpressionFor counted/tiered; any expression returning a number
tiersN, N, ...Bare numbers; defines unlock thresholds
ana
// Binary — unlocked when condition becomes true
(achieve-define: "quest_done",
  name, "Hero's Journey",
  desc, "Complete the first quest",
  condition, ($quest.stage is "complete"))

// Counted/tiered — tiers unlock as count crosses each threshold
(achieve-define: "crystal_hoarder",
  name, "Crystal Hoarder",
  desc, "Collect energy crystals",
  count, $player.crystals,
  tiers, 10, 25, 50)

// Item-set achievement using (contains-count:)
(achieve-define: "bronze_warrior",
  name, "Bronze Warrior",
  desc, "Collect the full bronze armor set",
  count, (contains-count: $inv, "bronze_gloves", "bronze_chest", "bronze_helm"),
  tiers, 1, 2, 3)

// Hidden achievement
(achieve-define: "secret",
  name, "???",
  desc, "...",
  hidden, true,
  condition, ($player.events contains "found_easter_egg"))

(achieve-check: "id")

Evaluates the achievement's condition/count right now and unlocks the next tier if the threshold is met. Pushes a notification to the notify zone on unlock.

ana
// After a scripted event that might meet the condition:
(achieve-check: "quest_done")

Auto-check runs for all achievements on every (time-advance:); you only need (achieve-check:) for immediate feedback without advancing time.


(achieve-unlock: "id")

Force-unlocks the next unprogressed tier regardless of condition. Use for scripted story moments (or for cheaters).

ana
(achieve-unlock: "quest_done")

(achieve-unlocked: "id") → boolean

Expression macro. Returns true if the achievement is fully unlocked (all tiers complete).

ana
(if: (achieve-unlocked: "quest_done"))[
    You remember your triumph.
]

(achieve-progress: "id") → number

Expression macro. Evaluates and returns the current count expression value for a counted achievement.

ana
Crystals: (achieve-progress: "crystal_hoarder") / 50

(achieve-list:) → array

(achieve-list: all) → array

Expression macro. Returns an array of achievement IDs. Without all, returns only fully unlocked IDs. With all, returns every defined ID.

ana
You have unlocked (achieve-list:) achievements.

(each: _id in (achieve-list: all))[
    (achieve-name: _id)
]

Display helpers

Use these to build a custom achievements UI. Copy templates/achievements.ana to passages/ui/ as a starting point.

MacroReturnsNotes
(achieve-name: "id")string"???" if hidden and not yet unlocked
(achieve-desc: "id")string"..." if hidden and not yet unlocked
(achieve-tiers-complete: "id")numberHow many tiers the player has unlocked
(achieve-tiers-total: "id")numberTotal tiers defined (1 for binary)
(achieve-next-threshold: "id")numberThreshold for the next tier (0 for binary)
(achieve-progress: "id")numberCurrent count expression value

Save/load

Achievement progress is included in the game save automatically. Achievement definitions (set in GameInit) are re-registered on every load.