Skip to content

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_cooldown field on GoalsComponent (default 0.0) tracks when goals were last generated, acting as a cooldown timer to prevent goal churn. However, AutonomySystem does 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 location accepts either a UUID or a string. When the location is a UUID and differs from the NPC's current room, the ScheduleSystem adds a NavigationIntent component 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) → REVENGE goal
  • Non-negative valenceSOCIAL goal

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:

[Created] → [Active] → [Completed]
                     ↘ [Failed]

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 to completed_goals, emits NPCGoalCompletedEvent
  • Deadline failure: Current tick exceeds goal.deadline → goal moves to failed_goals, emits NPCGoalFailedEvent
  • 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

  1. During a tick, the autonomy system identifies situations needing richer narrative (e.g., NPC witnessed a player event)
  2. An LLM request is enqueued with a priority and handler function
  3. A background task drains the queue between ticks, respecting rate limits
  4. 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 NeedsComponent and GoalsComponent — the system only processes entities with both
  • Ensure the AutonomySystem is registered (loaded via maid-classic-rpg content pack)
  • Note: the "npc" tag is not required for AutonomySystem processing (it only requires both components), but the debug_brain command uses it for entity lookup
  • Look for Errors > 0 in 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). See maid_stdlib.events.autonomy for 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