Files
code_of_conquest_dnd/CLAUDE.md
2026-07-09 11:17:54 -05:00

533 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md
Project charter and working agreement for an AI-driven single-player RPG built in Godot 4.
Read this before writing code. When a request conflicts with something written here, say so rather than silently complying.
---
## 1. What this is
A single-player, party-based fantasy RPG. Combat is deterministic JRPG-style. The story skeleton is authored; the *prose* is generated. An AI acts as Dungeon Master — narrating scenes, voicing NPCs, and improvising minor events — while all game state lives in code.
**Not multiplayer.** Not now, not later. Companions are code-controlled NPCs. Do not introduce networking, lockstep, or authority abstractions "in case."
**Target:** Desktop first (Windows/Linux). Mobile is a later consideration and should not constrain POC decisions.
**Current phase:** POC. The question the POC answers is *does bounded AI dialogue feel alive, or does it feel like a chatbot in a costume?* Everything else is scaffolding for that experiment.
---
## 2. The one rule
> **Code owns state. AI owns text.**
The AI never mutates game state. It either:
- **narrates** what code already decided, or
- **proposes** an action that code validates and applies.
Every AI response is untrusted input. Parse it, validate it, and be prepared to discard it. A failed AI call degrades to authored fallback text. It never blocks the player, corrupts a save, or throws to the UI.
If you find yourself writing code where an AI response directly sets a variable that persists — stop. That is the architectural failure this project is designed to avoid.
---
## 3. Setting and tone
High fantasy. Elves, dwarves, humans, beast folk.
**Gritty, not grim.** The reference is Batman in the DC comics, not Warhammer and not Care Bears. People get hangovers. People vomit. People curse. Wounds get infected. The world has real problems and does not care about you.
**Comedy emerges from situation, never from the narrator.** The narrator is dry and does not wink. Nothing is ever described as "hilariously" anything. The humor comes from:
- Bad Luck producing indignity
- Cadwyn causing a disaster
- Brannoc commenting on both
The world is played straight. That is what makes it funny.
**Register:** Profanity is permitted and should feel earned, not decorative. Violence has weight. Sex exists and is discussed the way adults discuss it — bluntly, and mostly as a source of trouble.
---
## 4. Architecture
Three processes. They talk over HTTP. Nothing else.
```
┌──────────────────┐ ┌──────────────────┐ ┌──────────────┐
│ Godot 4 client │ ─────▶ │ FastAPI proxy │ ─────▶ │ Replicate / │
│ (GDScript) │ ◀───── │ │ ◀───── │ Ollama │
│ │ │ auth · metering │ │ │
│ ALL game state │ │ prompts · logs │ │ │
│ ALL game rules │ │ model routing │ │ │
└──────────────────┘ └──────────────────┘ └──────────────┘
```
### The client never holds an API key. Ever.
Not in the POC. Not in a `.env`. Not "just for testing." The moment a key ships in a client binary, it is public.
The client knows exactly one base URL and a set of role endpoints:
```
POST /dm/narrate
POST /dm/adjudicate
POST /dm/improvise
POST /npc/speak
POST /party/banter
```
The client does not know which model serves a role. It does not know the prompt. It sends game state and receives text.
### Why the proxy exists in the POC, before there is anything to meter
1. **Prompts are the actual IP.** They live server-side.
2. **Model routing becomes config.** "The Adjudicator is now an 8B model" is a deploy, not a client patch.
3. **Every call is logged from day one.** This gives free eval infrastructure — replay yesterday's real traffic against a candidate model.
4. **Credits retrofit cleanly.** Add auth middleware and a Stripe webhook. The game does not notice.
Building the POC with direct client → Replicate calls and retrofitting the proxy later means rewriting every call site.
### Environments
| | Client | Proxy | Model |
|---|---|---|---|
| Dev | Godot editor | `localhost:8000` | Ollama (homelab) |
| Staging | Exported build | homelab VPS | Ollama (homelab) |
| Prod | Exported build | fly.io | Replicate |
---
## 5. The DM is not one prompt
The previous attempt used a single omniscient prompt. It failed — as it always does. One prompt cannot simultaneously hold narrative voice, rules adjudication, and state consistency without drifting on at least one axis.
Instead, four narrow roles. Each has its own prompt, its own input contract, its own output contract, and potentially its own model.
### Narrator
Describes scenes, outcomes, transitions. Gets scene state and the canon log. Returns prose.
- **Quality matters most here.** Use the good model.
- **Output:** prose with tags.
- Never invents proper nouns without emitting a `[FACT: ...]` tag.
### Adjudicator
Player typed free text. Map it onto a legal action, or reject it.
- **Speed matters, prose does not.** Use a small, fast model.
- **Output:** strict JSON.
- Returns `{"action": "...", "params": {...}}` or `{"action": "invalid", "reason": "..."}`.
- The `reason` is shown to the player. Write it in-world.
### Improviser
Player went off-script, or Luck fired. Generate a minor event.
- **Sandboxed by construction.** See §7.
- **Output:** prose with tags.
- Cannot touch quest flags. This is enforced in code, not in the prompt.
### NPC
Voice a specific character. See §6.
- **Output:** prose with move tags.
### Banter
Companions comment on recent events. Low stakes, high charm, aggressively cacheable.
- **Output:** prose.
- Reads the humiliation log. See §9.
---
## 6. Bounded NPC dialogue
NPCs may say anything *stylistically*. They may only *do* things from a closed vocabulary.
### The move vocabulary
```
reveal(topic_id)
offer_quest(quest_id)
accept_item(item_id)
give_item(item_id)
adjust_disposition(delta)
refuse
end_conversation
become_hostile
```
Eight moves. If this list grows past ten, you have two systems and should split them.
### The contract
**Prompt receives:**
- Persona (voice, manner, what they want)
- Current disposition toward player (integer, 100..100)
- `knowledge: [...]` — an explicit list of what this NPC knows
- `available_moves: [...]` — the subset this NPC can legally perform right now
- Canon log
**Prompt returns:** prose, with zero or more inline move tags.
```
"Aye, I knew your father." [ADJUST_DISPOSITION: +5]
"But I'll not speak of him in this place." [REFUSE]
```
**Code then:**
1. Extracts tags via regex
2. Validates each against actual game state — *does this NPC have quest 14? is disposition high enough?*
3. Applies valid moves
4. **Silently drops invalid moves. Keeps the prose.**
An NPC hallucinating a move they cannot perform is not an error. It is a Tuesday. Log it, drop it, move on.
### The knowledge list is the whole design
It is the only thing preventing the blacksmith from cheerfully revealing the twist. It is not a suggestion in a prompt — it is the *entire content* the NPC has to draw on.
Building these lists is real authoring work. Budget for it.
---
## 7. Luck
The signature system. Read this section twice.
### Luck never touches numbers
Damage is fully deterministic. Same attack, same target, same state → same number, every time. The player can plan a fight. Math is math.
**Luck selects between outcomes.** It governs which branch you land on when the game reaches a fork it was already going to reach:
- Whether a borderline resistance check lands
- Whether a hit crosses the critical threshold
- Whether the Improviser fires at all
- Whether the event it generates is a boon or a humiliation
- Whether the waitress is a waitress
A low-Luck character is not doing 8% less damage. A low-Luck character is the one the trap door opens under.
### Luck is visible in prose, never in the combat log
The player should *feel* cursed. They should never be able to *calculate* that they are cursed.
**Never display the Luck value.** Display a descriptor:
> *Fortune spits on you.*
> *The dice are kind today.*
Uncertainty is the point. The player should become superstitious. That is the entire reason this stat exists.
### Generation and drift
- Base: average of 5 rolls, plus modifier. Centralizes hard — most characters land near the middle. This is correct for a stat with this much narrative reach.
- **Luck is volatile.** It drifts ±3 from base, driven by events: sleeping in a real bed, a shrine's blessing, killing something that was praying.
- Cursed and blessed items move it. *A cursed blade granting +4 STR and 5 LCK is the most interesting item in this game.* Build the item system to support this on day one.
- The player has agency over Luck. Shrines, offerings, and the decision to keep or discard a cursed item are meaningful choices.
### The Improviser's hard limits
Bad luck costs **dignity, not progress.**
The Improviser **may** generate:
- Embarrassment
- Inconvenience
- Property damage
- Social catastrophe
- Minor injury
The Improviser **may never** generate:
- Quest failure
- Permanent stat loss
- Party death
- Loss of a plot-critical item
**Enforce this in code, not in the prompt.** The Improviser's output passes through a validator that can only apply a whitelisted set of state changes. It is structurally incapable of ending a quest.
Why: comedy that punishes a player for a build choice they made in character creation is not comedy. It is a fine. Bad luck should produce a story the player tells later. Good luck should make them feel briefly like the protagonist.
### The comedy is Brannoc, not the waitress
The joke is not that the waitress is a man in disguise. The joke is that Brannoc saw it happen and will not let it go for three towns.
This means the Banter role must have access to a memory of humiliations. Literally a list. See §9.
---
## 8. Stats and classes
### Stats
| Stat | Governs |
|---|---|
| STR | Melee damage, carry, forcing things |
| DEX | Initiative, finesse weapons, sleight |
| CON | HP, resisting poison and exhaustion |
| FTH | Divine casting. Priests, Oathbound. Also: conviction in dialogue |
| MAG | Arcane casting. Scholars. Also: recall, deduction |
| LCK | See §7 |
FTH and MAG replace WIS and INT. Both must matter outside combat or the POC has not tested anything.
### Classes
Named for the **role in the world**, not the mechanical archetype. *Priest* is what a village calls him. *Cleric* is what a rulebook calls him.
| Archetype | Name | Primary |
|---|---|---|
| Fighter | **Sellsword** | STR |
| Rogue | **Assassin** | DEX |
| Cleric | **Priest** | FTH |
| Wizard | **Scholar** | MAG |
| Warlock | **Oathbound** | FTH |
| Ranger | **Tracker** | DEX |
| Barbarian | **Berserker** | STR |
| Bard | **NPC ONLY** | — |
**Bard is not playable.** A Bard the player cannot control is funnier than one they can manage. He is a consequence, not a resource.
**POC playable classes:** Sellsword, Assassin, Priest. Covers all three combat pillars and gives FTH something to do outside combat.
---
## 9. Companions
Two. They carry the entire tone. Both are code-controlled in combat and AI-voiced in dialogue.
### Cadwyn Vell — Bard
**He is genuinely good.** That is load-bearing. If he were a hack he'd be a joke; because he is actually a fine musician and a fine liar and, briefly and in the right light, magnificent — the disasters land harder.
He is not a coward, which surprises people. He will walk into a burning building. He simply will not tell you *why* it is burning, or that he knows the man who set it, or that the man was looking for him.
- **Combat:** support, buffs, crowd control.
- **Out of combat:** the party's social solvent. He gets you into places. He is why you had to leave the last three.
- **Systemically:** he is the **Improviser's hook.** When Luck rolls badly in a town, the system does not have to invent a reason the innkeeper hates you. Cadwyn is standing right there.
- **Comedy register:** he does not know he is the problem. He is bewildered, every time, that the world reacted badly. *This never becomes self-awareness.* The moment Cadwyn learns, he stops being funny.
- **Depth:** he is running from something specific, and has been running long enough that the running is the only part he still does well. High approval reveals that what he did was not funny at all. Low approval means you never find out — he leaves in the night, and the absence lands harder than expected.
- **Voice:** florid when performing, clipped when scared. Lies reflexively about small things, truthfully about large ones.
### Brannoc Thane — Sellsword
Not a grump. Grumps are exhausting. Brannoc is *fond* of you. That is what makes it unbearable.
Twenty years past his prime, entirely aware of it, at peace with it. He has buried more friends than he can name and has arrived somewhere past grief at a kind of terrible good humor. He finds the world funny because the alternative was worse.
- **Combat:** frontline, damage soak.
- **Out of combat:** he tells you what things are. Brannoc has seen this before. He knows what that rune means and he is not going to enjoy telling you.
- **Systemically:** he **holds the humiliation log.** Events with high embarrassment-weight are written to a list. Banter pulls from it with decaying frequency. **New humiliations do not overwrite old ones — they stack.** By hour ten he has a repertoire.
- **Comedy register:** the callback. He remembers the waitress. He will bring her up in a crypt, three towns later, at the worst possible moment, with real affection. *He is not mocking you. He is delighted by you.* This is much funnier and much harder to resent.
- **Depth:** Brannoc is not fine. The good humor is a structure he built on purpose and maintains daily. He knows he will die on a job like this one and has decided that is acceptable. High approval does not unlock a tragic backstory. It unlocks him quietly asking you to make sure someone remembers his name. That is the whole thing.
- **Voice:** dry, warm, economical. Never raises his voice. Says devastating things in the tone of a man discussing weather.
### The pair
They dislike each other, mildly and permanently. Brannoc thinks Cadwyn is a child. Cadwyn thinks Brannoc is a tombstone that walks. Neither is wrong. Neither will leave the other behind.
- Cadwyn generates chaos. Brannoc annotates it. A complete comedy loop, no third party required.
- Cadwyn lies. Brannoc does not. The player learns to triangulate — which teaches them the world has a truth value. Essential in a game where an AI describes that world.
- **Approval means different things.** Cadwyn's high approval reveals he is *worse* than you thought. Brannoc's reveals he is *sadder* than you thought. Neither is a reward, exactly.
### Approval
Single integer, 100..100, per companion. Adjusted by player choices weighed against companion values (honor, greed, mercy, curiosity).
**POC scope:** companions cannot leave. That is a v2 problem.
---
## 10. Combat
**Positionless, turn-based.** No grid. No flanking. No terrain.
Combat is not the risk surface of this project. Ship the boring one; prove the interesting one.
- **Damage is deterministic.** No variance rolls on damage.
- **Initiative is rolled once at encounter start** and is relatively random. DEX modifies.
- **The interesting decision is resource management** — MP, cooldowns, consumables. Not matchup-reading. Pick one axis for the POC.
- **Luck influences crit thresholds and borderline status checks.** Nothing else. See §7.
- **Combat flavor text is async and optional.** The numbers land immediately. The prose catches up. If the AI call fails, the fight continues with authored text.
### Determinism and seeding
**Seed the RNG per encounter from save state.** Load a save, fight the same fight, get the same outcomes.
This costs nothing now and is nearly impossible to retrofit. It is the difference between a bug you can reproduce and a bug you cannot.
Log the seed *and* the full prompt with every AI call, for the same reason.
---
## 11. The canon log
The mechanism preventing the AI from contradicting itself.
**Do not use "last N messages."** It will fail. The model will forget the dagger you sold in hour one.
Instead, a compact structured fact list that *code* maintains and injects into every call:
```json
{
"player": { "name": "...", "class": "...", "luck_descriptor": "..." },
"party": [ { "name": "Brannoc Thane", "disposition": 40 } ],
"recent_events": ["3-5 terse lines"],
"established_facts": ["things the AI said that are now canon"],
"active_quests": [...],
"humiliations": ["for the banter role"]
}
```
### Every AI-generated proper noun becomes a durable fact
When the Improviser invents a hedge witch named Marn on the eastern road, code writes `"met hedge witch Marn on the eastern road"` into `established_facts` — so the Narrator does not later describe that road as untraveled.
Mechanism: the Improviser and Narrator emit `[FACT: ...]` tags. Code harvests them.
**AI-generated prose is never the source of truth for anything a later scene reads.** If prose and state disagree, state wins, and that is a bug to fix in the prompt.
---
## 12. Output contracts
| Role | Format | Why |
|---|---|---|
| Narrator | Prose + tags | Models get stiff inside braces. Voice is the differentiator. |
| NPC | Prose + tags | Same. |
| Improviser | Prose + tags | Same. |
| Banter | Prose | No tags needed. |
| **Adjudicator** | **Strict JSON** | Prose quality is irrelevant. Validity is everything. |
### Tag syntax
```
[MOVE: offer_quest(14)]
[FACT: the eastern bridge is out]
[ADJUST_DISPOSITION: -10]
```
Extracted by regex. Stripped from displayed prose.
### Retry policy
1. Parse fails → **one** retry, with the validation error appended to the prompt.
2. Second parse fails → **authored fallback text.**
Never let a bad parse block the player. Never retry more than once — latency compounds and the player is waiting.
---
## 13. Failure UX
**When the API is down, the player sees a degraded DM — not an error.**
This is a design problem, not an engineering one. The DM has a voice. A degraded DM still has that voice.
> The chamber is cold. Something waits in the dark.
Write fallbacks as **content**, not as error handling. They live in the same files as the rest of the writing. A generic error dialog is a failure of craft.
Every AI-dependent surface needs an authored fallback before it ships. No exceptions.
---
## 14. Cost and latency
Roughly 13k tokens per call, dozens of calls per session.
- **Cache aggressively.** Same room, same state → same description. Do not regenerate.
- **Pre-generate on loading screens.** You know the player is walking toward the crypt.
- **Stream everything.** Latency reads as *the DM is speaking* rather than *the game is frozen.*
- **Combat flavor is async.** Numbers first, prose after.
- **Route by role.** The Adjudicator can be tiny. The Narrator should not be.
---
## 15. Input model
**Hybrid.**
- **Menus:** combat, navigation, inventory.
- **Free text:** dialogue, and explicit "what do you do?" moments.
**The boundary must be visible.** The player should always know which mode they are in. Ambiguity here reads as broken.
Free text goes to the Adjudicator, which maps it to a legal action or rejects it with an in-world reason.
---
## 16. Tech
- **Client:** Godot 4, **GDScript.** Prefer readability. Drop to C# only if GDScript genuinely cannot do the thing — and say why before you do.
- **Proxy:** Python, **FastAPI.**
- **Models:** Ollama (self-hosted, dev/staging) → Replicate (prod). Routed per-role, server-side.
- **Repo:** greenfield. No existing code.
### Layout
```
/client Godot 4.7 project (was /game)
/docs Client-specific docs
/api FastAPI service — the guarding proxy of §4 (was /proxy)
/docs API-specific docs
/prompts Role prompts (versioned, reviewed like code)
/content Authored: quests, NPC knowledge lists, fallback text
/docs Cross-cutting docs — roadmap, ADRs, anything affecting both sides
```
`/api` is still the proxy described in §4 — auth, metering, prompt-hiding, model
routing. It is named `api` for the client's convenience, not because it is a
generic pass-through. Docs are split: side-specific docs live under
`/client/docs` and `/api/docs`; anything spanning the HTTP boundary lives in the
root `/docs`. `CLAUDE.md` stays at the repo root as the source of truth.
Prompts are source code. They get reviewed. They get versioned. Changing a prompt is a change to the game.
---
## 17. POC scope
**~30 minutes of play.**
- One town, **two NPCs** with authored knowledge lists
- One dungeon: **three fights, one boss**
- **Two companions:** Cadwyn, Brannoc
- **Three playable classes:** Sellsword, Assassin, Priest
- Luck fully wired: generation, drift, one shrine, one cursed item
- Full proxy, full role split, full canon log
- Authored fallback text for every AI surface
### The POC's job
Answer one question: **does bounded AI dialogue feel alive, or does it feel like a chatbot in a costume?**
Test that before building anything else. Every other system exists to make that test possible.
### Explicitly out of scope
- AI-generated story skeletons (v2 — the seeded story is authored for now)
- Multiplayer (never)
- Mobile
- Credits, payments, auth
- Companions leaving the party
- Positional combat
- More than three classes
---
## 18. Working agreement
- **Do not write code unless asked.** Design conversations are for design. The human writes code via Claude Code separately.
- **When a request conflicts with this document, say so.** Do not silently comply. This file is the argument; if it is wrong, change the file first.
- **When adding a system, state which side of §2 it falls on.** State or text. If it is both, it is two systems.
- **Prefer deleting scope.** The POC's value is answering one question fast.