Phase 2: Narrative Clocks - Implementation Plan¶
Status: Approved Author: Claude + Jake Date: 2026-01-04 Depends On: Phase 1 (Relationships) - Complete
Problem Statement¶
Threads lack urgency. Nothing forces the world to move forward. Players can ignore consequences indefinitely.
Current behavior: - Threads have priority (high/medium/low) but no progression - No visible stakes or deadlines - World is static until PC acts - No pressure to address threats
Desired behavior: - Threads have visible progress toward resolution or failure - Ignoring a threat causes it to escalate - Racing against time creates tension - World changes even when PC is elsewhere
Design Goals¶
- Blades in the Dark clocks as inspiration - simple, visual, narrative
- DM discretion - clocks advance when narratively appropriate, not automatically
- Context injection - clock state visible in every scene
- Event-sourced - all clock changes logged with reasons
Key Clarification: Clocks ≠ Deadlines¶
Clocks are narrative pressure tracks, not real-time countdowns. "Time passage" is a narrative interpretation, not a timer. This prevents drift into calendar simulation.
Non-Goals¶
- Automatic time-based advancement (requires session tracking)
- Clock UI components (chat-only for now)
- Complex linked/racing clock mechanics (Phase 3+)
- Countdown timers (real-time)
Data Model¶
Schema Migration (012_narrative_clocks.sql)¶
-- Add clock fields to existing threads table
ALTER TABLE rpg.threads ADD COLUMN clock_segments INT CHECK (clock_segments IN (4, 6, 8));
ALTER TABLE rpg.threads ADD COLUMN clock_filled INT DEFAULT 0;
ALTER TABLE rpg.threads ADD COLUMN clock_kind TEXT CHECK (clock_kind IN ('threat', 'progress'));
ALTER TABLE rpg.threads ADD COLUMN if_filled TEXT; -- What happens when clock completes
ALTER TABLE rpg.threads ADD COLUMN if_ignored TEXT; -- What escalation looks like (threat clocks)
-- Constraint: clock_filled valid only when clock exists
ALTER TABLE rpg.threads ADD CONSTRAINT clock_filled_valid
CHECK (
(clock_segments IS NULL AND clock_filled = 0) OR
(clock_segments IS NOT NULL AND clock_filled BETWEEN 0 AND clock_segments)
);
-- Constraint: clock_kind required when clock exists
ALTER TABLE rpg.threads ADD CONSTRAINT clock_kind_required
CHECK (
(clock_segments IS NULL AND clock_kind IS NULL) OR
(clock_segments IS NOT NULL AND clock_kind IS NOT NULL)
);
-- Index for "active threads with clocks" query
CREATE INDEX idx_threads_active_clocks
ON rpg.threads (campaign_id, status)
WHERE clock_segments IS NOT NULL AND status = 'active';
Design decisions:
- Clock segments fixed to 4/6/8 (Blades convention) - prevents weird fractions
- clock_kind distinguishes threat vs progress clocks for tonal clarity
- Null clock_segments = no clock on this thread
- Phantom clock_filled prevented by constraint
- Clocks cleared on thread resolution (app logic)
Clock Kinds¶
| Kind | Fills When | Narrative Tone |
|---|---|---|
threat |
Ignored, time passes, antagonists act | Doom approaches |
progress |
PC makes headway, effort invested | Goal approaches |
Event Type Addition¶
# api/vault_events.py
EventType = Literal[
"pc_updated",
"timeline_appended",
"thread_added",
"thread_resolved",
"relationship_changed",
"clock_advanced", # NEW
]
Payload structure (uses thread_id for stability):
{
"thread_id": "550e8400-e29b-41d4-a716-446655440000",
"thread_name": "Find the missing shipment",
"clock_kind": "threat",
"old_filled": 3,
"new_filled": 5,
"clock_segments": 8,
"reason": "Another day passed without investigation",
"triggered_by": "time_passage"
}
Implementation¶
1. DatabaseVault Methods¶
# lib/db_vault.py
def set_thread_clock(
self,
thread_id: str,
clock_segments: int,
clock_kind: Literal["threat", "progress"],
filled: int = 0,
if_filled: Optional[str] = None,
if_ignored: Optional[str] = None,
) -> Thread:
"""Initialize or reset a clock on a thread."""
def advance_thread_clock(
self,
thread_id: str,
advance_by: int,
reason: str,
triggered_by: Optional[str] = None,
) -> tuple[Thread, bool]:
"""
Advance clock by advance_by segments.
Returns (updated_thread, did_complete).
Logs clock_advanced event.
Clamps to max (no overflow).
Raises if thread has no clock.
"""
def clear_thread_clock(self, thread_id: str) -> Thread:
"""Clear clock from thread (called on resolution)."""
Guardrails:
- advance_by validated: 1-3 range (3 logged as unusual)
- Advancement clamps to clock_segments (no overflow)
- Error if advancing thread without clock
2. Agent Tools¶
# api/agent/tools.py
class ClockResult(BaseModel):
thread_id: str
thread_name: str
clock_kind: str
old_filled: int
new_filled: int
clock_segments: int
completed: bool
if_filled: Optional[str] = None
message: str
def advance_clock(
vault: DatabaseVault,
thread_name: str,
advance_by: int,
reason: str,
triggered_by: Optional[str] = None,
) -> ClockResult:
"""
Advance a thread's clock by the specified number of segments.
Call when:
- Time passes and a threat grows (threat clock)
- PC actions make progress toward goal (progress clock)
- Off-screen forces act
Args:
thread_name: Name of the thread with a clock
advance_by: How many segments to fill (1-3, typically 1-2)
reason: Why the clock advanced
triggered_by: Optional category (time_passage, pc_action, antagonist_action)
Returns clock state and whether it completed.
When completed, consider resolving the thread or triggering if_filled.
Raises error if thread has no clock (use set_clock first).
"""
def set_clock(
vault: DatabaseVault,
thread_name: str,
clock_segments: int,
clock_kind: Literal["threat", "progress"],
filled: int = 0,
if_filled: Optional[str] = None,
if_ignored: Optional[str] = None,
) -> ClockResult:
"""
Initialize or reset a clock on a thread.
Call when:
- Creating a new threat with urgency
- Resetting a clock after partial resolution
- Converting an existing thread to have urgency
Args:
thread_name: Name of the thread
clock_segments: Total clock size (4, 6, or 8)
clock_kind: "threat" (fills when ignored) or "progress" (fills with effort)
filled: Starting fill (default 0)
if_filled: What happens when clock completes
if_ignored: What escalation looks like (for threat clocks)
Guidelines:
- Only set clocks on threads that actually matter
- Not every thread needs a clock - default is no clock
- 4 segments: imminent (this session)
- 6 segments: pressing (next few sessions)
- 8 segments: looming (campaign arc)
"""
3. Context Builder Updates¶
Extended ThreadContext¶
# api/context/builder.py
@dataclass
class ThreadContext:
id: str # UUID for tool calls
name: str
priority: str = "medium"
status: str = ""
description: str = ""
next_step: str = ""
# Clock fields
clock_segments: Optional[int] = None
clock_filled: int = 0
clock_kind: Optional[str] = None
if_filled: str = ""
if_ignored: str = ""
Clock Visualization¶
CLOCK_GLYPHS = {
4: ["○○○○", "●○○○", "●●○○", "●●●○", "●●●●"],
6: ["○○○○○○", "●○○○○○", "●●○○○○", "●●●○○○", "●●●●○○", "●●●●●○", "●●●●●●"],
8: ["○○○○○○○○", "●○○○○○○○", "●●○○○○○○", "●●●○○○○○", "●●●●○○○○",
"●●●●●○○○", "●●●●●●○○", "●●●●●●●○", "●●●●●●●●"],
}
def _render_clock(filled: int, segments: int) -> str:
"""Render clock as radial segments: ●●●○○○ 3/6"""
return f"{CLOCK_GLYPHS[segments][filled]} {filled}/{segments}"
def _urgency_label(filled: int, segments: int) -> str:
"""Presentational urgency based on fill ratio."""
ratio = filled / segments
if ratio >= 0.75:
return "CRITICAL"
elif ratio >= 0.5:
return "PRESSING"
return ""
Updated Format¶
### Active Threads
- **[URGENT]** Find the missing shipment **(CRITICAL)** ●●●●●○○○ 5/8 [threat]
→ Next: Check the warehouse district
→ If ignored: Cargo sold, trail goes cold
- **[URGENT]** Harbor patrol investigation ●●○○○○○○ 2/8 [threat]
→ Next: Bribe the dock master
- Earn Marlena's trust ●●●○○○ 3/6 [progress]
→ Next: Help with the supply run
- The Crimson Vow (no clock)
→ Next: Research the binding ritual
Context budget:
- Show if_ignored/if_filled only when clock is >=50% OR priority is high/urgent
- Urgency label (PRESSING/CRITICAL) is purely presentational - no extra state
4. DM System Prompt Updates¶
Add to api/agent/dm.py:
## NARRATIVE CLOCKS
Clocks track progress toward consequences. They create urgency without automation.
**CRITICAL RULE:** Clocks are advanced by tools only. Do not silently advance clocks in narration without calling `advance_clock`.
### Clock Kinds
- **threat**: Fills when ignored, time passes, antagonists act → doom approaches
- **progress**: Fills when PC invests effort → goal approaches
### When to Advance Clocks
- Time passes (downtime, travel, waiting) → threat clocks
- Off-screen forces act (antagonists don't wait) → threat clocks
- PC makes progress toward goal → progress clocks
- Partial success/failure → either kind
### Clock Sizes
- **4 segments**: Imminent (this session)
- **6 segments**: Pressing (next few sessions)
- **8 segments**: Looming (campaign arc)
### Advance Guidelines
- 1 segment: Minor time passage, small progress
- 2 segments: Significant event, major delay
- 3 segments: Rare, catastrophic failure or major time skip
### When Clocks Complete
- Trigger the `if_filled` consequence
- Consider resolving the thread (success or failure)
- Or: Reset clock and escalate stakes
### When NOT to Use Clocks
- Not every thread needs a clock
- Default is no clock - only add when urgency matters
- Avoid "clock spam" - too many clocks = nothing feels urgent
### Tool Usage
| Situation | Tool | Example |
|-----------|------|---------|
| New urgent threat | `set_clock` | Assassin contract: 6 segments, threat |
| PC working toward goal | `set_clock` | Earn trust: 6 segments, progress |
| Time passes | `advance_clock` | "Day passed" advance_by=1 |
| Failed attempt | `advance_clock` | "Alerted guards" advance_by=2 |
| Threat resolved | `resolve_thread` | (clears clock automatically) |
5. Thread Resolution¶
Update resolve_thread to clear clock:
def resolve_thread(self, thread_name: str, resolution: str) -> Thread:
"""Mark thread resolved. Clears any clock on the thread."""
# ... existing logic ...
# Clear clock fields
thread.clock_segments = None
thread.clock_filled = 0
thread.clock_kind = None
thread.if_filled = None
thread.if_ignored = None
# ... save and return ...
Testing Plan¶
1. Migration Verification¶
- Apply migration 012 to Supabase sandbox
- Verify constraints prevent: invalid clock_filled, phantom clocks, missing clock_kind
- Verify null clock_segments works for threads without clocks
2. Tool Testing (Testvale)¶
- Create thread with
add_thread - Initialize clock with
set_clock(both threat and progress) - Advance clock with
advance_clock - Verify completion detection
- Verify event logging with thread_id
- Verify
advance_clockon thread without clock returns helpful error
3. Edge Cases¶
- Advance beyond completion clamps correctly (no overflow)
- Advance with advance_by=3 logged as unusual
- Resolve thread clears clock
4. Interleaving Test¶
- Create two threads with clocks
- Advance both in same turn
- Verify event ordering is deterministic
- Verify context shows both updates correctly
5. Context Injection Testing¶
- Verify clock renders with radial glyphs
- Verify urgency labels appear at thresholds (50%, 75%)
- Verify
if_ignoredonly shows when >=50% or high priority - Verify threads without clocks display normally
6. Seagate Integration¶
- Add clock to ONE existing Seagate thread (user approval required)
- Play brief session to verify natural usage
- Confirm context injection works in real gameplay
Definition of Done¶
- [x] Migration 012 creates clock fields with all constraints
- [x]
advance_clocktool implemented with advance_by parameter - [x]
set_clocktool implemented with clock_kind parameter - [x] Event payloads use thread_id (+ clock_advanced event type)
- [x] Context builder shows radial clock visualization
- [x] Urgency labels (PRESSING/CRITICAL) in context
- [x] DM system prompt updated with clock guidance + "tools only" rule
- [x] Clock Review Model documented and implemented
- [x] Thread resolution clears clocks
- [x] Tested in Testvale sandbox
- [x] Seagate threads cleaned (markers removed, clocks added)
- [x] Documentation updated
- [ ] Tagged:
phase2-clocks
Resolved Questions¶
- Threshold warnings → Yes, purely presentational: PRESSING at 50%, CRITICAL at 75%
- Clock history in context → No, current state only (history via RAG if needed)
- Auto-resolve on completion → No, DM decides outcome
- Radial vs bar visualization → Radial (see below)
Clock Visualization¶
Chat Context (Text)¶
Decision: Radial circles (●●●○○○)
| Aspect | Radial ●●●○○○ | Bar [███░░░] |
|---|---|---|
| Blades fidelity | Approximates pie slices | Different metaphor |
| Token count | Same | Same |
| Readability | Clear segments | Clear fill |
| "Pie slice" feel | Yes - evokes clock face | No - linear progress |
The filled/empty circles (●○) are the best text approximation of Blades-style radial clocks.
Web UI (Future)¶
For the web chat interface, use The Alexandrian's clock font:
Download: Progress Clocks - Fonts and Images
Contents: - SVG + PNG for all clock states - Web fonts (TTF, WOFF, EOT) - License: Free with credit to Justin Alexander
Font character mappings: | Clock | Empty → Full | |-------|--------------| | 4-seg | A B C D E | | 6-seg | a b c d e f g | | 8-seg | 0 1 2 3 4 5 6 7 8 |
React component example:
function Clock({ segments, filled }: { segments: 4|6|8, filled: number }) {
const chars = { 4: 'ABCDE', 6: 'abcdefg', 8: '012345678' };
return <span className="font-bitd-clocks text-2xl">{chars[segments][filled]}</span>;
}
This renders actual pie-chart clocks in the browser. Implementation deferred until web UI phase.
Clock Review Model¶
Core Insight¶
Clocks are reviewed at meaningful time boundaries (especially day changes), not every turn.
This prevents clocks from feeling like reactive bookkeeping. Instead, they emerge from narrative pressure.
The Day Boundary Anchor¶
The current_day field is the natural review boundary:
| Boundary | Clock Behavior |
|---|---|
| Within a scene | Clocks may advance (if narrative warrants) |
| Between scenes | Clocks might advance |
| Between days | Clocks must be reviewed |
Day changes are the clearest, least ambiguous unit we track.
When to ADD Clocks¶
Add clocks when narrative pressure demands it, not proactively:
| Trigger | Example | Action |
|---|---|---|
| Stakes revealed | "You have three days" | Add clock |
| Time pressure articulated | "The ship leaves at dawn" | Add clock |
| Consequences visible | "If we don't act, they move the prisoners" | Add clock |
| Thread becomes urgent | Narrative events escalate | Add clock |
Do NOT add clocks: - At session start (too early, leads to overclocking) - To every high-priority thread (clocks are for ticking urgency, not importance) - Without a plausible escalation path
When to ADVANCE Clocks¶
| Situation | Action |
|---|---|
| Time explicitly passes | Advance threat clocks |
| Antagonists act off-screen | Advance threat clocks |
| PC makes progress | Advance progress clocks |
| Partial success/failure | Context-dependent |
Context Injection: Clock Review Section¶
When current_day > 1 and high-priority threads lack clocks, the context builder injects:
### Clock Review
*Day 5 - Consider if any threads have become time-sensitive:*
- **The Fading Muffle** *(no clock)*
A stolen token may be used for scrying...
- **Gareth & Jorah Recovery** *(no clock)*
Both miners are traumatized...
*Add a clock only if ignoring this thread would cause the world to change.*
This nudges the DM without forcing automation.
Thread Name Cleanup¶
Clocks replace text markers. Thread names should be clean:
| Before | After |
|---|---|
The Fading Muffle (URGENT) |
The Fading Muffle + ●●○○○○ clock |
Silent Circle Salon (TONIGHT) |
Silent Circle Salon + ●○○○ clock |
The clock IS the urgency indicator.
Philosophical Alignment¶
This model aligns with narrative game design:
- Wildermyth: Pressure emerges between chapters
- Blades: Clocks tick when fiction demands
- Ironsworn: Vows matter because time passes and choices cost
We create urgency without timers, structure without simulation, pressure without railroading.
Future Considerations (Phase 3+)¶
- Linked clocks: One clock triggers another
- Racing clocks: PC vs antagonist
- Session-based advancement: Clocks tick between sessions
- Downtime clock interaction: Phase 4 integration
References¶
- Blades in the Dark: Progress Clocks
- Phase 1: Relationship System - Complete
- Palimpsest Roadmap - Master plan