Interactive story-narration · play a character inside a book you upload
Build an AI narrator that lets you play any character in a book.
A build-along reference for a narrator that turns a novel you own into a world you can step inside. It reads the book, maps the cast, relationships, and plot spine, then lets you pick a character — Neville instead of Harry — and play the story from their side. Timeline, personalities, and the events that must occur all hold; how they unfold bends around your choices. Read it, code it, play it, tick it off.
Foundations: the model seam, config, structured outputs
Loreplay turns a book you own into a world you can play. Upload a novel; the system reads it, maps its cast, relationships, and plot spine, then lets you pick a character — Neville instead of Harry — and live the story from their side. The canon still holds: the timeline, the personalities, the events that must occur. But how they occur bends around the choices you make.
book text ──▶ ingest (chunk + embed + index) ┐
extract cast → character cards │ the STORY BIBLE
extract relationships → graph │ (built once per book)
extract spine → beats + anchors ┘
│
player picks a character ──▶ TURN LOOP ──▶ narrate → choose → resolve → advance
│ (grounded in the bible + world state)
GOVERNOR: keep the crux, spend a divergence budgetEverything downstream depends only on an LLM port, never on a concrete vendor. That is the one seam that makes the whole thing testable: tests inject a fake so they run in milliseconds with no keys and no network. The default real provider is Anthropic Claude.
mkdir loreplay && cd loreplay
uv init backend && cd backend
mkdir -p app/{ingest,world,play,govern,serve} tests data/books data/bibles
uv add anthropic pydantic pydantic-settings numpy neo4j
uv add sentence-transformers faiss-cpu # local embeddings + vector index
uv add ebooklib beautifulsoup4 pypdf # parse EPUB / HTML / PDF
uv add --dev pytest
# the world model lives in Neo4j — run one locally (Docker):
docker run -p 7687:7687 -p 7474:7474 -e NEO4J_AUTH=neo4j/loreplay neo4j:5app/llm.py — the provider port
A tiny port — a typing.Protocol, not an abstract base class — with three shapes we will reuse everywhere: complete for free-form text, parse for structured outputs (schema-validated JSON), and stream for live narration. Because it is a Protocol, any class that has these three methods is an LLM as far as a type checker is concerned — the concrete providers below never subclass it, they just match its shape (structural typing). That is deliberate: it keeps vendors out of the interface.
from typing import Protocol, Type, TypeVar
from pydantic import BaseModel
T = TypeVar("T", bound=BaseModel)
class LLM(Protocol):
def complete(self, system: str, messages: list[dict], *, max_tokens: int = 2000) -> str: ...
def parse(self, system: str, prompt: str, schema: Type[T]) -> T: ...
def stream(self, system: str, messages: list[dict], *, max_tokens: int = 2000): ... # yields strapp/llm.py, continued — a real provider and a fake
Same file, right below the port. ClaudeLLM is the default real provider (Anthropic’s claude-opus-5); FakeLLM is what tests inject. Notice neither writes class ClaudeLLM(LLM) — they don’t inherit from the port, they conform to it. Each has the same three methods with matching signatures, and that is all a Protocol asks for. A type checker will accept either one anywhere the pipeline expects an LLM.
import anthropic
class ClaudeLLM:
def __init__(self, model: str = "claude-opus-5"):
self.client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY / ant profile
self.model = model
def complete(self, system, messages, *, max_tokens=2000) -> str:
r = self.client.messages.create(
model=self.model, max_tokens=max_tokens, system=system,
messages=messages, thinking={"type": "adaptive"})
return next(b.text for b in r.content if b.type == "text")
def parse(self, system, prompt, schema):
# structured outputs: the response is validated against the pydantic schema
r = self.client.messages.parse(
model=self.model, max_tokens=4000, system=system,
messages=[{"role": "user", "content": prompt}], output_format=schema)
return r.parsed_output
def stream(self, system, messages, *, max_tokens=2000):
with self.client.messages.stream(
model=self.model, max_tokens=max_tokens,
system=system, messages=messages) as s:
yield from s.text_stream
class FakeLLM: # tests inject this — no keys, no network
def __init__(self, canned: dict | None = None): self.canned = canned or {}
def complete(self, system, messages, **k): return self.canned.get("text", "...")
def parse(self, system, prompt, schema): return self.canned["obj"]
def stream(self, system, messages, **k): yield self.canned.get("text", "...")Ingest the book: parse, segment into scenes, embed, index
Extraction and narration are only as grounded as the text you feed them. Parse the book to clean text, then split on scene boundaries (chapter breaks, POV shifts, scene dividers) rather than fixed windows — a scene is the natural unit a story turns on.
from dataclasses import dataclass
@dataclass
class Scene:
id: str # "ch03-s02"
chapter: int
order: int # global position in the book — this is the timeline axis
text: str
def segment(raw: str) -> list[Scene]:
scenes, order = [], 0
for ch_i, chapter in enumerate(split_chapters(raw)): # your EPUB/PDF splitter
for s_i, body in enumerate(split_scenes(chapter)): # blank-line / divider heuristic
scenes.append(Scene(id=f"ch{ch_i:02d}-s{s_i:02d}",
chapter=ch_i, order=order, text=body.strip()))
order += 1
return scenesEmbed + index the scenes
import faiss, numpy as np
from sentence_transformers import SentenceTransformer
class SceneIndex:
def __init__(self, scenes: list[Scene], model="all-MiniLM-L6-v2"):
self.scenes = scenes
self.embedder = SentenceTransformer(model)
vecs = self.embedder.encode([s.text for s in scenes], normalize_embeddings=True)
self.index = faiss.IndexFlatIP(vecs.shape[1]) # cosine via inner product
self.index.add(np.asarray(vecs, dtype="float32"))
def search(self, query: str, k: int = 5) -> list[Scene]:
q = self.embedder.encode([query], normalize_embeddings=True)
_, idx = self.index.search(np.asarray(q, dtype="float32"), k)
return [self.scenes[i] for i in idx[0]]Extract the cast: character cards with voice and a knowledge horizon
A character the player can inhabit — or talk to — needs a card: who they are, how they talk, what they want, and crucially what they could plausibly know. We build cards with structured outputs so the model returns schema-valid JSON, and we demand evidence (scene ids) for each trait so cards stay grounded, not invented.
from pydantic import BaseModel, Field
class CharacterCard(BaseModel):
name: str
aliases: list[str] = Field(description="every name/epithet the text uses for them")
traits: list[str] = Field(description="stable personality traits, 3-7 items")
voice: str = Field(description="how they speak: diction, cadence, tics — 1-2 sentences")
wants: list[str] = Field(description="their driving goals across the book")
first_seen_order: int = Field(description="scene.order where they first appear")
evidence: list[str] = Field(description="scene ids supporting this card")
EXTRACT_SYS = (
"You are a literary analyst. Build an accurate character card ONLY from the "
"provided scenes. Do not invent traits the text does not support. Cite the "
"scene ids you relied on in `evidence`.")
def build_card(llm, name: str, scenes: list[Scene]) -> CharacterCard:
context = "\n\n".join(f"[{s.id}] {s.text}" for s in scenes if name in s.text)
return llm.parse(EXTRACT_SYS, f"Character: {name}\n\nScenes:\n{context}", CharacterCard)The knowledge horizon — what a character can know
Playing Neville means Neville does not know what happens in rooms he was never in. We attach a knowledge horizon to each card: the set of scenes (by order) the character witnessed or could have learned about. This is what later stops the player from metagaming — acting on the book's outcome instead of their character's limited view.
class KnowledgeHorizon(BaseModel):
character: str
witnessed_orders: list[int] # scenes they were present in
hearsay_orders: list[int] # scenes they plausibly heard about later
def horizon_for(name: str, scenes: list[Scene]) -> KnowledgeHorizon:
present = [s.order for s in scenes if name in s.text]
return KnowledgeHorizon(character=name, witnessed_orders=present, hearsay_orders=[])Map relationships: a knowledge graph that changes over time
How a character responds to the player turns on their relationships: ally, rival, mentor, crush, the-one-they-fear. Extract directed relationships from the text and store them in a graph keyed by time, so ‘how does Ron feel about Hermione at beat 6?’ has an answer that can differ from beat 20.
from pydantic import BaseModel
class Relationship(BaseModel):
source: str
target: str
kind: str # ally | rival | mentor | family | fears | loves | distrusts
valence: float # -1.0 hostile ... +1.0 warm
since_order: int # when this state begins
evidence: list[str]
REL_SYS = ("Extract directed relationships between named characters from these scenes. "
"One entry per ordered pair per state change. Cite scene ids.")Store it in Neo4j — a relationship is literally an edge
Characters are nodes; a relationship is a directed edge carrying its kind, valence, and the since_order it began. A graph database makes the temporal and multi-hop queries the narrator needs — ‘as of beat 14, who does the player's closest ally distrust?’ — a one-line Cypher match instead of hand-rolled traversal.
from neo4j import GraphDatabase
class WorldGraph:
def __init__(self, uri="bolt://localhost:7687", auth=("neo4j", "loreplay")):
self.driver = GraphDatabase.driver(uri, auth=auth)
def add(self, r: Relationship): # upsert one relationship state
self.driver.execute_query(
"""
MERGE (a:Character {name: $source})
MERGE (b:Character {name: $target})
MERGE (a)-[e:REL {kind: $kind, since_order: $since}]->(b)
SET e.valence = $valence, e.evidence = $evidence
""",
source=r.source, target=r.target, kind=r.kind,
since=r.since_order, valence=r.valence, evidence=r.evidence)
def state(self, a: str, b: str, at_order: int) -> Relationship | None:
records, _, _ = self.driver.execute_query(
"""
MATCH (:Character {name: $a})-[e:REL]->(:Character {name: $b})
WHERE e.since_order <= $at
RETURN e.kind AS kind, e.valence AS valence, e.since_order AS since
ORDER BY e.since_order DESC LIMIT 1 // newest state at/before the beat
""",
a=a, b=b, at=at_order)
if not records: return None
row = records[0]
return Relationship(source=a, target=b, kind=row["kind"],
valence=row["valence"], since_order=row["since"], evidence=[])The multi-hop questions the narrator actually asks become plain Cypher — the payoff of a graph database over a flat table of pairs:
// as of beat 14, who does Neville's closest ally distrust?
MATCH (me:Character {name: 'Neville'})-[a:REL {kind: 'ally'}]->(ally)
WHERE a.since_order <= 14
MATCH (ally)-[d:REL {kind: 'distrusts'}]->(x)
WHERE d.since_order <= 14
RETURN ally.name, x.nameExtract the spine: plot beats, and which are load-bearing anchors
The spine is the ordered list of plot beats. Some are anchors — the crux, the events that make this this story (the villain is revealed; the hero descends into the vault; the sacrifice is made). Others are soft scenes that can reshuffle, be reached differently, or be skipped. We classify them, and record each anchor's preconditions so the governor can still deliver it after divergence.
from pydantic import BaseModel
from typing import Literal
class Beat(BaseModel):
id: str
order: int
summary: str
kind: Literal["anchor", "soft"]
preconditions: list[str] = [] # what must be true for this beat to make sense
canonical_participants: list[str] = []
scene_ids: list[str] = []
class Spine(BaseModel):
beats: list[Beat]
SPINE_SYS = (
"You are a story editor. Segment the book into plot beats in order. Mark a beat "
"`anchor` if removing it would break the story's identity or ending; otherwise "
"`soft`. For each anchor, list preconditions — facts that must hold for the beat "
"to still land — so it can be reached even if earlier scenes played out differently.")World state & memory: the running playthrough
A playthrough is a fork of the book. We track it in a world state: which beat we are on, what the player chose, which relationships have shifted from canon, what each character now knows, and a running summary of the fork so far. This is the memory the narrator conditions on every turn.
from pydantic import BaseModel, Field
class Choice(BaseModel):
beat_order: int
prompt: str
chosen: str
ripple: str = "" # one-line consequence the governor recorded
class WorldState(BaseModel):
book_id: str
playing_as: str # the character the player inhabits (e.g. "Neville")
current_order: int = 0 # position on the timeline
choices: list[Choice] = []
relationship_deltas: dict[str, float] = Field(default_factory=dict) # "Ron->Harry": +0.2
known_facts: dict[str, list[str]] = Field(default_factory=dict) # per character
summary_so_far: str = "" # compacted narrative memory
divergence_spent: float = 0.0Compaction — keep memory bounded
A long playthrough will not fit in one prompt. Every few turns, summarize the fork so far into summary_so_far — preserving choices, relationship shifts, and open threads — and drop the verbatim turn history. This is the same discipline as server-side compaction, done in your own state so the summary is inspectable and save-able.
COMPACT_SYS = ("Summarize this playthrough so a narrator can continue seamlessly. Preserve: the "
"player's choices and their consequences, every relationship that shifted from canon, "
"unresolved threads, and what the player-character currently knows. Be concise.")
def compact(llm, state: WorldState, recent_turns: list[str]) -> str:
joined = state.summary_so_far + "\n\n" + "\n\n".join(recent_turns)
return llm.complete(COMPACT_SYS, [{"role": "user", "content": joined}], max_tokens=1200)Persist the fork next to the canon graph: a :Playthrough node anchors the player's choices and relationship deltas, so a save is a small subgraph, not a copy of the book.
// record one choice against the active playthrough
MATCH (pt:Playthrough {id: $playId})
CREATE (pt)-[:MADE {order: $beat}]->(:Choice {prompt: $prompt, chosen: $chosen, ripple: $ripple})
// layer a player-caused relationship delta over canon (does not touch the canonical :REL edges)
MATCH (pt:Playthrough {id: $playId}), (a:Character {name: $src}), (b:Character {name: $dst})
MERGE (pt)-[d:DELTA {source: $src, target: $dst}]->(a)
SET d.valence_shift = $shift, d.since_order = $beatThe turn loop: narrate → choose → resolve → advance
A turn: retrieve the relevant canon, narrate the scene from the player-character's vantage, offer choices (or accept free text), then resolve the choice into consequences and advance the timeline. The narration streams for immediacy; the choices come back as structured output so the UI can render them.
from pydantic import BaseModel
class Turn(BaseModel):
narration: str # what just happened, in the character's POV
choices: list[str] # 2-4 in-character options; free text also allowed
beat_order: int
def narrate(llm, state: WorldState, bible, index: SceneIndex):
beat = bible.spine.beats_by_order[state.current_order]
canon = index.search(beat.summary, k=4) # ground in the real text
card = bible.cards[state.playing_as]
system = build_bible_prompt(bible, card, state) # cached — see M11
user = (f"Current beat: {beat.summary}\n"
f"Grounding scenes:\n" + "\n".join(f"[{s.id}] {s.text}" for s in canon) +
f"\n\nNarrate this beat from {state.playing_as}'s point of view, then offer choices. "
f"Only reveal what {state.playing_as} could perceive or know.")
# stream the prose to the player as it generates
buf = []
for chunk in llm.stream(system, [{"role": "user", "content": user}], max_tokens=1200):
buf.append(chunk); yield chunk
# then get the structured choices in a second, cheap call
turn = llm.parse(system, "".join(buf) + "\n\nList 2-4 in-character choices.", Turn)
return turndef resolve(llm, state: WorldState, choice: str, bible) -> Choice:
# ask the model for the immediate consequence, constrained by the governor (M08)
ripple = llm.complete(RESOLVE_SYS,
[{"role": "user", "content": f"Player ({state.playing_as}) chose: {choice}. "
f"Give the immediate in-world consequence in one line."}],
max_tokens=200)
state.current_order += 1
return Choice(beat_order=state.current_order, prompt="", chosen=choice, ripple=ripple)Personas in play: stay in voice, and no metagaming
When the player talks to Ron, Ron must answer like Ron — using his card's voice, wants, and his relationship to the player at this beat — and must not know things Ron could not know. Persona conditioning + a knowledge filter is what keeps the cast feeling authored rather than generic.
def persona_prompt(card: CharacterCard, rel: Relationship | None, state: WorldState) -> str:
stance = f"Your stance toward {state.playing_as}: {rel.kind} (valence {rel.valence:+.1f})." if rel else ""
return (f"You are {card.name}. Voice: {card.voice}. Traits: {', '.join(card.traits)}. "
f"You want: {', '.join(card.wants)}. {stance} "
f"Stay strictly in character. Never break the fourth wall or reference being an AI.")
def knowledge_gate(card_name: str, horizon: KnowledgeHorizon, candidate_facts: list[dict]) -> list[dict]:
# drop any fact whose source scene the character never witnessed or heard of — no metagaming
allowed = set(horizon.witnessed_orders) | set(horizon.hearsay_orders)
return [f for f in candidate_facts if f["order"] in allowed]Optional: let the player re-shape a personality
The player asked for freedom, so allow an opt-in personality shift — ‘play a braver Neville’ — recorded as an explicit override on the card, tracked as divergence spend (M08). Default is faithful; deviation is a choice the player makes and the system remembers.
class PersonaOverride(BaseModel):
character: str
change: str # "more assertive", "secretly resentful of Harry"
since_order: int
def effective_voice(card: CharacterCard, overrides: list[PersonaOverride], at: int) -> str:
live = [o.change for o in overrides if o.character == card.name and o.since_order <= at]
return card.voice + ("" if not live else " Now also: " + "; ".join(live))Canon rails & the divergence budget: keep the crux, spend the rest
This is the heart of the promise. The player may reroute soft scenes freely, but every anchor must still be reached — possibly by a different road. We score how far the fork has drifted, spend a divergence budget on soft choices, and when an anchor's window arrives, we bend the narrative back toward its preconditions.
def canon_distance(state: WorldState, bible) -> float:
# 0.0 = on-canon, 1.0 = wholly diverged. Cheap, interpretable heuristic:
missed_anchors = sum(1 for b in bible.spine.anchors
if b.order < state.current_order and b.order not in state.reached_anchors)
rel_drift = sum(abs(v) for v in state.relationship_deltas.values()) / max(1, len(bible.cards))
return min(1.0, 0.5 * missed_anchors + 0.5 * rel_drift)
def next_anchor(state: WorldState, bible):
return min((b for b in bible.spine.anchors if b.order >= state.current_order),
key=lambda b: b.order, default=None)Steering back toward an anchor
When the player nears an anchor, the governor injects its preconditions into the narrator's instructions — not to railroad, but to make the world conspire so the beat can still occur in a way that honors the choices made. The trapdoor still opens; who opens it, and why, can change.
GOVERN_SYS = (
"You are the story governor. The player has diverged from canon. An upcoming ANCHOR must still "
"occur, honoring its preconditions, but it may be reached differently given the player's choices. "
"Rewrite the narrator's brief so the world plausibly converges on the anchor WITHOUT contradicting "
"what the player has already established. Do not undo player choices; re-route around them.")
def steer(llm, state: WorldState, bible) -> str:
anchor = next_anchor(state, bible)
if anchor is None or canon_distance(state, bible) < 0.25:
return "" # on track — no steering needed
brief = (f"Anchor to reach: {anchor.summary}\n"
f"Preconditions: {anchor.preconditions}\n"
f"Player choices so far: {[c.chosen for c in state.choices]}")
return llm.complete(GOVERN_SYS, [{"role": "user", "content": brief}],
max_tokens=400) # injected into the next narrate() system promptBUDGET = 1.0
def spend(state: WorldState, cost: float) -> bool:
if state.divergence_spent + cost > BUDGET: # soft cap: pull harder toward canon
return False
state.divergence_spent += cost
return TrueThe director: narrator, continuity, and character actors
Asking a single call to narrate, stay in canon, voice every NPC, and enforce knowledge boundaries is asking for mush. Split the turn into roles that hand off, each with a tight brief. This is a small multi-agent pipeline — the same idea as a director coordinating actors and a continuity supervisor on set.
- Director — plans the turn: which beat, is an anchor near, invoke the governor (M08), assemble briefs.
- Narrator — writes the prose from the player-character's POV, grounded in retrieved canon.
- Actors — one persona-conditioned call per speaking NPC, knowledge-gated (M07).
- Continuity — a cheap check that the draft contradicts neither the world state nor the character horizons; flags leaks (metagaming, out-of-voice, canon breaks).
def run_turn(llm, state, bible, index, player_input: str | None):
steer_brief = steer(llm, state, bible) # Director → Governor
turn = yield from narrate(llm, state, bible, index) # Narrator (streams prose)
replies = [act(llm, npc, state, bible) for npc in speaking_npcs(turn)] # Actors
report = continuity_check(llm, turn, replies, state, bible) # Continuity
if report.has_violation:
turn = repair(llm, turn, report) # one bounded retry
return turn, repliesclass ContinuityReport(BaseModel):
has_violation: bool
kinds: list[str] # "metagaming" | "out_of_voice" | "canon_break" | "timeline"
notes: str
CONTINUITY_SYS = (
"You are a continuity supervisor. Given the world state, character knowledge horizons, and a draft "
"turn, flag any place the draft: reveals what the POV character cannot know; makes an NPC act out "
"of their established voice; contradicts an anchor already reached; or breaks the timeline. "
"Return structured findings only.")Evaluation & safety: fidelity vs agency, and guardrails
Two forces pull against each other: canon fidelity (it still feels like the book) and player agency (choices matter). You cannot tune the divergence budget or the governor without measuring both. Score playthroughs with an LLM judge against an explicit rubric, plus cheap programmatic checks.
class TurnScore(BaseModel):
canon_fidelity: int # 1-5: consistent with timeline, anchors, established facts
persona_adherence: int # 1-5: NPCs sound like themselves
knowledge_integrity: int # 1-5: no metagaming leaks
agency: int # 1-5: the choice visibly mattered
notes: str
JUDGE_SYS = (
"You are evaluating one turn of an interactive re-telling. Score 1-5 on each rubric item. Be "
"strict about knowledge integrity: any fact the POV character could not know is an automatic 1. "
"Justify each score in one clause.")
def judge(llm, turn, state, bible) -> TurnScore:
ctx = f"State: {state.model_dump_json()}\n\nTurn:\n{turn.narration}"
return llm.parse(JUDGE_SYS, ctx, TurnScore)- Programmatic guards — assert every anchor reached before its window closes; assert no scene above the POV horizon leaked into narration; assert
divergence_spent ≤ BUDGET. - Golden playthroughs — keep a few scripted choice sequences and re-judge them whenever you change a prompt, so regressions are visible.
Serve & persist: an API, save/load, and a cached bible
Wrap the loop in a thin FastAPI service: upload a book (build the bible once), start a playthrough, and stream turns to a minimal text UI. Persist the fork as a :Playthrough subgraph in Neo4j so a player can resume. The single biggest cost lever is prompt caching the story bible.
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
@app.post("/books") # ingest + build bible (M01-M04), save under data/bibles/
def upload(book: UploadFile): ...
@app.post("/play") # start a playthrough: pick book + character
def start(book_id: str, playing_as: str) -> WorldState: ...
@app.post("/turn") # stream one governed turn
def turn(state: WorldState, choice: str | None = None):
def gen():
yield from run_turn(llm, state, load_bible(state.book_id), load_index(state.book_id), choice)
return StreamingResponse(gen(), media_type="text/plain")Cache the bible — it is the same on every turn
The character cards, spine, and relevant graph slice are large and identical across the whole playthrough. Put them in a cached system block so every turn after the first pays a fraction of the input cost. Keep the volatile part — current beat, recent choices — after the cache breakpoint so the prefix stays byte-stable.
def build_bible_prompt(bible, card, state):
frozen = render_bible(bible, card) # cards + spine + rules — stable all playthrough
return [
{"type": "text", "text": STORY_RULES}, # narrator rules (stable)
{"type": "text", "text": frozen, "cache_control": {"type": "ephemeral"}}, # cached prefix
]
# volatile turn context (beat, choices, retrieved scenes) goes in the USER message,
# after the breakpoint — so the cached prefix is reused turn after turn.Worked example: playing Neville through the vault
Concrete makes it click. Take a book with a well-known climax — the trio descending past a guardian into a vault to stop a thief. The player chooses to inhabit Neville, a minor character in that scene. Here is how the machine keeps the crux while honoring the choice.
BIBLE (built once)
cards: Harry, Ron, Hermione, Neville(minor, timid, loyal, wants: to belong)
spine: ... beat 21 [ANCHOR] "the descent past the guardian into the vault"
preconditions: [thief is unmasked at the vault, hero reaches the guardian]
horizon: Neville witnessed the common-room scenes, NOT the vault (in canon)
PLAY AS Neville
beat 19 [soft] Neville catches the trio sneaking out. CHOICE:
(a) let them pass (b) insist on coming (c) try to stop them > player picks (b)
governor: soft scene — spend 0.2 budget. relationship_delta {"Harry->Neville": +0.3}
ripple: "Neville, knees shaking, follows them down."
beat 21 [ANCHOR] window opens. canon_distance = 0.18 (< 0.25) — light steering.
steer: anchor preconditions still hold; Neville is now PRESENT at the vault.
narrate: the descent occurs (anchor kept) — but from Neville's POV, and his presence
changes a soft detail: he is the one who freezes the guardian, not Hermione.
continuity: no metagaming (Neville only narrates what he sees); anchor reached; PASS.The timeline held (the vault descent is still beat 21). The personalities held (Neville is timid but loyal; his choice to come is in character). The event still occurred (the anchor fired) — but its manner bent around the player: a background character stepped into the light, and one soft detail rerouted. That is the entire thesis of Loreplay in a single beat.