Skip to content

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

  1. Blades in the Dark clocks as inspiration - simple, visual, narrative
  2. DM discretion - clocks advance when narratively appropriate, not automatically
  3. Context injection - clock state visible in every scene
  4. 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_clock on 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_ignored only 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_clock tool implemented with advance_by parameter
  • [x] set_clock tool 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

  1. Threshold warnings → Yes, purely presentational: PRESSING at 50%, CRITICAL at 75%
  2. Clock history in context → No, current state only (history via RAG if needed)
  3. Auto-resolve on completion → No, DM decides outcome
  4. 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