Home NPCs & Creatures

NPCs & Creatures (Unified Actor Model)


1. Summary

Monsters are NPCs. There is one unified actor system for every non-player entity in the world — town blacksmiths, quest givers, guards, hired workers, rabbits, dire wolves, and dungeon bosses. Each is an npc_instance spawned from an npc_definition in YAML.

Definitions describe stats, skills, roles, schedules, loot, personality, story hooks, and behavior controller (deterministic, state-machine, utility AI, or LLM-assisted). The same rabbit definition spawns many unique instances across the world — each with its own instance_id, position, inventory, and runtime state — sharing rules and mechanics from the def.

Town NPCs follow routes and schedules — work, lunch, rest, sleep; they are not always in the same spot. Important characters use an LLM conversation layer: the full NPC YAML context (backstory, personality, quests, role) is passed to the model so dialogue stays in character but never identical twice. Per-player memory and feelings (trust, annoyance, warmth) persist and color future chats. Within control parameters on the def, the LLM may also nudge behavior (emotes, schedule overrides like “leave early for lunch”) — always validated server-side.


2. Locked decisions

# Decision
N1 One NPC system — no separate “monster” engine; kind / roles distinguish behavior.
N2 Definition + instance — YAML defs are reusable templates; every spawned entity is a unique npc_instance_id.
N3 Full sim where needed — NPCs use same attributes, pools, skills, combat, and inventory rules as players (11, 12, 15) unless def flags simplify.
N4 Behavior is data-drivenbehavior_controller on def selects engine: schedule, state_machine, utility, llm.
N5 Schedules & routes — civilized NPCs have time-of-day waypoints (work, home, bed, leisure).
N6 Story layerpersonality, backstory, dialogue_pack, quest_hooks on def; instances may track per-player quest state.
N7 Loot on death — combat NPCs reference loot_table → procedural item mint (18); critters drop materials; town neutrals drop nothing or minimal.
N8 LLM per defllm.enabled, prompts, and control parameters in YAML; full def context sent as character role to the model.
N9 Constrained uniqueness — conversation varies freely inside hard rails (story facts, quest state, no inventing items/quests).
N10 Per-player memory — summarized history + relationship scores (trust, affection, annoyance, fear) on npc_instance.
N11 LLM behavior bounds — model may request allowed schedule/emote/trade actions inside llm.behavior_controls; server approves or rejects.
N12 Schedules include breaks — lunch, rest, leisure; NPC location varies by time; LLM may trigger within-parameter early break.

3. Design principles

Principle Meaning
Defs in YAML All NPC content in assets/npcs/ — hot-reload per ruleset
Graph, not monolith Defs reference schedules, loot tables, dialogue packs, quest ids
Instances are real Kill one rabbit ≠ kill all rabbits; guard respawn is spawn rules, not shared HP
Players read identity Physical description + name visible; inspect shows stats if skill allows
Server authority AI/LLM emits intents only (12 §2) — same as players
Story is deterministic Quest facts, faction, inventory come from sim — LLM narrates, does not author canon
Conversation is unique Same player question → different in-character reply; bounded by persona rails
Memory is persistent NPCs remember players and how they feel about them

4. Definition vs instance

Parallel to items (18):

Layer Role
npc_definition Schema — rabbit, town_guard, blacksmith_elsa
npc_instance Live entity in a region — unique id, transform, runtime state
struct NpcInstance {
    instance_id: NpcInstanceId,
    definition_id: NpcDefinitionId,
    spawn_source: SpawnSource,       // segment, respawn_timer, quest, hire, …
    display_name: Option<String>,    // override — "Elsa" vs def default
    transform: Transform,

    // Rolled or authored at spawn
    stat_variance: StatBlock,        // optional ±% from def base
    level: u32,

    // Runtime
    pools: ResourcePools,            // HP, stamina, mana, hunger, … per def flags
    skills: SkillMap,
    inventory: InventoryGraph,
    equipment: EquipmentSlots,
    modifiers: Vec<Modifier>,

    behavior_state: BehaviorState,   // controller-specific blob
    schedule_clock: GameTime,
    quest_instance_flags: Map<QuestId, NpcQuestState>,

    // Per-player social (LLM + sim)
    player_relationships: Map<CharacterId, PlayerRelationship>,

    faction_standing: FactionId,
    alive: bool,
}

struct PlayerRelationship {
    trust: f32,           // -100 .. 100
    affection: f32,
    annoyance: f32,
    fear: f32,
    memory_summary: String,   // rolling LLM-compressed log, capped tokens
    last_spoke_at: Timestamp,
    talk_count: u32,
    flags: Vec<RelFlag>,      // owes_favor, insulted_once, quest_promised, …
}

Duplicate defs, unique instances: ten rabbit instances in a meadow — same def, ten instance_ids, independent position and loot rolls on kill.


5. NPC definition schema (YAML)

# assets/npcs/rabbit.yaml
id: rabbit
display_name: Rabbit
kind: wildlife                    # wildlife | humanoid | undead | construct | …
roles: [critter, harvestable]     # tags for systems

# ── Identity & story ──
physical_description: >
  A small brown lagomorph with twitching nose and long ears.
personality:                      # optional — dialogue / LLM tone
  traits: [skittish, curious]
  speech_style: minimal
backstory: null                   # critters usually none
dialogue_pack: null
quest_hooks: []

# ── Stats & skills ──
level: 1
primaries: { STR: 2, DEX: 8, VIT: 3, STA: 10, INT: 2, WIS: 4, CHA: 2 }
stat_variance_pct: 0.1            # roll ±10% at spawn
skills:
  unarmed: 5
  evasion: 15
pools:
  hp: { enabled: true, max_formula: "8 + VIT*2" }
  stamina: { enabled: true }
  mana: { enabled: false }
  hunger: { enabled: true, simplified: true }

# ── Combat & justice ──
combat:
  enabled: true
  aggro: flee_on_damage           # never | defend | aggro_radius | flee_on_damage
  aggro_radius_m: 0
  attack_bindings: []             # or simple bite attack ref
faction: wild_beasts
evil_on_kill: 0                   # killing rabbits ≠ evil (`14`)
loot_table: rabbit_loot
corpse_harvest: null              # or butcher table for hunters

# ── Equipment ──
equipment_slots: []               # critter — none
starting_inventory: []

# ── Behavior ──
behavior_controller: utility    # schedule | state_machine | utility | llm
behavior:
  utility:
    goals: [forage, flee_threat, idle_graze]
    hearing_radius_m: 12
    fov_deg: 200
  wander_radius_m: 25
  home_anchor: spawn              # or fixed waypoint id

# ── Spawn / respawn ──
respawn:
  enabled: true
  delay_ticks: 6000
  max_instances_per_anchor: 8
  block_while_corpse: true   # see 25-npc-lifecycle-and-ground-loot.html

Full lifecycle (ground drops, carcass TTL, FFA pickup): 25-npc-lifecycle-and-ground-loot.html.

# assets/npcs/blacksmith_elsa.yaml
id: blacksmith_elsa
display_name: Elsa Ironhand
kind: humanoid
roles: [town_civilian, merchant, quest_giver, smith]

physical_description: >
  Broad-shouldered woman, soot-stained apron, burn scar on left forearm.
personality:
  traits: [gruff, fair, proud]
  speech_style: plain_direct
backstory: >
  Third-generation smith in Northhold. Lost her brother in the mine collapse of '12.
dialogue_pack: elsa_blacksmith
quest_hooks:
  - quest: northhold_mine_reopened
    role: quest_giver
  - quest: iron_delivery
    role: turn_in

level: 12
primaries: { STR: 14, DEX: 10, VIT: 12, STA: 14, INT: 9, WIS: 8, CHA: 11 }
skills:
  weaponsmith: 45
  smelting: 40
  trading: 20
pools:
  hp: { enabled: true, max_formula: "50 + VIT*4" }
  hunger: { enabled: false }       # town NPC — simplified

combat:
  enabled: true                   # can be killed — assault crime (`14`)
  aggro: defend
faction: northhold_civilian
evil_on_kill: large
loot_table: null                  # no farm loot; inventory may drop (`07`)

equipment_slots: [mainhand, body, …]
starting_inventory:
  - generator: npc_smith_hammer    # unique instance at spawn (`18`)
  - template: smith_apron_instance

behavior_controller: schedule
schedule_id: elsa_daily

# ── LLM character system (see §8) ──
llm:
  enabled: true
  model_tier: standard              # ruleset routing — cost vs quality
  character_role: |                 # system / role prompt — "who you are"
    You are Elsa Ironhand, blacksmith of Northhold.
    Stay in character. You do not know modern Earth or break the fourth wall.
    Never invent quests, items, or facts not in CANON_CONTEXT.
  context_fields:                   # YAML slices merged into user context each call
    - physical_description
    - personality
    - backstory
    - quest_hooks
    - roles
    - faction
    - current_schedule_phase       # runtime: "at_forge" | "at_lunch" | …
    - settlement_link
  prompt_pack: elsa_persona         # optional extra rails in assets/npcs/llm/
  conversation:
    max_turns_per_minute: 8
    max_tokens_reply: 400
    temperature: 0.85
    memory_max_chars: 2000          # per player, on instance
    summarize_every_n_turns: 6
  rails:
    forbidden_topics: [real_world_politics, admin_commands]
    must_acknowledge_quest_state: true
    cannot_contradict: [backstory, quest_hooks, faction]
  behavior_controls:                # what LLM may *request* (server validates)
    allow_schedule_override: true   # e.g. "I'm closing early"
    schedule_override_max_min: 45
    allowed_overrides: [lunch_early, rest_break, emote, refuse_trade]
    allow_emote: true
    allow_trade_mood: true          # worse prices if annoyance high
    combat_decisions: false
  fallback:
    on_error: dialogue_pack         # elsa_blacksmith tree
    on_rate_limit: short_wait_message

settlement_link: northhold_forge  # building fixture anchor (`20`)

6. NPC definition graph

Defs reference other assets — validated as a DAG at build time:

blacksmith_elsa ──► schedule: elsa_daily
                ──► dialogue_pack: elsa_blacksmith
                ──► quest: northhold_mine_reopened
                ──► settlement: northhold_forge

rabbit        ──► loot_table: rabbit_loot
                ──► loot_table ──► generators: rabbit_pelt, raw_meat

town_guard    ──► schedule: guard_patrol_night
                ──► behavior: state_machine: guard_fsm
                ──► loot_table: null
Node type File Purpose
npc_definition assets/npcs/*.yaml Core actor
schedule assets/npcs/schedules/*.yaml Time-of-day routes
loot_table assets/loot/*.yaml Death drops (18)
dialogue_pack assets/dialogue/*.yaml Branching lines, conditions
behavior_fsm assets/npcs/behaviors/*.yaml State machine defs
llm_prompt_pack assets/npcs/llm/*.yaml Persona + constraints

7. Schedules & routes

# assets/npcs/schedules/elsa_daily.yaml
id: elsa_daily
timezone: region_local
entries:
  - time: "06:00"
    action: travel
    path: [home_bed, forge_anvil]
  - time: "06:30"
    action: work
    station: northhold_forge
    until: "12:00"
  - time: "12:00"
    action: travel
    path: [forge_anvil, market_bench]
    label: lunch
  - time: "12:15"
    action: idle
    anchor: market_bench
    activity: eat_lunch
    until: "13:00"
  - time: "13:00"
    action: travel
    path: [market_bench, forge_anvil]
  - time: "13:15"
    action: work
    station: northhold_forge
    until: "18:00"
  - time: "18:00"
    action: travel
    path: [forge_anvil, tavern_stool]
  - time: "18:30"
    action: idle
    anchor: tavern_stool
    until: "22:00"
  - time: "22:00"
    action: travel
    path: [tavern_stool, home_bed]
  - time: "22:30"
    action: sleep
    anchor: home_bed
    until: "06:00"
action Behavior
travel Follow path waypoints (region navmesh)
work Settlement worker job or station animation (13, 18)
idle Stand/sit at anchor; activity: eat_lunch, read, socialize
rest Short break at anchor — not full sleep
sleep Uninterruptible except assault; in bed anchor
patrol Loop waypoint ring (guards)

NPCs are not vending machines at fixed points — players find Elsa at the forge mid-morning, on the market bench at lunch, at the tavern evening, home at night.

Jitter (optional): time_variance_min: ±15 on entries so lunch isn't always exactly 12:00.

LLM schedule override: if llm.behavior_controls.allow_schedule_override, a conversation may trigger ScheduleOverrideIntent { phase: lunch_early, max_min: 30 } — clamped by def limits (§8.5).

Same path every day or variant schedules (elsa_daily, elsa_festival) swapped by calendar/event.

Instances track schedule_clock — if player blocks path, NPC waits or paths around (navmesh).


8. Behavior controllers

Controller Use case Cost
schedule Town NPCs, shopkeepers Low
state_machine Guards, dungeon mobs, bosses Medium
utility Wildlife, packs, ambient AI Medium
llm Key characters — dialogue & social first; combat stays rules-bound Higher

8.1 State machine (example: guard)

states: [idle, patrol, alert, chase, combat, flee]
transitions:
  - from: patrol
    on: crime_witnessed
    to: alert
  - from: alert
    on: suspect_in_range
    to: chase
  # …

Emits combat intents (12): SetTargetSlot, UseActionSlot, pathing intents.

8.2 Utility AI

Scores actions each tick: flee, graze, investigate_sound, attack. Weights from def + current pools (low HP → flee).

8.3 LLM character & conversation system

Every socially important NPC may define a top-level llm: block on the def (see blacksmith_elsa §5). Wildlife and trash mobs omit it or set enabled: false.

8.3.1 Context bundle (YAML → model)

Each TalkIntent builds CANON_CONTEXT from the live sim + def YAML:

{
  "npc_instance_id": "…",
  "definition_id": "blacksmith_elsa",
  "character_role": "<from llm.character_role>",
  "physical_description": "…",
  "personality": { "traits": ["gruff", "fair"], "speech_style": "plain_direct" },
  "backstory": "…",
  "quest_hooks": [ { "quest": "northhold_mine_reopened", "role": "quest_giver", "state": "active" } ],
  "roles": ["merchant", "smith"],
  "faction": "northhold_civilian",
  "current_schedule_phase": "at_forge",
  "current_anchor": "forge_anvil",
  "available_actions": ["trade", "discuss_mine_quest"],
  "player": {
    "character_id": "…",
    "display_name": "…",
    "relationship": { "trust": 12, "affection": 5, "annoyance": 0, "fear": 0 },
    "memory_summary": "Player helped deliver iron last week. Elsa likes them."
  },
  "prompt_pack_extras": "…"
}

The model replies in character using character_role + CANON_CONTEXT. Story rails (llm.rails) block invented quests, items, or lore contradictions.

8.3.2 Prompt pack (optional asset)

# assets/npcs/llm/elsa_persona.yaml
id: elsa_persona
extends: base_town_npc_rails
conversation_starters:
  - "Can't talk long — orders on the anvil."
  - "If you're selling ore, speak up."
example_replies:
  - player_asks_mine: "Mine's still shut. Brother's still down there, far as I'm concerned."
refusal_templates:
  - too_annoyed: "Come back when you've cooled off."
relationship_modifiers:
  high_trust: "Warmer, shares minor personal details"
  high_annoyance: "Short, may refuse service"

8.3.3 Server pipeline

Player message ──► region worker
    │
    ├─► Build CANON_CONTEXT from def YAML + instance state + player relationship
    ├─► Call LLM (character_role + context + player text)
    ├─► Parse structured response:
    │     { "say": "…", "emote": "hammer_pause", "relationship_delta": { "trust": +2 },
    │       "behavior_request": { "type": "lunch_early", "minutes": 20 } }
    ├─► Validate rails (no forbidden claims, quest state consistent)
    ├─► Apply relationship_delta (clamped per ruleset)
    ├─► Approve/reject behavior_request against llm.behavior_controls
    ├─► Emit TalkIntent + optional EmoteIntent + ScheduleOverrideIntent
    └─► Append to memory_summary; summarize if over cap

Never trusted from LLM: damage, spawn item, grant XP, complete quest stage, teleport — those require separate validated game intents.

8.3.4 Per-player memory & feelings

Stored on npc_instance.player_relationships[character_id]:

Field Use
trust, affection, annoyance, fear Scores −100..100; shift from dialogue + actions (player helped quest +trust; insulted +annoyance)
memory_summary Compressed narrative — "Player bragged about killing guards; Elsa disapproves."
flags Machine-readable — promised_discount, caught_lying
talk_count, last_spoke_at Cadence, "we haven't spoken in weeks"

LLM reads and updates summary; server owns numeric scores (model proposes relationship_delta; server clamps).

Feelings affect sim: high annoyance → refuse trade or worse prices (llm.behavior_controls.allow_trade_mood); high trust → quest hints, discounts.

8.3.5 Behavioral control parameters

llm.behavior_controls defines the envelope for LLM-driven behavior changes:

behavior_controls:
  allow_schedule_override: true
  schedule_override_max_min: 45
  allowed_overrides: [lunch_early, rest_break, leave_work, emote, refuse_trade, end_conversation]
  allow_emote: true
  allow_trade_mood: true
  allow_relocate_idle: true       # "walk with me to the tavern" → short escort path if in schedule
  combat_decisions: false
  max_overrides_per_day: 3
Request Server check
lunch_early Within schedule_override_max_min; not already in combat
refuse_trade Allowed if annoyance > threshold OR def permits
emote Must be in allowed_emotes list on def

Deterministic schedule remains default; LLM adds human variance inside the envelope.

8.3.6 Fallback & cost

Condition Behavior
LLM down / timeout fallback.on_errordialogue_pack tree
Rate limit Queue message or short canned wait
llm.enabled: false Schedule + dialogue_pack only

model_tier on def routes to ruleset cost caps (town extras vs quest bosses).

8.3.7 Combat

behavior_controls.combat_decisions: false at launch — monsters/guards use FSM/utility (§8.1–8.2). LLM is social layer, not combat brain.


9. Roles (tags)

Role Systems
town_civilian Schedule, assault = crime (14)
merchant Shop shelf / trade window
quest_giver Quest graph hooks
guard Crime response, wanted enforcement
worker Hired automation (13)
settlement_worker Town craft loops (18)
critter Low threat, harvest loot
monster Aggro, dungeon, XP, loot
boss Rare spawn, complex FSM
trainer Skill training fees
labor_broker Hire workers (13)

Roles are tags — one NPC can have several (blacksmith_elsa: merchant + quest_giver + smith).


10. Stats, inventory & equipment

NPC instances use the same attribute model as players (11):

  • Primaries, derived stats, pools (per def flags)
  • Skills for combat scaling (12) and craft (09)
  • Unique equipment instances spawned at instance mint (18)
  • Inventory graph (08) — merchants stock real instances

Inspect / assess skill may reveal approximate stats on unknown creatures.

Workers (13) are NPC instances with role: worker + employer_id + active job — not a separate species.


11. Death, loot & corpses

On hp → 0:

1. Emit death event; stop behavior controller
2. If loot_table: roll generators → mint unique items (`18`)
3. Spawn corpse entity (npc corpse or generic) — harvest rules (`07` D3)
4. evil += victim.evil_on_kill (`14`)
5. Respawn scheduler if def.respawn.enabled — new instance_id later
6. Quest hooks: on_npc_death definitions
NPC type Loot Respawn
Rabbit pelts, meat — procedural Yes, anchor
Dungeon mob table + coin Instance lockout / timer
Town quest NPC none / inventory only No casual respawn — story rules
Hired worker inventory + evil Employer may re-hire different def (13)

12. Spawning (world build)

Segment spawns layer (01):

spawn_anchor:
  id: meadow_rabbits_01
  definition_id: rabbit
  count: 12
  scatter_radius_m: 40
  respawn_link: rabbit

Instance mint at region load or player proximity — each spawn creates new npc_instance_id.


13. Dialogue, quests & story

Asset Role
dialogue_pack Deterministic fallback + quest branches when LLM off
quest_hooks Canon quest graph — LLM references, cannot override state
backstory, personality Injected into CANON_CONTEXT (§8.3.1)
llm.character_role Primary "you are …" role prompt
llm.rails Hard limits on what conversation may claim

Per-player: quest_instance_flags + player_relationships on instance.


14. Phasing

Phase Scope
2 npc_definition + instance spawn; rabbit + static idle humanoid
3 Utility AI wildlife; loot tables; death + procedural drops
4 Combat NPCs; state machine guards; schedules (travel/sleep)
5 Dialogue packs; quest hooks; merchants with real inventory
6 Settlement-linked NPCs; labor broker; schedules with lunch/rest
7 LLM enabled on key defs; CANON_CONTEXT; player memory & feelings
8 LLM behavior_controls; schedule overrides; relationship affects trade

15. TUI / agent

flatland npc list --nearby
flatland npc inspect <instance_id> --json
flatland npc defs list
flatland npc defs show rabbit
flatland talk <instance_id> "Have you seen the mine?"
flatland npc relationship <instance_id> --json   # trust, memory summary
flatland-world spawn npc rabbit --anchor meadow_rabbits_01 --count 5

16. Content pipeline

assets/
  npcs/*.yaml
  npcs/schedules/*.yaml
  npcs/behaviors/*.yaml
  npcs/llm/*.yaml
  dialogue/*.yaml
  loot/*.yaml
tools/
  validate-npc-graph.mjs
  validate-schedule-paths.mjs

17. Cross-references

Topic Doc
Movement, FOV, attributes 11
Combat intents, AI tick 12
Hired workers 13
Evil, guards, assault 14
Pool flags per creature 15 §2
Loot generators 18
Segment spawns 01 §5.2

18. Open questions

Resolved (2026-07-06):

# Decision
N1 Unified NPC system — monsters included
N2 YAML defs + unique instances
N3 Full stats/skills/inventory where def enables
N4 Multiple behavior controllers + optional LLM
N5 Schedules with routes, work, sleep
N6 Personality, backstory, dialogue, quests on def graph
N7 Loot tables → procedural unique drops
N8 LLM block on def: character_role, context fields, control parameters
N9 Unique conversation inside story rails
N10 Per-player memory + relationship scores on instance
N11 LLM may request behavior changes inside behavior_controls
N12 Schedules with lunch/rest; location varies; optional jitter

Workers: 13-workers-and-automation.html
Settlement crafting: 18-unique-items-and-procedural-economy.html §6