Appearance
NPCs, Relationships, Stats & Skills
The character-state systems: NPC data, relationship tracking, player stats, and skill checks.
NPC System
NPC data lives in the nested namespace npc. Each NPC is identified by a string ID; properties are initialized with (npc-define: "id", ...) in GameInit.
Reading and writing NPC properties follows the same conventions as any other variable: the 3-part dot path is valid everywhere a variable is accepted:
ana
// Inline in prose and conditions
$npc.bartender.name
(if: $npc.bartender.location is "bar")[...]
// With (get:) — equivalent to inline, useful in expression chains
(get: $npc.bartender.name)
(if: (get: $npc.bartender.age) > 50)[...]
// With (set:) — update a single property
(set: $npc.bartender.location to "back_room")
// Dynamic lookup — when the id is in a temp variable (e.g., inside a loop)
(get: $npc, _id, "name")Declaring NPC data
Use (npc-define: "id", key, value, ...) in GameInit to initialize all properties at once. To update a single property later in a passage, use (set:) with the 3-part variable form.
ana
// GameInit (_init.ana)
(npc-define: "bartender", name, "Old Bill", gender, "male", age, 58, location, "bar", events, [])
// In a passage, update state:
(set: $npc.bartender.angry to true)
(set: $npc.bartender.location to "back_room")See (npc-define:) for the full signature.
(declare: $npc.id, key, value, ...): custom nested namespaces
(npc-define:) is the preferred way to create NPCs, but the underlying batch form, (declare: $namespace.id, key, value, ...), works on any registered nested namespace, not just npc. Use it for game-specific entity types:
ana
(ns-nested: "faction")
(declare: $faction.guild, name, "Merchant Guild", leader, "Theron", reputation, 0)Keys are bare identifiers (no quotes). Values can be any literal or expression. (declare: $npc.id, ...) still works identically to (npc-define: "id", ...) for NPCs, but prefer (npc-define:) there.
Dynamic NPC lookup
When the NPC id is in a temp variable (e.g., inside a (each:) loop), use the bare namespace form with an explicit id argument:
ana
(get: $npc, _id, "name") // name property for the NPC whose id is in _id
(get: $npc, _id, "location")For a known static id, the 3-part dot path is cleaner and works everywhere:
ana
You're talking to $npc.bartender.name.
(if: $npc.bartender.location is "bar")[
He's still behind the bar.
]To loop over all NPCs or filter by property, see (ids:), (filter:), (query:).
(load-npc: "id")
Declares which NPC the player is actively interacting with and populates the NPC sidebar for the current scene. Does three things in sequence:
- Sets
$world.activeNpcto the given NPC id - Pushes the NPC's portrait, name, and relationship tier to
sidebar_npc - Scans for quests this NPC offers that are currently available; for each, pushes a
★ Quest Titlelink to theoptionszone pointing at<questId>_offer(skipped if that passage doesn't exist). Also pushes turn-in links for any quests inreadystate that this NPC is the giver of.
(load-npc:) loads exactly the NPC you name; it does not filter by location. (For a location-filtered NPC list, build one yourself with (filter: $npc.location is $world.location) in your sidebar passage; see the npc_panel.ana template.)
Quest links: The prefix strings for offer and turn-in links injected into the options zone are configurable via (quest-link-format:) in GameInit. Default: "★ " for offer links and "✓ " for turn-in links.
Call this at the start of any passage that begins an NPC interaction. The @on(goto) handler in UI_NpcPanel resets $world.activeNpc to "" and clears the sidebar on every full navigation, so the panel is always consistent.
ana
:: 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)]
(link: "Leave")[(goto: MainStreet)]Pattern: approach vs. encounter. Use (load-npc:) on the first (update:) passage that starts the conversation, not on the scene's root (goto:) passage. This keeps the sidebar empty when the player enters the location without approaching anyone.
ana
// BarScene (goto target — sidebar stays empty)
:: BarScene [location:bar]
(img: bar_evening.jpg)
@zone(options)
(link: "Approach the bar")[(update: Bar_BartenderGreet)] // (load-npc:) fires here
(link: "Scan the crowd")[(update: Bar_CrowdScan)]
(link: "Leave")[(goto: MainStreet)]Relationships
Tracks numeric relationship values between the player and named characters. Relationship scores live as $rel.<id> global variables. You can also track named dimensions per relationship (e.g., friendship, attraction) alongside the default value.
Note: relationship configuration lives in dedicated
(rel-defaults:)/(rel-tiers:)macros, matching the(item-define:)/(status-define:)family.(declare:)is now only for declaring a per-NPC relationship variable;(refine:)only updates bounds. The old(declare: $rel, ...),(declare: $rel.tiers, ...), and(refine: $rel.id, tiers, ...)forms now raise an error pointing at the replacement.
Global defaults: (rel-defaults: initial, min, max)
Sets the default bounds and initial value for all $rel.* relationships. When a relationship is first used without an explicit per-NPC declaration, it is auto-created using these defaults.
ana
// GameInit — applies to every $rel.* that isn't explicitly overridden
(rel-defaults: 0, -100, 100)The named-dimension form sets a global default for one dimension across all NPCs:
ana
(rel-defaults: "friendship", 0, 0, 100)Global tier labels: (rel-tiers: threshold, "label", ...)
Sets the default tier labels for all relationships. Takes alternating threshold/label pairs. The highest threshold the current value meets wins.
ana
// GameInit
(rel-tiers: -50, "hostile", 0, "neutral", 50, "friendly", 80, "close friend", 100, "best friend")Per-NPC declaration: (declare: $rel.id, initial, min, max)
Declares a specific relationship with its own bounds. Overrides the global defaults for that NPC. min and max are optional; if omitted they default to -100 and 100 respectively.
ana
// NPCs that need different ranges
(declare: $rel.bartender, 0, -50, 50) // shorter range than global default
(declare: $rel.mayor, 0) // initial 0, min/max default to -100/100Per-NPC tier override: (rel-tiers: "id", threshold, "label", ...)
Replaces the tier config for a specific NPC (overrides global tiers for that NPC only). The first argument is the NPC id (a string); the rest are threshold/label pairs.
ana
(rel-tiers: "bartender", -25, "disgruntled", 0, "regular", 25, "friend", 50, "confidant")Per-NPC bounds override: (refine: $rel.id, initial, min, max)
Resets the value and bounds for a specific NPC.
ana
(refine: $rel.sheriff, 0, -75, 75)Modifying the default relationship value
Use (add:) and (sub:); they auto-clamp to bounds. If global defaults are set and the relationship hasn't been declared, it is auto-created on first use.
ana
(add: $rel.bartender, 10) // relationship improves
(sub: $rel.bartender, 15) // relationship worsensReading the default relationship value
Read $rel.<id> directly in conditions or prose:
ana
(if: $rel.bartender > 50)[
He greets you by name.
]
(elseif: $rel.bartender > 0)[
He nods.
]
(else:)[
He looks through you.
]
Your relationship with Old Bill: $rel.bartenderNamed dimensions: (rel-dimension: $rel.id, dim, initial, min, max)
Declares a named dimension on a specific NPC. Use when only certain NPCs have this dimension (e.g., romantic attraction).
ana
(rel-dimension: $rel.bruce, attraction, 0, 0, 100)
(rel-dimension: $rel.bruce, friendship, 0, 0, 100)For a dimension that applies to all NPCs, use the global form instead:
ana
(rel-defaults: "friendship", 0, 0, 100) // global dimension defaultModifying and reading named dimensions
$variable path: Named dimensions are readable as $rel.npcId.dimensionName, both inline and via (get:).
ana
// Modify
(add: $rel.bruce, "friendship", 10)
(sub: $rel.bruce, "attraction", 5)
// Read inline — 3-part path works anywhere
(if: $rel.bruce.friendship > 60)[
He smiles when you enter.
]
Your friendship with Bruce: $rel.bruce.friendship
// (get:) form also works
(get: $rel.bruce, "friendship") // → numberThe default dimension ($rel.bruce with no name) is a regular flat signal:
ana
(if: $rel.bruce > 50)[He seems to trust you.](tier: $rel.id)
(tier: $rel.id, dim)
(tier: _id)
Expression macro. Returns the current tier label as a string. Uses global tier labels unless a per-NPC override is set.
ana
Your relationship with the bartender: (tier: $rel.bartender)
// → "friendly"
// Named dimension tier:
Your friendship with Bruce: (tier: $rel.bruce, "friendship")
// Dynamic form — when id is in a temp variable (e.g., in a loop):
(tier: _id) // looks up $rel.<_id> and returns the tier labelReturns an empty string if no tier labels are configured.
Stats
Bounded numeric attributes. Declare stats with (declare:) in GameInit: initial value and clamping bounds in one call. Once bounds are registered, every (add:) or (sub:) on that variable auto-clamps.
(declare: $variable, initial, min, max)
Declares a bounded numeric stat. Registers both the initial value and clamping bounds in a single call.
ana
// GameInit — single step (replaces the old two-step pattern)
(declare: $player.health, 100, 0, 100)
(declare: $player.stamina, 100, 0, 100)
(declare: $player.maxHealth, 100) // no bounds — plain declarationUse (refine: $variable, max, N) if the max changes at runtime (e.g., health upgrade on level-up):
ana
(refine: $player.health, max, 150) // new max; current value clamped automaticallyModifying stats
Use (add:) and (sub:); they auto-clamp when bounds are registered:
ana
(add: $player.health, 10) // heal (won't exceed 100)
(sub: $player.health, 15) // take damage (won't go below 0)
(add: $player.stamina, 20)
(sub: $player.stamina, 10)To set a stat to a specific value, use (set:). The value is clamped at read-time:
ana
(set: $player.health to 100) // full heal
(set: $player.health to 1) // barely aliveReading stats
Read $player.health directly; it evaluates to the current value:
ana
(if: $player.health < 25)[
You're badly hurt.
]
Health: $player.health / $player.maxHealth
(meter: $player.health, $player.maxHealth)Skills
Skills are numeric, bounded, and improvable. Declare them with (declare:) in GameInit; the $skill.* namespace auto-applies the engine's default bounds (0–100) unless you specify different ones.
Traits are just string arrays on the character namespace; see Arrays for the traits pattern.
Setup
ana
// GameInit — single step; auto-bounds 0–100 for $skill.* variables
(declare: $skill.persuasion, 5)
(declare: $skill.stealth, 0)
(declare: $skill.lockpicking, 3)
// Override bounds for a specific skill:
(declare: $skill.crafting, 10, 0, 50)
// Change the default skill range (call before all skill declarations):
(skill-range: 0, 200)Modifying skills
Use (add:) and (sub:); they auto-clamp to the registered bounds:
ana
(add: $skill.persuasion, 5) // improve (won't exceed 100)
(sub: $skill.stealth, 10) // skill loss (won't go below 0)(check: $skill.variable, threshold)
Expression macro. Returns true if the skill value is at or above the threshold, false otherwise.
ana
(if: (check: $skill.persuasion, 60))[
He softens. "All right. I'll tell you what I know."
]
(else:)[
He shakes his head. "Leave it alone."
]
(if: (check: $skill.lockpicking, 40) and (has: $inv, "lockpick"))[
You could pick this lock.
]Reading skill values
Read $skill.* variables directly in conditions or prose:
ana
(if: $skill.persuasion >= 50)[
You're quite persuasive.
]
Persuasion: $skill.persuasion / 100