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-driven — behavior_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 layer — personality, 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 def — llm.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_error → dialogue_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