Skip to content

Palimpsest Relationship System Design

Status: Complete (All phases done, ready for live play) Author: Claude + Jake Date: 2026-01-04


Overview

This document defines the relationship system for Palimpsest, a solo TTRPG with an LLM Dungeon Master. The system tracks how NPCs feel about the player character and each other, enabling narrative consequences and mechanical permissions.

Design Principles

  1. Relationships are narrative facts that manifest as permissions — not stat buffs
  2. Implicit by default, explicit by choice — story choices drive changes; downtime accelerates
  3. Continuity density over mechanical surface area — every system increases narrative coherence
  4. Two sources of truth, clear semantics — baseline disposition vs PC-specific tier

What is Palimpsest?

A memory-first, continuity-driven tabletop RPG engine where an LLM acts as a persistent Dungeon Master.

Three distinguishing characteristics:

  1. Canon persistence over novelty — the system's job is to remember correctly, not be clever
  2. Narrative state is first-class — threads, relationships, time, consequences are data, not vibes
  3. The LLM is constrained, not unleashed — creativity exists inside a scaffold of identity and obligation

Closest analog: Wildermyth — procedural storytelling that bends systems toward intimacy, not spectacle.


Relationship Tiers

Scale: -3 to +3

Tier Label Narrative Effect Permissions Unlocked
-3 Enemy Actively hostile, may ambush Combat encounters likely
-2 Hostile Refuses to help, may obstruct No services/info available
-1 Wary Guarded, suspicious Disadvantage on social approaches
0 Neutral Standard NPC behavior Baseline interactions
+1 Friendly Helpful, shares rumors Minor discounts, extra info
+2 Trusted Proactive assistance Access to restricted info/places
+3 Bonded Deep connection, will sacrifice Companion promotion eligible

Key Insight: Permissions, Not Modifiers

Instead of "+2 to Persuasion," relationships unlock: - Access to new locations - New dialogue stances - New thread types - NPC-initiated help - Companion conversion

This keeps the LLM focused on story causality, not dice math.


Two-Layer Model

Layer 1: Baseline Disposition (Temperament)

Stored in entities.meta.disposition: - Represents NPC's personality / how they treat strangers - This is who they are, not how they feel about the PC - Rarely changes (only through major character development)

Value Meaning
welcoming Open, approachable, gives benefit of the doubt
professional Transactional, polite but guarded
cautious Reserved, needs to warm up
suspicious Distrustful, assumes bad intent
hostile Aggressive toward outsiders

Layer 2: PC-Specific Tier (Relationship)

Stored in relationship_state table: - Represents NPC's personal attitude toward the PC specifically - Values: -3 to +3 with derived labels - Changes through story actions and downtime

Tier Label Notes
-3 enemy Actively seeks harm
-2 hostile Opposes, obstructs
-1 wary Distrusts this person specifically
0 neutral No history
+1 friendly Likes, willing to help
+2 trusted Confides in, proactive support
+3 bonded Deep connection, will sacrifice

Why "bonded" over "intimate" or "devoted"? - Works for romantic partners, found family, blood oaths, deep friendship, mentor/protégé - Flexible without being euphemistic (inspired by Hades II)

Context Display

When both are relevant, show both:

Marlena (temperament: welcoming → toward Jake: +2 trusted)

Interesting Dynamics

The two layers create meaningful combinations:

Temperament PC Tier Narrative Meaning
suspicious +2 trusted PC broke through their walls — notable
welcoming -1 wary PC specifically burned this friendly NPC
hostile +1 friendly Mutual respect despite general aggression
cautious +3 bonded Deep bond with someone who doesn't open up easily

Schema Design

Already exists for declared facts (NPC↔NPC, PC↔familiar):

CREATE TABLE rpg.entity_links (
  campaign_id UUID NOT NULL,
  from_type rpg.entity_type NOT NULL,
  from_id TEXT NOT NULL,
  link_type rpg.link_type NOT NULL,  -- bonded_to, ally_of, enemy_of, rival_of, etc.
  to_type rpg.entity_type NOT NULL,
  to_id TEXT NOT NULL,
  data JSONB DEFAULT '{}'  -- reason, strength, notes
);

Use for: NPC rivalries, faction affiliations, family relations, companion bonds.

New: relationship_state (Mutable Tiers)

CREATE TABLE rpg.relationship_state (
  campaign_id UUID NOT NULL REFERENCES rpg.campaigns(id) ON DELETE CASCADE,

  a_type rpg.entity_type NOT NULL,
  a_id   TEXT NOT NULL,
  b_type rpg.entity_type NOT NULL,
  b_id   TEXT NOT NULL,

  tier   INT NOT NULL DEFAULT 0 CHECK (tier BETWEEN -3 AND 3),

  updated_at TIMESTAMPTZ DEFAULT now(),

  PRIMARY KEY (campaign_id, a_type, a_id, b_type, b_id),

  FOREIGN KEY (campaign_id, a_type, a_id)
    REFERENCES rpg.entities(campaign_id, entity_type, id) ON DELETE CASCADE,
  FOREIGN KEY (campaign_id, b_type, b_id)
    REFERENCES rpg.entities(campaign_id, entity_type, id) ON DELETE CASCADE
);

Why typed a/b fields? - Supports PC↔NPC and NPC↔NPC - Supports asymmetry (NPC likes PC, PC distrusts NPC) - Avoids string hacks like "pc:jake"

History: vault_events (Existing)

Relationship changes logged as vault events:

EventType = Literal[
    "pc_updated",
    "timeline_appended",
    "thread_added",
    "thread_resolved",
    "relationship_changed",  # NEW
]

Payload structure:

{
  "a_type": "pc",
  "a_id": "jake",
  "b_type": "npc",
  "b_id": "marlena",
  "old_tier": 1,
  "new_tier": 2,
  "reason": "Helped recover stolen cargo"
}

No separate history table needed — vault_events provides history for free.


Relationship Progression

Implicit (Default)

Story choices automatically create relationship deltas: - Saved NPC's life → +2 - Broke a promise → -1 - Shared a meaningful moment → +1 - Betrayed their trust → -2

The DM calls update_relationship as a side effect of narrative.

Explicit (Optional)

A "downtime / spend time" action: - Locks in progress - Allows reflection - Triggers relationship-history entries - Mirrors oral storytelling: "We spent a few evenings together after that"


Tool Design

update_relationship

def update_relationship(
    npc_id: str,
    delta: int,  # -2 to +2 typically
    reason: str,
) -> RelationshipResult:
    """
    Adjust PC's relationship with an NPC based on story events.

    Call when actions should shift how an NPC feels about the PC:
    - Saved their life: delta=+2
    - Broke a promise: delta=-1
    - Shared a meaningful moment: delta=+1
    - Betrayed their trust: delta=-2

    Returns new tier (-3 to +3) and label (enemy → devoted).
    Automatically logs to vault_events for history.
    """

DatabaseVault Methods

TIER_LABELS = {
    -3: "enemy",
    -2: "hostile",
    -1: "wary",
    0: "neutral",
    1: "friendly",
    2: "trusted",
    3: "bonded",
}

def get_relationship_tier(self, npc_id: str) -> tuple[int, str]:
    """Get PC's relationship tier with an NPC. Returns (tier, label)."""

def update_relationship_tier(
    self,
    npc_id: str,
    delta: int,
    reason: str
) -> tuple[int, str]:
    """Update relationship tier, clamped to [-3, +3]. Logs vault_event."""

def get_npc_relationships(self, npc_id: str) -> list[EntityLink]:
    """Get declared NPC↔NPC relationships via entity_links."""

Context Injection

Extended NPCContext

@dataclass
class NPCContext:
    id: str
    name: str
    role: str = ""
    status: str = ""
    disposition: str = ""  # Baseline
    location: str = ""
    personality: str = ""
    voice: str = ""
    # NEW: Relationship fields
    relationship_tier: int = 0
    relationship_label: str = ""
    relationship_reason: str = ""  # Most recent delta
    npc_relationships: list[str] = field(default_factory=list)

Output Format

### NPCs Present
- **Marlena** (tavern keeper) [active]
  Temperament: welcoming → Toward Jake: **trusted (+2)**
  *RP: proactive assistance; shares secrets freely*
  *Recent: "Helped recover stolen cargo"*
  *Relationships: Rival of Captain Voss (harbor master bid)*

Token Budget

To avoid prompt bloat: - Inject only entities present in scene + threads referenced - Include at most 6 relationship lines - Include at most 1 reason per relationship (most recent) - Deep history is RAG/tool lookup territory


NPC↔NPC Relationships

Cheap storage, queryable, no new table:

INSERT INTO rpg.entity_links (campaign_id, from_type, from_id, link_type, to_type, to_id, data)
VALUES (
  'campaign-uuid',
  'npc', 'marlena',
  'rival_of',
  'npc', 'captain-voss',
  '{"reason": "Competing for harbor master position"}'
);

Injected as context when both NPCs are present:

"Marlena and Captain Voss are rivals (harbor master bid)"

Phase 2: Simulated Dynamics (Future)

  • Faction tension
  • Cascading consequences
  • Off-screen world motion

Not MVP — requires significant cognitive overhead.


Implementation Phases

Phase 1: Schema + Vault Methods

  • [x] Migration 011: Add rival_of to link_type, create relationship_state table
  • [x] Add relationship_changed to EventType
  • [x] Implement get_relationship_tier, update_relationship_tier in DatabaseVault
  • [x] Implement get_npc_relationships for entity_links lookup

Phase 2: Tool + Context

  • [x] Add update_relationship tool to agent
  • [x] Extend NPCContext with relationship fields
  • [x] Update _load_npcs_at_location to fetch relationship data
  • [x] Update format_full to display relationship context

Phase 3: Testing

  • [x] Backfill Seagate campaign with relationship data
  • [x] Migrate temperament vocabulary (welcoming/professional/cautious/suspicious/hostile)
  • [x] Simulated gameplay test: verified update_relationship tool works
  • [x] NPC↔NPC relationships display tested (Silas rival_of Aldric shows in context)
  • [x] Context injection verified (two-layer display working)

Open Questions

  1. Should relationship_state support NPC↔NPC mutable tiers?
  2. Current design: Only PC↔NPC mutable, NPC↔NPC via static entity_links
  3. Future: Could extend for faction simulation

  4. Downtime mechanic design?

  5. How does "spend time" action work mechanically?
  6. Separate tool or part of narrative flow?

  7. Companion promotion flow?

  8. When tier reaches +3, what triggers companion conversion?
  9. Automatic offer or player-initiated?

Next Systems

This document covers Phase 1 of the Palimpsest roadmap. Subsequent phases build on relationships:

  • Phase 2: Narrative Clocks — Urgency and deadlines for threads
  • Phase 3: Fronts — Threat bundles with shared stakes
  • Phase 4: Downtime — Explicit phase for relationship investment and world advancement

See: palimpsest-roadmap.md for full phase definitions and acceptance criteria.


References

  • Wildermyth — Procedural storytelling with relationship-driven narrative
  • Clair Obscur: Expedition 33 — Relationship tiers unlocking gradient attacks
  • Fire Emblem Support System — C/B/A/S ranks with combat synergies
  • Persona Social Links — Time-limited relationship building with arcana bonuses

Appendix: Current Codebase Alignment

Component Status Location
entity_links table Exists 010_vault_to_database.sql:301
link_type enum Exists 010_vault_to_database.sql:83 (+ rival_of in 011)
vault_events Exists api/vault_events.py
ContextBuilder Exists api/context/builder.py
DISPOSITION_RP_CUES Exists api/context/builder.py:80
TIER_RP_CUES Exists api/context/builder.py:89
NPC disposition Exists Entity frontmatter
relationship_state Exists 011_relationship_state.sql
update_relationship tool Exists api/agent/tools.py:1312
get_npc_relationships tool Exists api/agent/tools.py:1374
DatabaseVault relationship methods Exists lib/db_vault.py:915