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¶
- Relationships are narrative facts that manifest as permissions — not stat buffs
- Implicit by default, explicit by choice — story choices drive changes; downtime accelerates
- Continuity density over mechanical surface area — every system increases narrative coherence
- 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:
- Canon persistence over novelty — the system's job is to remember correctly, not be clever
- Narrative state is first-class — threads, relationships, time, consequences are data, not vibes
- 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:
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¶
Existing: entity_links (Static Relationships)¶
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¶
Phase 1: Declared Facts (entity_links)¶
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_ofto link_type, createrelationship_statetable - [x] Add
relationship_changedto EventType - [x] Implement
get_relationship_tier,update_relationship_tierin DatabaseVault - [x] Implement
get_npc_relationshipsfor entity_links lookup
Phase 2: Tool + Context¶
- [x] Add
update_relationshiptool to agent - [x] Extend NPCContext with relationship fields
- [x] Update
_load_npcs_at_locationto fetch relationship data - [x] Update
format_fullto 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_relationshiptool works - [x] NPC↔NPC relationships display tested (Silas rival_of Aldric shows in context)
- [x] Context injection verified (two-layer display working)
Open Questions¶
- Should relationship_state support NPC↔NPC mutable tiers?
- Current design: Only PC↔NPC mutable, NPC↔NPC via static entity_links
-
Future: Could extend for faction simulation
-
Downtime mechanic design?
- How does "spend time" action work mechanically?
-
Separate tool or part of narrative flow?
-
Companion promotion flow?
- When tier reaches +3, what triggers companion conversion?
- 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 |