NPC Autonomy Guide¶
Overview¶
MAID's NPC Autonomy system gives Non-Player Characters independent agency — they pursue goals, follow daily schedules, react to world events, and make decisions without player input. This creates a living world where NPCs feel like real inhabitants rather than static quest dispensers.
Key features:
- Need-Driven Behavior: NPCs have survival, economic, purpose, and comfort needs that decay over time and drive decision-making
- Goal Generation: Goals are automatically created from unmet needs, memories, and relationships
- Daily Schedules: NPCs follow configurable time-based routines (work, sleep, patrol, socialize)
- Utility Scoring: Actions are ranked by how well they satisfy needs, advance goals, and align with the current schedule
- Tick-Budget Safety: Processing is automatically throttled to prevent server lag, with dynamic demotion of expensive NPCs
- LLM Integration: Optional off-tick LLM calls for richer narrative decisions without blocking the game loop
- Circuit Breaker: Automatic protection against cascading LLM failures
Architecture¶
The autonomy system is layered across two packages:
┌─────────────────────────────────────────────┐
│ maid-classic-rpg: AutonomySystem │ ← Game-specific scoring, goals, LLM
├─────────────────────────────────────────────┤
│ maid-stdlib: BaseAutonomySystem │ ← Generic tick loop, budget, tiers
├─────────────────────────────────────────────┤
│ maid-stdlib: NeedsComponent, GoalsComponent│ ← Data models (ECS components)
│ ScheduleComponent │
└─────────────────────────────────────────────┘
Components (data on entities):
| Component | Package | Purpose |
|---|---|---|
NeedsComponent |
maid-stdlib | Tracks need values, decay rates, mood, and stress |
GoalsComponent |
maid-stdlib | Active/completed/failed goals with priorities |
ScheduleComponent |
maid-stdlib | Daily time-block schedule and current activity |
Systems (logic that processes entities each tick):
| System | Package | Purpose |
|---|---|---|
NeedDecaySystem |
maid-stdlib | Decays NPC needs by elapsed game hours and updates mood |
ScheduleSystem |
maid-classic-rpg | Applies schedule blocks based on world time/weather events |
AutonomySystem |
maid-classic-rpg | Main decision loop: goals → candidates → scoring → execution |
Supporting classes:
| Class | Purpose |
|---|---|
GoalGenerator |
Creates goal candidates from needs, memories, and relationships |
GoalLifecycleManager |
Tracks goal progress, completion, and failure |
UtilityScorer |
Ranks action candidates by utility |
ActionExecutor |
Executes chosen actions in the world |
OffTickLLMQueue |
Async priority queue for LLM requests outside the tick loop |
Configuration¶
The autonomy system requires the maid-classic-rpg content pack to be loaded — it registers the AutonomySystem, ScheduleSystem, and NeedDecaySystem. Entities must have both NeedsComponent and GoalsComponent attached to be processed by the autonomy loop.
Tick Budget Tuning¶
The system uses built-in budget governors that require no configuration:
| Parameter | Default | Description |
|---|---|---|
| Soft ceiling | 4.0 ms | NPCs may be deferred when exceeded |
| Hard ceiling | 5.0 ms | Processing stops for the tick |
| Recovery ticks | 10 | Ticks under budget before demotions clear |
| Per-NPC timeout | 2 ms | Maximum time for a single NPC's decision |
| Max failures | 3 | Consecutive failures before demotion |
LLM Queue Settings¶
The off-tick LLM queue has sensible defaults:
| Parameter | Default | Description |
|---|---|---|
| Max queued | 50 | Maximum pending LLM requests |
| Max results | 200 | Maximum buffered results |
| Max QPS | 10.0 | Rate limit for LLM calls |
| Per-call timeout | 2.0 s | Timeout for individual LLM calls |
These are class-level constants on OffTickLLMQueue. To customize, subclass and override.
Components¶
NeedsComponent¶
Tracks an NPC's current needs and emotional state.
from maid_stdlib.models.npc.autonomy import (
Need, NeedCategory, NeedsComponent,
)
npc.add(NeedsComponent(
needs={
NeedCategory.SURVIVAL: Need(
category=NeedCategory.SURVIVAL,
value=0.8, # 0.0 = desperate, 1.0 = fully satisfied
decay_rate=0.01, # How fast the need depletes per elapsed game hour
),
NeedCategory.ECONOMIC: Need(
category=NeedCategory.ECONOMIC,
value=0.6,
decay_rate=0.005,
),
NeedCategory.PURPOSE: Need(
category=NeedCategory.PURPOSE,
value=0.7,
decay_rate=0.008,
),
NeedCategory.COMFORT: Need(
category=NeedCategory.COMFORT,
value=0.9,
decay_rate=0.003,
),
},
personality_weights={
NeedCategory.SURVIVAL: 1.0, # How much this NPC cares about survival
NeedCategory.ECONOMIC: 1.5, # A greedy merchant cares more about gold
NeedCategory.PURPOSE: 0.8,
NeedCategory.COMFORT: 0.5,
},
mood=0.5, # Overall mood (0.0–1.0)
stress=0.0, # Stress level (0.0–1.0)
))
Need categories:
| Category | Drives |
|---|---|
SURVIVAL |
Finding food, supplies, shelter |
ECONOMIC |
Earning money, trading |
PURPOSE |
Meaningful work, duty fulfillment |
COMFORT |
Rest, socializing, relaxation |
When a need drops below 0.3, the GoalGenerator automatically creates a reactive goal to address it.
GoalsComponent¶
Tracks the NPC's active goals and history.
from maid_stdlib.models.npc.autonomy import GoalsComponent
npc.add(GoalsComponent(
active_goals=[], # Populated automatically by GoalGenerator
completed_goals=[], # UUIDs of completed goals (last 20 kept)
failed_goals=[], # UUIDs of failed goals
))
The GoalsComponent model validator sorts active_goals by priority and truncates to 5 entries, and keeps only the last 20 completed_goals. It does not touch failed_goals. The Classic RPG GoalLifecycleManager.add_goal() handles replacement logic: when active goals are at capacity, it replaces the lowest-priority one. GoalLifecycleManager.update_goals() also caps failed_goals at 50 entries to prevent unbounded growth.
Note: The
goal_generation_cooldownfield onGoalsComponent(default0.0) tracks when goals were last generated, acting as a cooldown timer to prevent goal churn. However,AutonomySystemdoes not read this field; it instead uses a private wall-clock timestamp (_last_goal_generation_time) with a 60-second cooldown constant (GOAL_COOLDOWN_SECONDS). Goal generation triggers when an NPC has fewer than 2 active goals (GOAL_GENERATION_THRESHOLD) and the cooldown has elapsed.
ScheduleComponent¶
Defines the NPC's daily routine as time blocks.
from maid_stdlib.models.npc.autonomy import (
ActivityType, ScheduleBlock, ScheduleComponent,
)
npc.add(ScheduleComponent(
blocks=[
ScheduleBlock(
start_hour=6,
end_hour=12,
activity=ActivityType.WORK,
location=marketplace_id, # UUID — triggers NavigationIntent for movement
priority=0.8,
),
ScheduleBlock(
start_hour=12,
end_hour=13,
activity=ActivityType.EAT,
location=tavern_id,
priority=0.9,
),
ScheduleBlock(
start_hour=13,
end_hour=18,
activity=ActivityType.TRADE,
location=marketplace_id,
priority=0.7,
),
ScheduleBlock(
start_hour=18,
end_hour=21,
activity=ActivityType.SOCIALIZE,
location=tavern_id,
priority=0.5,
),
ScheduleBlock(
start_hour=22,
end_hour=6,
activity=ActivityType.SLEEP,
location=home_id,
priority=1.0,
),
],
schedule_adherence=0.8, # How strictly NPC follows schedule (0.0–1.0)
))
Activity types: WORK, SLEEP, EAT, SOCIALIZE, PATROL, GUARD, TRADE, CRAFT, WORSHIP, TRAIN, WANDER, CUSTOM
Location field: The
locationaccepts either aUUIDor a string. When the location is aUUIDand differs from the NPC's current room, theScheduleSystemadds aNavigationIntentcomponent to trigger movement. String locations only update the activity state without triggering navigation.
The ScheduleSystem listens to time-change events and updates each NPC's current_activity. The UtilityScorer applies a schedule alignment bonus (0.5 * schedule_adherence) to actions that match the current activity.
Goal Generation¶
The GoalGenerator creates goals from three sources:
1. Need-Derived Goals¶
When a need value drops below 0.3, a goal is automatically created:
| Need | Generated Goal | Category |
|---|---|---|
| Survival < 0.3 | "Find food and supplies." | ACQUIRE |
| Economic < 0.3 | "Earn coin for stability." | ACQUIRE |
| Purpose < 0.3 | "Pursue meaningful work." | DUTY |
| Comfort < 0.3 | "Seek comfort and rest." | SOCIAL |
Priority scales inversely with need value — more desperate needs create higher-priority goals.
2. Memory-Reactive Goals¶
When an NPC has high-importance memories (importance > 0.7), reactive goals are created. The goal description is taken from memory["summary"], falling back to "React to recent memory" if no summary exists. Goal category is determined by the memory's valence:
- Negative valence (< 0) →
REVENGEgoal - Non-negative valence →
SOCIALgoal
Relationship data can override the category: if the memory target is in the NPC's friends list with trust > 0.5, the goal becomes SOCIAL; if the target is in rivals, it becomes REVENGE.
3. Relationship-Informed Goals¶
The generator inspects the NPC's relationships independently of memories:
- High-trust friends (trust > 0.6) → SOCIAL goals with description
"Assist trusted ally {id}."(truncated UUID) - Rivals → REVENGE goals with description
"Confront rival {id}."(truncated UUID)
Cooldown¶
After goal generation runs, a 60-second cooldown prevents excessive churn. New goals won't be generated for an NPC until the cooldown expires.
Goal Categories¶
| Category | Description | Aligned Actions |
|---|---|---|
ACQUIRE |
Obtain items or resources | move_to, sell_item, pick_up_item |
CRAFT |
Create items | craft_item |
SOCIAL |
Social interactions | socialize, gossip |
PROTECT |
Defend people or places | guard, patrol |
EXPLORE |
Discover new areas | move_to, investigate, open_door |
REVENGE |
Confront or pursue enemies | investigate, guard |
AMBITION |
Advance position or wealth | craft_item, sell_item |
DUTY |
Fulfill obligations | patrol, guard |
Goal Lifecycle¶
Goals progress through states managed by GoalLifecycleManager:
Progress Tracking¶
Each goal can have conditions with predicates that evaluate completion:
from maid_stdlib.models.npc.autonomy import (
Goal, GoalCategory, GoalCondition, GoalSource,
HasItemPredicate, LocationPredicate,
RelationshipThresholdPredicate, CurrencyThresholdPredicate,
)
goal = Goal(
category=GoalCategory.ACQUIRE,
description="Deliver the sword to the captain.",
priority=0.8,
source=GoalSource.QUEST,
conditions=[
GoalCondition(
predicate=HasItemPredicate(item_id=sword_uuid),
description="Have the sword in inventory",
),
GoalCondition(
predicate=LocationPredicate(room_id=barracks_uuid),
description="Be at the barracks",
),
],
deadline=10000, # Fail if not completed by tick 10000
)
Built-in Predicates¶
| Predicate | Checks |
|---|---|
HasItemPredicate(item_id) |
Item is in NPC's inventory |
LocationPredicate(room_id) |
NPC is in the specified room |
RelationshipThresholdPredicate(other_id, dimension, minimum) |
Relationship dimension meets threshold |
CurrencyThresholdPredicate(amount) |
NPC has at least this much currency |
Custom Predicates¶
Implement the GoalPredicate protocol:
from uuid import UUID
from maid_engine.core.world import World
from maid_stdlib.components.core import HealthComponent
class HealthAbovePredicate:
"""Goal is satisfied when NPC health exceeds threshold."""
def __init__(self, threshold: float) -> None:
self.threshold = threshold
def evaluate(self, world: World, npc_id: UUID) -> bool:
entity = world.get_entity(npc_id)
if entity is None:
return False
health = entity.try_get(HealthComponent)
return health is not None and health.current >= self.threshold
def progress_estimate(self, world: World, npc_id: UUID) -> float:
entity = world.get_entity(npc_id)
if entity is None:
return 0.0
health = entity.try_get(HealthComponent)
if health is None:
return 0.0
return min(1.0, health.current / self.threshold)
Completion and Failure¶
- Completion: All conditions evaluate to
True→ goal moves tocompleted_goals, emitsNPCGoalCompletedEvent - Deadline failure: Current tick exceeds
goal.deadline→ goal moves tofailed_goals, emitsNPCGoalFailedEvent - Capacity overflow: When 5 goals are active and a higher-priority goal arrives, the lowest-priority goal is dropped
LLM Integration¶
The OffTickLLMQueue enables NPCs to make LLM-powered decisions without blocking the game tick loop.
How It Works¶
- During a tick, the autonomy system identifies situations needing richer narrative (e.g., NPC witnessed a player event)
- An LLM request is enqueued with a priority and handler function
- A background task drains the queue between ticks, respecting rate limits
- Results are polled at the start of the next tick and applied to NPC state
Priority Levels¶
| Priority | When Used |
|---|---|
PLAYER_WITNESSED (0) |
A player is present and will see the result |
STORY_SIGNAL (1) |
Relevant to an active story arc |
FLAVOR (2) |
Background atmosphere and flavor text |
Circuit Breaker¶
The system includes automatic circuit-breaker protection:
- After consecutive LLM failures, the breaker opens and drops requests
- This prevents cascading failures from overwhelming the LLM provider
- The breaker automatically recovers after a cooldown period
Enqueuing Requests¶
Content packs can enqueue custom LLM requests:
from maid_engine.ai.providers.base import Message, CompletionOptions
llm_queue = world.get_data("_llm_queue")
if llm_queue is not None:
async def my_handler() -> str:
# LLMProvider.complete() accepts a list of Messages and CompletionOptions
result = await provider.complete(
[Message.user("What should the NPC say?")],
CompletionOptions(max_tokens=200),
)
return result.content
request_id = llm_queue.enqueue(
my_handler,
player_witnessed=True,
metadata={"npc_id": str(npc.id), "context": "greeting"},
)
Examples¶
Basic Autonomous Merchant¶
from maid_engine.core.ecs import Entity
from maid_stdlib.components.core import (
DescriptionComponent, NPCComponent, PositionComponent,
)
from maid_stdlib.models.npc.autonomy import (
ActivityType, Need, NeedCategory, NeedsComponent,
GoalsComponent, ScheduleBlock, ScheduleComponent,
)
merchant = Entity()
merchant.add_tag("npc")
merchant.add(DescriptionComponent(
name="Elara",
short_desc="A shrewd elven merchant",
keywords=["merchant", "elf", "elara"],
))
merchant.add(NPCComponent())
merchant.add(PositionComponent(room_id=marketplace_id))
# Needs: Elara prioritizes economic needs
merchant.add(NeedsComponent(
needs={
NeedCategory.SURVIVAL: Need(NeedCategory.SURVIVAL, value=0.9, decay_rate=0.005),
NeedCategory.ECONOMIC: Need(NeedCategory.ECONOMIC, value=0.7, decay_rate=0.015),
NeedCategory.PURPOSE: Need(NeedCategory.PURPOSE, value=0.8, decay_rate=0.008),
NeedCategory.COMFORT: Need(NeedCategory.COMFORT, value=0.6, decay_rate=0.01),
},
personality_weights={
NeedCategory.SURVIVAL: 0.8,
NeedCategory.ECONOMIC: 1.8, # Strongly motivated by profit
NeedCategory.PURPOSE: 1.0,
NeedCategory.COMFORT: 0.6,
},
mood=0.7,
))
# Goals: Start empty, will be auto-generated
merchant.add(GoalsComponent())
# Schedule: Work at market during the day, socialize in evenings
merchant.add(ScheduleComponent(
blocks=[
ScheduleBlock(6, 8, ActivityType.EAT, "tavern", 0.7),
ScheduleBlock(8, 17, ActivityType.TRADE, "marketplace", 0.9),
ScheduleBlock(17, 20, ActivityType.SOCIALIZE, "tavern", 0.6),
ScheduleBlock(21, 6, ActivityType.SLEEP, "elara_home", 1.0),
],
schedule_adherence=0.85,
))
world.add_entity(merchant)
Guard with Patrol Schedule¶
guard = Entity()
guard.add_tag("npc")
guard.add_tag("brave") # Personality tag affects utility scoring
guard.add(DescriptionComponent(
name="Captain Roderick",
short_desc="A vigilant town guard captain",
keywords=["guard", "captain", "roderick"],
))
guard.add(NPCComponent())
guard.add(PositionComponent(room_id=gatehouse_id))
guard.add(NeedsComponent(
needs={
NeedCategory.SURVIVAL: Need(NeedCategory.SURVIVAL, value=0.9, decay_rate=0.003),
NeedCategory.ECONOMIC: Need(NeedCategory.ECONOMIC, value=0.8, decay_rate=0.002),
NeedCategory.PURPOSE: Need(NeedCategory.PURPOSE, value=0.5, decay_rate=0.02),
NeedCategory.COMFORT: Need(NeedCategory.COMFORT, value=0.7, decay_rate=0.005),
},
personality_weights={
NeedCategory.SURVIVAL: 1.2,
NeedCategory.ECONOMIC: 0.5,
NeedCategory.PURPOSE: 2.0, # Strongly duty-driven
NeedCategory.COMFORT: 0.3,
},
))
guard.add(GoalsComponent())
guard.add(ScheduleComponent(
blocks=[
ScheduleBlock(6, 14, ActivityType.PATROL, "town_square", 0.9),
ScheduleBlock(14, 16, ActivityType.EAT, "barracks_mess", 0.7),
ScheduleBlock(16, 22, ActivityType.GUARD, "gatehouse", 0.95),
ScheduleBlock(22, 6, ActivityType.SLEEP, "barracks", 1.0),
],
schedule_adherence=0.95, # Very disciplined
))
world.add_entity(guard)
Adding Goals via Content Data¶
Goals can also be pre-seeded for quest-driven NPCs:
from maid_stdlib.models.npc.autonomy import (
Goal, GoalCategory, GoalCondition, GoalSource,
HasItemPredicate,
)
quest_giver = Entity()
# ... add standard components ...
quest_giver.add(GoalsComponent(
active_goals=[
Goal(
category=GoalCategory.ACQUIRE,
description="Collect rare herbs from the forest.",
priority=0.7,
source=GoalSource.INNATE,
conditions=[
GoalCondition(
predicate=HasItemPredicate(item_id=rare_herb_id),
description="Have the moonpetal herb",
),
],
),
],
))
Debugging¶
The debug_brain Command¶
Use debug_brain <npc_name> in-game (requires BUILDER access) to inspect an NPC's autonomy state:
> debug_brain elara
NPC Brain: 7a3f2c1e-...
Tier: nearby
Mood: 0.70 (positive)
Needs:
survival ████████████████···· 0.82 decay=0.01 weight=0.80
economic ██████████·········· 0.48 decay=0.02 weight=1.80 URGENT
purpose ████████████████···· 0.78 decay=0.01 weight=1.00
comfort ████████████········ 0.59 decay=0.01 weight=0.60
Current action: navigation
Schedule: activity=trade adherence=0.85
Top utility scores:
sell_item 0.847
move_to 0.623
socialize 0.412
rest 0.201
Goals:
acquire progress=0.30 priority=0.90
social progress=0.00 priority=0.50
Errors: 0 Demoted: False
Reading the Output¶
| Field | Meaning |
|---|---|
| Tier | Processing frequency: active (every tick), nearby (every 10 ticks), background (every 60 ticks) |
| Mood | Aggregate emotional state (positive > 0.7, neutral 0.4–0.7, strained < 0.4) |
| Needs | Bar chart + values. "URGENT" appears when value < 0.25 |
| Current action | What the NPC is doing right now (idle, navigation, social) |
| Schedule | Current activity from schedule and adherence score |
| Top utility scores | Ranked candidate actions from last evaluation |
| Goals | Active goals with progress and priority |
| Errors/Demoted | Failure count and whether the NPC is budget-demoted |
Troubleshooting¶
NPC is idle / not doing anything:
- Verify the NPC has both
NeedsComponentandGoalsComponent— the system only processes entities with both - Ensure the
AutonomySystemis registered (loaded via maid-classic-rpg content pack) - Note: the
"npc"tag is not required forAutonomySystemprocessing (it only requires both components), but thedebug_braincommand uses it for entity lookup - Look for
Errors > 0in debug_brain — repeated failures cause demotion
NPC is demoted:
- Demotion means the NPC repeatedly failed or exceeded tick budget
- After 10 consecutive ticks under the soft ceiling, demotions automatically clear
- Fix the root cause (broken predicates, missing components) rather than restarting
Goals aren't being generated:
- Goals are only generated every 60 seconds (wall-clock cooldown on
AutonomySystem.GOAL_COOLDOWN_SECONDS) - Generation only triggers when active goals drop below 2 (
GOAL_GENERATION_THRESHOLD) - Verify needs have dropped below 0.3 (the threshold for reactive goals)
- Memory/relationship providers must be registered for memory/social goals
LLM requests are being dropped:
- Check circuit breaker state — if open, requests are dropped until recovery
- Verify queue isn't full (default max: 50 pending requests)
- Check your AI provider configuration and API key settings
Events¶
The autonomy system emits events you can subscribe to:
| Event | When |
|---|---|
NPCGoalCreatedEvent |
A new goal is added to an NPC |
NPCGoalCompletedEvent |
A goal's conditions are all satisfied |
NPCGoalFailedEvent |
A goal's deadline expired |
NPCActionEvent |
An NPC executes an action |
NPCActivityChangedEvent |
Schedule activity changes |
NPCMoodChangedEvent |
NPC mood changed significantly |
NPCBarkEvent |
NPC produces ambient dialogue |
NarrativeReadyEvent |
LLM narrative result is ready |
TickBudgetExceededEvent |
Autonomy processing exceeded soft ceiling |
ArchetypeCircuitBreakerEvent |
An NPC archetype hit failure threshold |
NPCSocialInteractionEvent |
NPC engaged in a social interaction |
StorySignalEvent |
Story signal detection produced a signal |
Note: This list covers the primary events. Additional events exist for specific action types (e.g.,
NPCCraftRequestEvent,NPCPickedUpItemEvent,ActionCompletedEvent,ActionFailedEvent). Seemaid_stdlib.events.autonomyfor the full set.
from maid_stdlib.events.autonomy import NPCGoalCompletedEvent
async def on_goal_complete(event: NPCGoalCompletedEvent) -> None:
print(f"NPC {event.npc_id} completed goal: {event.goal_category}")
# In a System or ContentPack on_load, subscribe via the world's event bus:
self.events.subscribe(NPCGoalCompletedEvent, on_goal_complete)
Further Reading¶
- NPC Dialogue Guide — AI-powered conversations with NPCs
- Creating NPCs — General NPC creation guide
- ECS Overview — Understanding entities and components
- Events Overview — Working with the event system