Skip to content

Automated Quest Generation

Overview

The Automated Quest Generation system creates dynamic, contextual quests from world events and NPC states. Rather than relying solely on hand-crafted content, the system observes what's happening in your game world and generates quests that feel organic and responsive to player actions.

Key capabilities:

  • Event-driven: Quests emerge from NPC needs, goals, threats, and player actions
  • LLM-enhanced narratives: AI generates quest text with template fallbacks
  • Quality controlled: Multi-stage validation prevents broken or repetitive quests
  • Chain-aware: Completed quests can trigger follow-up story arcs
  • Personalized delivery: Quests are matched to player play styles
  • Consequence propagation: Quest outcomes ripple through the world

Pipeline Overview

Quest generation follows a seven-stage pipeline:

StorySignal → SeedEvaluator → ArchetypeRegistry → QuestBuilder → NarrativeGenerator → QuestValidator → Delivery

1. Story Signals

A StorySignal (or QuestSignal) is a lightweight event emitted by world systems:

from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
from uuid import UUID

@dataclass(slots=True)
class QuestSignal:
    signal_type: str        # e.g., "npc_need", "npc_goal", "quest_chain"
    source_entity: UUID     # The NPC or entity that triggered the signal
    importance: float       # 0.0–1.0, how urgent/important this signal is
    context: dict[str, Any] = field(default_factory=dict)
    timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))

Signals are generated by:

  • NPC autonomy systems — when NPCs have unmet needs or blocked goals
  • World event systems — threats, resource crises, environmental changes
  • Quest completion — chain follow-ups from prior quest outcomes
  • Admin commands — manual @questgen generate triggers

Note: Signals flow into the system primarily via StorySignalEvent (subscribed during startup). The CompoundSeedComposer class exists in the codebase but is not called by QuestGenerationSystem during normal operation — it is available infrastructure for custom content packs that wish to batch multiple lightweight string signals into composite seeds externally.

2. Seed Evaluator

The SeedEvaluator converts raw signals into QuestSeed bundles using registered strategies:

  • NpcNeedEvaluator — selects the NPC's highest need via NeedsComponent.highest_need() (emits seeds with seed_type="npc_need")
  • NpcGoalEvaluator — detects NPC goals that require player assistance (emits seeds with seed_type="npc_goal")
  • ChainFollowupEvaluator — creates follow-up seeds from completed quests (emits seeds with seed_type="consequence_chain")

The evaluator applies shared filtering rules:

Filter Default Description
min_importance 0.4 Seeds below this threshold are discarded
min_involved_npcs 1 At least one NPC must be involved
cooldown_per_npc 3600s Minimum time between quests from same NPC
variety_window 5 Recent quest type diversity check window
max_active_seeds 10 Maximum seeds in the pipeline at once

The CompoundSeedComposer can also merge multiple related lightweight signals into composite seeds (e.g., two NPC need signals in the same region become a "regional crisis"). However, it is not invoked automatically by QuestGenerationSystem — it must be called explicitly by content pack code that collects and groups signals before injection.

3. Archetype Registry

The ArchetypeRegistry class provides a standalone registry for quest archetypes, but QuestGenerationSystem uses its own internal _archetype_list combined with the select_archetype() function. Use the public register_archetype() / register_archetypes() methods on the system to add archetypes at runtime.

@dataclass(slots=True)
class QuestArchetype:
    archetype_id: str
    name: str
    applicable_seed_types: list[str]   # Which seed types this archetype handles
    objective_patterns: list[str]       # e.g., ["fetch", "clear", "escort"]
    narrative_arcs: list[str]           # e.g., ["three_act", "mystery"]
    default_difficulty: float
    reward_scale: float
    priority: int = 0
    requires_combat: bool = False
    requires_social: bool = False
    requires_exploration: bool = False

The select_archetype() function picks the best archetype by matching seed_type against applicable_seed_types, then selecting the highest-priority candidate. When multiple candidates share the same top priority, one is chosen at random.

4. Quest Builder

The QuestBuilder and ObjectiveBuilder construct a GeneratedQuest from the seed and archetype. The ObjectiveBuilder supports these built-in patterns:

Pattern Description
fetch Retrieve an item and bring it back
clear Eliminate threats from an area
escort Protect an NPC during travel
investigate Gather clues and uncover information
deliver Transport items to a destination
negotiate Resolve a conflict through dialogue
craft_and_deliver Craft an item then deliver it
multi_stage Multiple sequential objectives
choice Branching objectives with player choice

5. Narrative Generator

The QuestNarrativeGenerator uses an LLM to produce:

  • Description and backstory — thematic quest text
  • Dialogue — NPC offer, progress, and completion lines

Note: The LLM narrative generation produces a description, backstory, and stakes (via QuestNarrativeModel). The quest title is set by the QuestBuilder during stage 4, not by the narrative generator.

When LLM is unavailable or the token budget is exhausted, the TemplateFallbackGenerator provides deterministic text using archetype-specific templates (threat_elimination, diplomatic_resolution, supply_chain, investigation, rescue_mission, faction_choice).

Token budget controls:

  • Token budget window: 3600 seconds (1 hour)
  • Token budget limit: 15,000 tokens per window
  • Max tokens per narrative: 500
  • Max tokens per dialogue: 400

6. Quest Validator

Two validation stages ensure quality:

QuestValidator — structural checks: - Quest giver entity exists in world - Objective target entities and rooms exist - Minimum NPC involvement met

QuestQualityFilter — anti-pattern detection: - duplicate_archetype — same archetype used too recently - npc_overuse — same NPC giving too many quests - region_saturation — too many active quests in one area - impossible_objective — objectives referencing invalid targets - trivial_reward — rewards too low for difficulty

Quality score thresholds (all 0.0–1.0):

Score Default Minimum Description
Grounding 0.7 References to real world entities
Coherence 0.5 Objective composition makes sense
Novelty 0.35 Different from recent quests

7. Delivery

The DeliveryPlanner selects how the quest reaches the player based on their PlayerProfile:

Play Style Delivery Method Description
Explorer DISCOVERY Player finds clues at a location
Social RUMOR Heard through NPC gossip
Combat/Balanced DIRECT_OFFER NPC approaches player directly

Other delivery methods include LETTER and NOTICE_BOARD.

Configuration

Quest generation settings are defined as module-level constants in maid_stdlib.systems.quests.generation.constants and as a QuestGenerationSettings Pydantic model in the same module. These are not wired into the main MAID_ environment variable settings hierarchy — they are plain MAIDBaseModel defaults used at code level.

Limitations: QuestGenerationSystem(world) accepts only a World argument — there is no settings override parameter. The QuestGenerationSettings Pydantic model exists for schema documentation but is not consumed by the system at runtime. All tuning values are imported as module-level constants before construction, so the system uses the values that were set at import time. To customize, mutate the constants before the system is instantiated (i.e., before the content pack calls get_systems()):

# Must run before QuestGenerationSystem is constructed
import maid_stdlib.systems.quests.generation.constants as qg_constants

qg_constants.MIN_IMPORTANCE = 0.3
qg_constants.MAX_ACTIVE_SEEDS = 20
qg_constants.TOKEN_BUDGET_LIMIT = 30_000

Reference for all available constants:

from maid_stdlib.systems.quests.generation.constants import (
    MIN_IMPORTANCE,          # 0.4 — minimum signal importance to generate
    MIN_INVOLVED_NPCS,       # 1 — minimum NPCs involved in a seed
    MAX_ACTIVE_SEEDS,        # 10 — max seeds in pipeline
    COOLDOWN_PER_NPC,        # 3600 — seconds between quests from same NPC
    VARIETY_WINDOW,          # 5 — type diversity check window
    MAX_PENDING_QUESTS,      # 10 — max quests awaiting delivery
    MAX_SEED_ATTEMPTS,       # 3 — retries before discarding a seed
    MAX_CONCURRENT_BUILDS,   # 3 — parallel generation tasks
    TOKEN_BUDGET_LIMIT,      # 15000 — LLM tokens per hour
    TOKEN_BUDGET_WINDOW,     # 3600.0 — budget window in seconds
    MAX_TOKENS_PER_NARRATIVE,# 500
    MAX_TOKENS_PER_DIALOGUE, # 400
    GOSSIP_CONFIDENCE_CAP,   # 0.9 — max confidence for gossip-sourced seeds
    MAX_ACTIVE_QUESTS_PER_REGION,  # 3
    MAX_PENDING_OFFERS_PER_PLAYER, # 2
    MAX_CHAIN_DEPTH,         # 5 — maximum chain follow-up depth
    CHAIN_DAMPENING,         # 0.7 — importance decay per chain level
    CONSEQUENCE_SIGNAL_BUDGET,     # 5 — max consequence signals per outcome
    GENERATION_INTERVAL_TICKS,     # 20 — ticks between generation cycles
    MAX_QUESTS_PER_CYCLE,    # 2 — max quests generated per cycle
    SIGNAL_DEDUP_WINDOW,     # 60.0 — seconds to dedup same signal
    GLOBAL_SIGNAL_MAX,       # 30 — max signals in 5-minute window
    GLOBAL_SIGNAL_WINDOW,    # 300.0 — 5-minute window for GLOBAL_SIGNAL_MAX
    QUEUE_SPIKE_THRESHOLD,   # 8 — spike detection threshold for queued seeds
    BUILD_TIMEOUT_SECONDS,   # 30.0 — timeout for a single quest build task
)

Note: Some constants (e.g., MIN_IMPORTANCE, MIN_INVOLVED_NPCS) are copied into class attributes on SeedEvaluator at import time. Mutating the module constant after the class has been imported will not affect the evaluator unless you also update SeedEvaluator.MIN_IMPORTANCE etc. directly.

The system uses the configured AI dialogue provider for LLM narrative generation:

# The narrative generator resolves the provider via settings.ai_dialogue.default_provider
# Override explicitly if you want a provider other than the configured default (chatjimmy):
MAID_AI_DIALOGUE_DEFAULT_PROVIDER=anthropic
MAID_AI_ANTHROPIC_API_KEY=sk-...

At startup, the system resolves a single provider name using this logic:

  1. If settings.ai_dialogue exists, use settings.ai_dialogue.default_provider.
  2. Otherwise, if settings.ai exists, use settings.ai.default_provider.
  3. Otherwise, default to "anthropic".

This is a one-time lookup — there is no retry or fallback between providers. If the resolved provider name is not found in the provider_registry, or no provider registry exists, the system silently runs without an LLM and uses template-based narrative generation for all quests.

Built-in Quest Seed Types

Seed Type Trigger Required Context Key
npc_need NPC's highest need value is elevated need
npc_goal NPC has a blocked goal requiring help goal
consequence_chain Previous quest outcome creates new situation parent_quest_id

The QuestSeedTypes class also defines threat_response, resource_crisis, mystery, goal_assistance, and consequence_chain as registered seed types in the SeedTypeRegistry, but the built-in evaluators emit only npc_need, npc_goal, and consequence_chain. Custom evaluators can emit any registered seed type.

Classic RPG Extensions: The maid-classic-rpg pack ships a parallel quest generation pipeline under maid_classic_rpg.systems.quests.generation that diverges from the stdlib implementation. Key differences:

  • NpcNeedEvaluator emits resource_crisis seeds (not npc_need) based on the NPC's highest need.
  • NpcGoalEvaluator emits goal_assistance seeds (not npc_goal) based on the NPC's current goal.
  • QuestSeedTypes defines 12 additional extension types: rivalry_intervention, faction_conflict, trade_mission, alliance_quest, discovery_expedition, justice, power_struggle, rescue, player_reputation, crafting_request, social_manipulation, and cold_case.
  • The Classic RPG constants.py mirrors the stdlib constants module but is a separate copy; changes to one do not affect the other.

If you are building a content pack that depends on maid-classic-rpg, use the Classic RPG evaluators and seed types. If you depend only on maid-stdlib, use the stdlib pipeline.

Compound seed types (created by CompoundSeedComposer when called manually):

Seed Type Trigger
regional_crisis 2+ NPC need signals in the same region
opportunity NPC goal aligns with a concurrent world event
player_driven 2+ player actions targeting the same entity

Admin Commands

The @questgen command provides administrative control:

@questgen status

Displays current pipeline state:

Quest Generation Status
pending=0.30 in_flight=0.10 token_pressure=0.45
tokens_used=3200 tokens_remaining=11800 builds_completed=7 builds_failed=1

@questgen generate <seed_type>

Force-generates a quest with maximum importance:

@questgen generate threat_response
> Queued forced seed a1b2c3d4-... (threat_response).

This bypasses the SeedEvaluator entirely and injects a seed directly into the pending queue via enqueue_seed(). The forced seed has primary_npc=None and empty involved_npcs, so it skips evaluator filters like min_involved_npcs. However, it can still fail at later pipeline stages: select_archetype() may find no matching archetype for the seed type, QuestBuilder may produce incomplete objectives without a quest giver, and QuestValidator may reject the result for missing entity references. Prefer providing NPC context via a StorySignalEvent for realistic test generation.

@questgen history

Shows recently generated quests:

Recent generated quests:
- quest_abc123 [threat_elimination] outcome=completed
- quest_def456 [diplomatic_resolution] outcome=None
- quest_ghi789 [supply_chain] outcome=abandoned

Events

The system emits events at each pipeline stage for observability and integration:

Event When
QuestSeedCreatedEvent Signal accepted as a valid seed
QuestGeneratedEvent Quest fully built and validated
QuestOfferedEvent Quest offered to a specific player
QuestDeliveredEvent Quest delivered via chosen method
QuestChainEvent Follow-up chain triggered
QuestExpiredInternalEvent Unaccepted quest expired

Subscribe to these in your content pack for custom behavior:

from maid_stdlib.events.quest_generation import QuestGeneratedEvent
from maid_engine.core.events import EventBus

async def on_quest_generated(event: QuestGeneratedEvent) -> None:
    logger.info(f"New quest: {event.quest_id} from {event.archetype_id}")

# In a System's startup() or ContentPack's on_load():
engine.world.events.subscribe(QuestGeneratedEvent, on_quest_generated)

Consequences

When quests complete (or fail), the ConsequenceSystem applies world mutations:

Consequence Type Effect
npc_goal_advance Progresses an NPC's goal state
npc_goal_complete Marks an NPC goal as achieved
npc_need_satisfy Satisfies an NPC need
relationship_change Alters NPC-player relationships
faction_standing Changes faction reputation
economic_change Modifies economy state
social_influence Shifts social dynamics
gossip_injection Adds knowledge to gossip network
memory_creation Creates NPC memories of events
world_state Modifies arbitrary world data

Mutations are applied atomically with rollback support via WorldMutationBatch.

Customization

Adding Custom Archetypes

Register archetypes in your content pack's on_load:

from maid_stdlib.systems.quests.generation.models import QuestArchetype
from maid_stdlib.systems.quests.generation.system import QuestGenerationSystem

async def on_load(self, engine: GameEngine) -> None:
    # Get the quest generation system
    quest_system = engine.world.systems.get(QuestGenerationSystem)
    if quest_system is None:
        return

    custom_archetype = QuestArchetype(
        archetype_id="bounty_hunt",
        name="Bounty Hunt",
        applicable_seed_types=["threat_response", "player_driven"],
        objective_patterns=["clear", "fetch"],
        narrative_arcs=["three_act", "escalation"],
        default_difficulty=0.7,
        reward_scale=1.5,
        priority=5,
        requires_combat=True,
    )
    quest_system.register_archetype(custom_archetype)

Adding Custom Seed Evaluators

Implement the SeedEvaluationStrategy protocol:

from datetime import UTC, datetime, timedelta
from uuid import UUID

from maid_engine.core.world import World
from maid_stdlib.systems.quests.generation.models import QuestSeed
from maid_stdlib.systems.quests.generation.seeds import SeedEvaluationStrategy
from maid_stdlib.systems.quests.generation.stubs import StorySignal
from maid_stdlib.systems.quests.generation.history import QuestHistory

class WeatherCrisisEvaluator:
    """Generates quests when severe weather threatens settlements."""

    async def evaluate(
        self,
        signal: StorySignal,
        world: World,
        quest_history: QuestHistory,
    ) -> QuestSeed | None:
        if signal.signal_type != "weather_crisis":
            return None

        now = datetime.now(UTC)
        return QuestSeed(
            source_signals=[signal.signal_type],
            seed_type="resource_crisis",
            importance=0.8,
            urgency=0.9,
            primary_npc=signal.source_entity,
            primary_npc_motivation="Protect the village from the storm",
            backstory="A terrible storm approaches the settlement.",
            stakes="Without preparation, lives will be lost.",
            location=None,
            threat_source="weather",
            evidence_location=None,
            involved_npcs=[signal.source_entity],
            context_keys={"need": "shelter"},
            created_at=now,
            expires_at=now + timedelta(hours=6),
        )

Register it during startup:

quest_system._seed_evaluator.register_strategy(WeatherCrisisEvaluator())

Adding Custom Seed Types

The SeedTypeRegistry tracks known seed type identifiers for internal bookkeeping. However, the pipeline does not consult the registry during seed ingestion, and @questgen status does not display registered types. Registering a custom type has no observable effect on pipeline behavior or admin output — the pipeline will process any seed regardless of registration. To actually handle a custom seed type, register a SeedEvaluationStrategy with the SeedEvaluator (see above) and ensure at least one archetype matches the seed type.

# Optional: register the type for internal tracking (no user-visible effect)
quest_system._seed_registry.register(
    "weather_crisis",
    "Triggered by severe weather events threatening settlements"
)

Custom Objective Builders

Register pattern-specific objective builders:

from maid_engine.core.world import World
from maid_stdlib.systems.quests.generation.builder import ObjectiveBuilder
from maid_stdlib.systems.quests.generation.models import QuestSeed
from maid_stdlib.systems.quests.generation.system import QuestGenerationSystem
from maid_stdlib.models.quest import QuestObjective

def build_patrol_objectives(seed: QuestSeed, world: World) -> list[QuestObjective]:
    """Build patrol route objectives."""
    # Create objectives for visiting multiple locations
    ...

quest_system._objective_builder.register("patrol", build_patrol_objectives)

Custom Narrative Templates

Extend the fallback generator with new archetype templates:

from string import Template

quest_system._fallback_generator._narrative_templates["bounty_hunt"] = [
    Template("A bounty has been placed: $stakes. $motivation drives the hunt."),
    Template("$backstory. The target must be found before $stakes escalates."),
]

Example: Quest Generation Flow

Here's how a quest emerges from world state:

  1. World event: The NPC autonomy system detects that blacksmith Gren has an unmet "materials" need at urgency 0.85.

  2. Signal emission: A StorySignalEvent fires with signal_type="npc_need", involved_npc_ids=[gren_id], importance=0.85.

  3. Seed evaluation: The NpcNeedEvaluator converts this to a QuestSeed with seed_type="npc_need", context_keys={"need": "economic"}.

  4. Archetype selection: The registry matches a "supply_chain" archetype with objective_patterns=["craft_and_deliver"].

  5. Quest building: The QuestBuilder creates objectives — fetch iron ore from the abandoned mine, deliver it to Gren.

  6. Narrative generation: The LLM (or fallback template) produces: "Critical supplies are missing: Gren's forge has gone cold. Recover iron from the old mine and restore the village's lifeline."

  7. Validation: The validator confirms the mine room exists, Gren's entity is valid, and no duplicate supply quests are active.

  8. Delivery: A combat-focused player receives a DIRECT_OFFER from Gren. An explorer might instead discover ore samples as DISCOVERY clues.

  9. Completion: When the player delivers the ore, the ConsequenceSystem satisfies Gren's need, improves the player's relationship with Gren, and potentially triggers a chain quest if Gren's next goal requires the forge.

Architecture Notes

  • ECS System: QuestGenerationSystem runs at priority 230; ConsequenceSystem at 240
  • Async builds: Quest generation runs as background tasks with configurable timeout (30s default)
  • Back-pressure: The system pauses signal intake when the build pipeline stalls
  • Spike detection: Throttles to 1 quest/cycle when 5+ signals arrive in 10 seconds
  • Deduplication: Same signal type + entity pair ignored within 60-second window
  • Token tracking: LLM usage tracked per-window with automatic fallback to templates when exhausted