Skip to content
WaifuStack
Go back

World-Building with Dynamic Lorebooks for AI Roleplay

Your AI character knows her own name. She knows she’s a tsundere. But does she know the name of the bar she works at? The history of the guild she belongs to? The inside joke from three weeks ago that only matters when someone mentions “that umbrella”?

This is the lorebook problem. You can’t cram an entire world into a system prompt — context windows have limits, and most world details are irrelevant most of the time. What you need is a system that injects the right lore at the right moment, triggered by what’s actually happening in the conversation.

Here’s how we built that system in Suzune.

Table of contents

Open Table of contents

What’s a Lorebook (And Why You Need One)

A lorebook is a collection of world-building entries that get injected into the AI’s context when relevant keywords appear in the conversation. Think of it like a reference book the AI can consult — except instead of the AI deciding when to look things up, you define the triggers.

Without lorebooks, you have two bad options:

ApproachProblem
Stuff everything into the system promptBlows your token budget. 90% of lore is irrelevant at any given moment
Include nothingThe character has amnesia about her own world

Lorebooks give you a third option: context on demand. The user mentions “ARCADIA” and suddenly the AI knows the full game world. They mention “Tanaka-san” and the AI knows that’s their coworker who hates mornings. The context arrives exactly when it’s needed.

If you’ve read our piece on prompt engineering for immersive roleplay, you know that keeping the context window focused is critical. Lorebooks are how we do that at the world-building layer.


The LorebookEntry Data Model

Every lorebook entry in Suzune is defined as a JSON object. Here’s the core structure:

{
  "keys": ["ARCADIA", "Arcadia Online"],
  "secondary_keys": ["game", "quest", "guild"],
  "content": "ARCADIA ONLINE is a VRMMORPG that launched in 2031. The world consists of five floating islands connected by sky bridges. Players join guilds and take on quests ranging from dungeon raids to political intrigue between island factions. {{char}} plays as a healer class named 'Luminara' and is part of the Starfall Guild.",
  "selective": true,
  "constant": false,
  "priority": 50,
  "enabled": true,
  "case_sensitive": false,
  "probability": 100
}

Let’s break down what each field does:

keys — Primary trigger keywords. If any of these appear in recent messages, the entry is a candidate for injection.

secondary_keys — Used when selective is true. At least one primary key AND one secondary key must match. This is AND-matching — it prevents “ARCADIA” from triggering every time someone uses the word casually.

selective — Toggles AND vs OR matching. When false, any primary key match is enough. When true, you need a hit from both keys and secondary_keys.

constant — When true, this entry is always injected, regardless of keywords. We use this for critical lore that should never be forgotten (more on this below).

priority — Lower numbers mean higher priority. When the token budget runs tight, low-priority entries get dropped first.

probability — A value from 0-100 representing the percentage chance the entry fires when its keywords match. At 100, it always fires. At 50, it’s a coin flip. This is great for flavor text you don’t want appearing every single time.


AND/OR Matching: Getting Triggers Right

The selective flag is one of the most important design decisions in the system. Here’s why.

Say you have an entry about your character’s workplace — a café called “Sakura Beans.” Without selective matching, any mention of the word “beans” could trigger an entire paragraph about the café. With selective matching, you can require both a primary key (“Sakura Beans”, “the café”) AND a secondary key (“work”, “shift”, “Tanaka”) before the entry fires.

{
  "keys": ["Sakura Beans", "the café", "the cafe"],
  "secondary_keys": ["work", "shift", "manager", "Tanaka", "apron"],
  "content": "Sakura Beans is a cozy café in Shimokitazawa where {{char}} works part-time as a barista. Her shift is usually 10AM-4PM. The manager, Tanaka-san, is strict but fair. {{char}} secretly adds extra foam art for customers she likes.",
  "selective": true,
  "priority": 40
}

This prevents false positives while still catching the conversation when it’s genuinely about the workplace. In practice, about 60% of our lorebook entries use selective matching. The remaining 40% — character names, unique proper nouns — are fine with simple OR matching because they’re distinctive enough to not cause false triggers.


The Scan-and-Select Algorithm

Here’s the pseudocode for how Suzune decides which lorebook entries to inject:

def scan_and_select(recent_messages: list[str], lorebook: list[LorebookEntry], token_budget: int) -> list[str]:
    """Scan recent messages for keyword matches and select entries within budget."""
    
    candidates = []
    text = " ".join(recent_messages).lower()
    
    for entry in lorebook:
        if not entry.enabled:
            continue
        
        # Check timed effects
        if entry.is_on_cooldown():
            continue
        if entry.delay and entry.messages_since_added < entry.delay:
            continue
            
        # Constant entries always qualify
        if entry.constant:
            candidates.append(entry)
            continue
        
        # Check primary key match
        primary_match = any(key.lower() in text for key in entry.keys)
        if not primary_match:
            continue
        
        # AND matching: require secondary key too
        if entry.selective:
            secondary_match = any(key.lower() in text for key in entry.secondary_keys)
            if not secondary_match:
                continue
        
        # Probability roll
        if entry.probability < 100:
            if random.randint(1, 100) > entry.probability:
                continue
        
        candidates.append(entry)
    
    # Sort by priority (lower = higher priority)
    candidates.sort(key=lambda e: e.priority)
    
    # Fill within token budget
    selected = []
    total_chars = 0
    for entry in candidates:
        if total_chars + len(entry.content) > token_budget:
            break
        selected.append(entry.content)
        total_chars += len(entry.content)
        
        # Activate sticky timer if present
        if entry.sticky:
            entry.activate_sticky()
    
    return selected

The key insight is the priority sort before budget trimming. When you’re over budget, you want to drop flavor text about the weather, not the entry that explains why the character is crying right now. We assign priority ranges by category:

Priority RangeCategoryExample
0-19Critical contextCharacter’s current emotional state
20-39Active storylinesOngoing quest details
40-59Location/settingWorkplace, school, game world
60-79Background NPCsCoworker personality, friend backstory
80-100Flavor/atmosphereWeather, time of day, ambient details

Timed Effects: Sticky, Cooldown, and Delay

Static keyword matching gets you 80% of the way. Timed effects get you the other 20%.

Sticky entries stay active for N turns after triggering, even if the keywords disappear from the conversation window. This is essential for scenes — if the user triggers a lorebook entry about a festival, you want that context to persist for the next several messages, not vanish the moment the keyword scrolls out of the scan window.

{
  "keys": ["festival", "summer festival", "matsuri"],
  "content": "The annual Tanabata festival is happening tonight. The shrine grounds are lit with paper lanterns, and stalls sell yakisoba and candied apples. {{char}} is wearing a blue yukata with a goldfish pattern.",
  "sticky": 8,
  "priority": 45
}

Cooldown entries become unavailable for N turns after firing. This prevents the same piece of lore from being injected over and over when a topic lingers in conversation. A character’s dramatic backstory reveal shouldn’t repeat every message.

Delay entries require N messages in the conversation before they can trigger. This is useful for entries that only make sense once the conversation has had time to develop — you wouldn’t want a deep emotional revelation firing on message two.

These three timers interact cleanly: a sticky entry with a cooldown will stay active for its sticky duration, then become unavailable for the cooldown duration. It creates a natural rhythm of lore appearing, persisting, then fading.


Constant Entries: Fighting Recency Bias

Some lore is too important to leave to keyword matching. In Suzune, constant entries are always injected into the context regardless of what’s being discussed.

We use these for:

Here’s the twist: we inject constant entries at the end of the prompt, right before the AI generates its response. This is deliberate. We found that DeepSeek and similar models have a strong recency bias — they pay more attention to text near the end of the context. By placing critical lore reminders at the end, we combat the model “forgetting” rules that were defined at the top of the system prompt.

def get_constant_entries(lorebook: list[LorebookEntry]) -> list[str]:
    """Get entries that should always be injected as end-of-prompt reminders."""
    constants = [e for e in lorebook if e.constant and e.enabled]
    constants.sort(key=lambda e: e.priority)
    return [e.content for e in constants]

This pattern is also discussed in our long-term memory architecture — constant lorebook entries work alongside chat summaries and character diaries to keep the AI grounded.


Organizing 27 Lorebook Files

Suzune currently uses 27 lorebook JSON files across multiple characters. Here’s how we keep that manageable.

Category-Based File Organization

lorebooks/
├── characters/
│   ├── mana_backstory.json
│   ├── mana_relationships.json
│   └── sakura_backstory.json
├── worlds/
│   ├── arcadia_online.json        # The VRMMORPG game world
│   ├── workplace_cafe.json        # Sakura Beans café
│   └── school_setting.json
├── shared/
│   ├── owner_persona.json         # Info about {{user}}
│   ├── glossary.json              # Shared terminology
│   └── timeline.json              # Major events timeline
└── dynamics/
    ├── relationship_stages.json   # How affection level changes behavior
    └── seasonal_events.json       # Holidays, festivals, etc.

Character-Lorebook Binding

Each character’s YAML config specifies which lorebooks they use:

name: mana
display_name: Mana
lorebooks:
  - owner_persona
  - glossary
  - mana_backstory
  - mana_relationships
  - arcadia_online
  - timeline

This is powerful because characters can share lorebooks. If Mana and Sakura both exist in the same world, they both reference owner_persona and glossary. Update the owner’s name in one file, and every character reflects the change. This pattern is similar to the modular character system we described in designing AI personalities with YAML — except applied to the world instead of the character.

Shared Timeline

One of our most useful shared lorebooks is the timeline — a chronological record of major events across all characters. When the user celebrated Christmas with Mana, that event gets added to the shared timeline. When Sakura references “the holidays,” she can acknowledge what happened without having been part of that conversation.

This creates the illusion of a living world where characters exist independently and are aware of each other’s storylines. It’s the same principle that makes good ensemble fiction work — the world feels bigger than any single character’s perspective.


Token Budget Management

Every lorebook injection costs tokens. Without a budget system, a conversation touching multiple topics could easily blow out the context window with lore entries.

Suzune’s approach is simple: we set a character count limit per injection pass. When candidates exceed the budget, the lowest-priority entries get dropped. In practice, our budget is tuned to about 10-15% of the total context window — enough to provide rich context without crowding out actual conversation history.

The priority system makes budget management mostly invisible to the end user. High-priority entries (emotional state, active plot points) always survive. Low-priority entries (ambient flavor) are the first to go. The conversation stays coherent even when the world is complex.


Practical Tips for Designing Lorebook Entries

After writing hundreds of lorebook entries, here’s what we’ve learned:

Keep entries self-contained. Each entry should make sense on its own. Don’t write Entry A assuming Entry B is also present — there’s no guarantee both will fire.

Write in the third person with template variables. Use {{char}} and {{user}} so entries work across characters. “{{char}} gets nervous around dogs” is better than “Mana gets nervous around dogs” if you might reuse the entry.

Front-load the important information. If the token budget cuts an entry short (which it shouldn’t, but defensive design), the critical details should be at the top.

Use selective matching aggressively. False positives are worse than false negatives. An irrelevant lorebook entry confuses the AI more than a missing one. When in doubt, add secondary keys.

Test with edge cases. Search for your keywords in common English phrases. “Keys: [‘ring’]” will trigger every time someone says “ringing phone” or “boxing ring.” Be specific: “engagement ring”, “the Ring of Starfall.”


Lorebooks vs. Platform Solutions

If you’re using a hosted platform like CandyAI or FantasyGF, you get some of this for free. These platforms offer built-in character memory and world-building tools that handle context injection behind the scenes. They’re a solid choice if you want rich characters without building the infrastructure yourself.

But if you’re building a custom bot — whether for personal use or as a product — a lorebook system gives you full control over exactly what context reaches the model and when. For our production architecture, that control is non-negotiable.


What’s Next

We’re working on a few extensions to the lorebook system:

The lorebook system is one of those features that keeps compounding in value. Every entry you add makes the world a little richer, a little more consistent. After a few months, you stop noticing the lorebook itself — you just notice that the characters seem to know things.

And that’s the whole point.


Building your own AI roleplay bot? Check out our full architecture overview for how lorebooks fit into Suzune’s broader system, or browse our platform comparison if you want to explore what’s already out there.


Share this post on:

Next Post
Architecture of a Production NSFW RP Bot: The Complete System Map