Skip to content

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:

  1. Sets $world.activeNpc to the given NPC id
  2. Pushes the NPC's portrait, name, and relationship tier to sidebar_npc
  3. Scans for quests this NPC offers that are currently available; for each, pushes a ★ Quest Title link to the options zone pointing at <questId>_offer (skipped if that passage doesn't exist). Also pushes turn-in links for any quests in ready state 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/100

Per-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 worsens

Reading 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.bartender

Named 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 default

Modifying 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")     // → number

The 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 label

Returns 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 declaration

Use (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 automatically

Modifying 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 alive

Reading 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