Reserve the turn first.
The runtime establishes an owner-scoped, leased request claim before making a dialogue call. Interaction ID, payload digest and expected version distinguish a replay from a competing or changed request.
BEHIND THE SCENES / FOR THE CURIOUS
How caveOS combines a classic adventure interface,
a small authoritative backend, and optional language-model dialogue.
The interesting part is the boundary. A character can interpret what you mean. The game still has to check whether it is true, whether you found the evidence, and whether the action is allowed.
01 / WHO OWNS WHAT?
The browser presents.
The backend decides.
The database remembers.
Painted rooms, characters, hotspots, inventory, captions and input.
Owner checks, versioned content, rules, dialogue context and validated actions.
Save state, request claims, stored results, conversation history and bounded NPC memory.
OPTIONAL BRANCH FROM THE SERVER
LangChain + LangGraph → model provider → typed result → validation. Provider credentials stay on the backend. A provider reply is an input to the rules, never permission to edit the save.
React and TypeScript are built with Vite. JSON Schema validation with Ajv checks the client-facing contracts. Rooms are layered HTML elements and images rather than a full game-engine canvas.
FastAPI and Pydantic handle typed requests. Ordinary Python rules own inventory, evidence, access and progression. Predefined choices and Episode 1 text proposals meet at the same canonical evaluator and commit boundary.
Authored JSON describes the world and its dependencies. Public presentation supplies safe labels and geometry; full story catalogues remain server-side. Content, contract and save identities are pinned rather than silently upgrading old saves.
LangChain provides a structured model adapter. Small LangGraph workflows bound each turn. The configured OpenRouter path pins a model and provider allowlist; SDK retries and model substitution are disabled. Authored replies remain available.
02 / WHAT HAPPENS AFTER SAY?
An illustrative Episode 1 disk-request example.
Minor puzzle-mechanics spoilers; no story revelations.
STEP 1 / 5
The server receives a player action with an interaction ID and an expected state version. Ownership is checked before private save data is used.
{
"interaction": "example-A",
"input": "player text",
"expectedVersion": 12
}Local explanatory walkthrough. It sends no API request, reads no save and makes no model call. This is simplified control flow, not an exact wire payload or a model-output simulator.
03 / WHY LANGGRAPH?
The interpreter asks for a typed ConversationOutput. Its action vocabulary is small: the model can propose release_disk or no action. A parse or proposal-validation failure takes the authored-fallback path.
The graph returns a proposal. The separate game-rule layer still checks the actual evidence and prerequisites before a state change can commit. A correctly shaped JSON response is not proof that the request is legitimate.
Source: backend/app/agents/workflow.py. The graph compiles without a durable LangGraph checkpointer; SQLite is the save and recovery authority.
The current Episode 2 graph is smaller: START → respond → END. Its output schema contains a bounded reply. Free text is non-mutating; explicit authored controls handle evidence use, statements, branch choices and other puzzle decisions.
Todd, Ruth and Pell receive scoped scene context and registered persona instructions. Marit’s send-off stays authored. Reply screening rejects obvious ungrounded effect claims, but lexical screening cannot establish that every sentence is true.
Source: backend/app/agents/episode02_workflow.py. Default graph budgets are 18 seconds for the Episode 1 interpreter and 15 seconds for Episode 2, both below the API budget.
A shortened extract of the Episode 1 implementation. Branch functions choose the named destinations; the canonical game commit happens outside this graph.
graph = StateGraph(WorkflowState)
graph.add_node("interpret", interpret_node)
graph.add_node("validate", validate_node)
graph.add_node("fallback", fallback_node)
graph.add_node("done", lambda state: {
"proposal": state.get("proposal")
})
graph.add_edge(START, "interpret")
graph.add_conditional_edges(
"interpret", route_after_interpret,
{"validate": "validate", "fallback": "fallback"}
)
graph.add_conditional_edges(
"validate", route_after_validation,
{"done": "done", "fallback": "fallback"}
)
graph.add_edge("fallback", END)
graph.add_edge("done", END)
compiled = graph.compile()This gives flexible expression inside an authored puzzle, at the cost of limits on what language can do. The design deliberately avoids an autonomous NPC society, runtime-generated plot and an agent for every object. A more elaborate graph would need a demonstrated gameplay benefit.
04 / THE UNGLAMOROUS PART
A successful provider call and a successful save are different events. Recovery has to account for both.
The runtime establishes an owner-scoped, leased request claim before making a dialogue call. Interaction ID, payload digest and expected version distinguish a replay from a competing or changed request.
Network inference does not run inside a long SQLite transaction. An attempt token and version fences must still match when the result is finalized. A late attempt cannot simply overwrite a newer state.
The relevant state/history/result and eligible memory writes are finalized transactionally. A completed interaction can return its stored response rather than execute a second time.
| What happened | What the implementation does |
|---|---|
| Same ID and same payload | Return the stored completed result, or report the existing pending claim. Do not make an extra model call for a completed replay. |
| Same ID, changed payload | Reject the mismatch. An interaction ID is bound to the request that originally used it. |
| The client has an old version | Return a conflict and current snapshot instead of blindly applying the stale action. |
| The save committed; the response was lost | Recover by interaction ID through action status or exact replay, using the stored result. |
| Provider error or expired dialogue claim | Resolve through authored fallback. Expiry does not automatically authorize another paid inference call. |
The browser separately tracks pending, failed and uncertain requests. It can preserve a draft, check the status of the original interaction and ignore an older snapshot. Owner-scoped saves use an HttpOnly ownership cookie; the server stores a hash of the token. Knowing a session ID alone is not permission to read it.
Implementation anchors: backend/app/storage/sqlite.py, backend/app/episode_runtime/service.py, frontend/src/state/requestMachine.ts and the full/episode-runtime session hooks. These are bounded recovery guarantees, not a claim that networks deliver requests exactly once.
05 / WHAT A CHARACTER REMEMBERS
Personal recall and canonical knowledge
are deliberately different stores.
After accepted free text, a deterministic extractor can retain a short, explicit personal statement. It recognizes finite English forms such as a favorite color or a stated preference. It makes no extra model call.
There are at most 12 notes per actor per save. A request receives at most six eligible notes, within a 900-character field-value budget before JSON formatting. Topic overlap and recency select them; a correction can replace the same typed slot.
This is bounded recall, not semantic memory of everything said. Indirect wording, arbitrary promises and paraphrase reconciliation are not guaranteed.
The model sees selected notes as quoted, unverified player statements. Saying that you own an item does not put it in inventory. A preference does not become a discovered clue. One character does not receive another’s conversation notes.
Eligible notes can carry across the validated Episode 1 → Episode 2 transition, preserving their provenance. Standalone saves do not inherit somebody else’s notebook, and earlier source saves do not read later destination memories.
No embeddings, vector database or generated summary is needed for this implementation. SQLite persists the notes; LangGraph assembles the bounded context.
Source: docs/architecture/npc-memory.md and backend/app/application/npc_memory.py. Live-model recall quality and broad natural-language coverage remain unmeasured.
06 / CHARACTER PROMPTS
A character sheet sets the persona.
The episode sets what they know.
Character sheets are the source of truth for persona text and authored fallback lines. Most registered runtime prompts use a verbatim binding: the prompt contains those approved lines word for word, and scripts/check-character-sheets.py fails if the sheet and prompt drift apart.
The checker also rejects spoiler terms in the prompt layers shared across episodes. Episode-specific knowledge is supplied separately from the episode content specification.
Todd’s existing Episode 1 interpreter prompt predates his sheet and adapts its ideas instead of embedding the sheet verbatim. It uses a reviewed binding: the sheet and prompt hashes are recorded at review, and a change to either requires a new review.
That binding checks for reviewed text changes; it does not guarantee that the prompt has the sheet’s exact wording or that their meanings are identical. The checker catches drift, while a person still has to review meaning.
Source: docs/design/characters/README.md, docs/design/characters/todd.md, backend/app/agents/workflow.py and scripts/check-character-sheets.py.
07 / BEHIND THE PAINT
The scene is a presentation.
Walking across it does not change the rules.

Architecture, fixed furniture and most clutter live in one background.
Independent sprite layers preserve proportions and foot or chair contact.
Named buttons provide object interaction, focus and optional hotspot assistance.
Confirmed dialogue, inspection text, choices and inventory remain readable interface elements.
Full Episode 1 is authored on a 960 × 352 logical plane and scales uniformly. Standing actors meet a fixed floor at y = 327; horizontal movement is bounded by room-specific intervals. This is modest foreground walking, not a general pathfinder or depth-sorting engine.
Only the speaking mouth patch animates during confirmed speech. Bodies and anchors stay fixed. Reduced motion closes the mouth and settles movement without changing gameplay.
Rooms and character artwork are prepared as separate assets. Development sound generation is also offline authoring machinery. Runtime playback uses explicit local slots; a missing slot stays silent rather than generating a guessed asset.
Credentials remain backend-only. Private story imagery is served only after the corresponding owner-scoped reveal or retention state; hiding a URL in the frontend would not be an access boundary.
Source: frontend/src/components/FullEpisodeRoom.tsx, the room/actor styles and docs/design/episode-01/character-staging-rules.md. Readable controls are implemented; narrow-scene caption overlap and human visual acceptance remain review concerns.
08 / WHAT HAS ACTUALLY BEEN MEASURED?
Rules, interpretation, narrative quality and enjoyment are different questions. The project records them separately.
pytest covers rules, ownership, state invariants, forged evidence and replay/recovery. Frontend component checks use Vitest; browser checks use Playwright. Fake providers test failure boundaries without paid inference.
LangSmith can group scoped NPC turns and capture timing and provider-reported usage/cost. It is observability, not the save or memory authority. Tracing is opt-in and can upload the selected dialogue context.
HISTORICAL SAMPLE / TODD PROTOTYPE / REVISION fa93297
The preserved task030 report contains 26 curated cases: 23 live calls and three controlled checks. The dataset informed prompt development, so it is not a held-out estimate of generalization.
| Measure | Observed result |
|---|---|
| Valid gameplay approaches accepted | 12 / 12 |
| Unauthorized state awards | 0 in this sample |
| Exact intent/action labels | 17 / 23 live cases |
| Median interpreter latency | 664 ms across 23 calls |
| Provider-reported cost | USD 0.00471504 for the reported sample |
These figures predate the current full-episode runtime and voice prompt. They are not current full-game latency, operating-cost or dialogue-quality measurements. Actual serving-host attribution was unconfirmed; missing accounting is not assumed to be zero.
Human comprehension and enjoyment, full-lane live dialogue quality/cost and current recall quality are not established by those numbers. A system can preserve every state invariant and still deliver an awkward conversation. That is why authored choices, honest failure feedback and human playtests matter.
09 / IF YOU LIKE READING THE SOURCE
This page was checked against local main 617b8f9ea96ed73f030828e013d5d29eb1a11860 on 25 October 2026. Source paths below identify the implementation behind each explanation; they are not links to private files.
| Episode 1 rules | backend/app/domain/rules.py |
|---|---|
| Episode 1 typed graph | backend/app/agents/workflow.pybackend/app/agents/schemas.py |
| Episode 2 conversation | backend/app/agents/episode02_workflow.py |
| Versioned runtime and recovery | backend/app/episode_runtime/service.pybackend/app/storage/sqlite.py |
| NPC claim memory | backend/app/application/npc_memory.pydocs/architecture/npc-memory.md |
| Room presentation | frontend/src/components/FullEpisodeRoom.tsxdocs/design/episode-01/character-staging-rules.md |
| Contracts and authored content | contracts/ and content/ |
| Historical live evaluation | evals/reports/task-030/README.md |
The website itself is intentionally simpler: static HTML, CSS and JavaScript, served separately from the game. This walkthrough and the artwork viewer run locally in the page. They do not connect to player saves, a model provider or the game API.
AI-assisted development does not replace source review, validation or Patrick Neubauer’s acceptance.