# The Chaos — Game Architecture

This document describes the system architecture for developers and AI agents
working on the codebase.

## Design Principle

The Chaos is a **self-contained terminal game**. The TUI owns the full game
loop — including LLM calls — so there is no split between a "DM agent" in chat
and a "dashboard" in the terminal. The player runs one command:

```bash
python3 tools/run.py
```

Everything — narrative, choices, character sheet, log, archive, ambience — lives
in that process.

## Project Layout

```
the-chaos/
├── themes/                   # SHARED theme packs (versioned)
│   └── default/
│       ├── theme.json        # Theme metadata
│       ├── rules/            # LOCKED — game rules
│       │   ├── deck/         # Card tables
│       │   ├── mechanics.md
│       │   ├── core_mechanics.md
│       │   ├── character_creation.md
│       │   └── end_game.md
│       ├── audio/            # Baseline ambience tracks
│       ├── ambience_options.md
│       └── character_template.md
├── tools/                    # Game system code
│   ├── __init__.py
│   ├── engine.py             # Game engine
│   ├── run.py                # TUI (Textual app)
│   ├── ambience.py           # CLI shortcut for ambience switching
│   ├── draw_card.py          # Card drawing tool
│   ├── music-fetch.py        # YouTube audio downloader
│   ├── roll_dice.py          # Dice rolling tool
│   ├── test_imports.py       # Import validation test
│   └── test_runtime.py       # Runtime import test
├── scripts/                  # UNLOCKED — helper scripts
├── run.sh                    # Entry point (just calls tools/run.py)
├── session/                  # Game state (read/write by engine)
│   ├── config.json           # LLM provider config
│   ├── current_theme         # Active theme id (e.g. "default")
│   ├── character.md          # Player character sheet (evolved)
│   ├── world.md              # Keep & Realm state (evolved)
│   ├── book.md               # Story book (append-only turn archive)
│   ├── journal.md            # TODO / DONE tracking
│   ├── ambience.md           # Current ambience name
│   ├── ambience_options.md   # Working copy (seeded from theme)
│   ├── ambience_sources.md   # Track download URLs
│   ├── tweaks.md             # House rules log
│   ├── audio/                # Working copy (seeded from theme)
│   └── log/                  # Session logs by date
└── saves/                    # Save slots (gitignored, per session)
    ├── _autosave/
    │   ├── metadata.json     # {hero, theme, timestamp, preview}
    │   └── ...               # session state snapshot (no audio)
    └── my-campaign/
        └── ...
```

## How It Works

### Tools

| Tool | Role |
|------|------|
| `tools/engine.py` | Game engine. Owns the LLM interaction, prompt assembly, response parsing, and state persistence. Can be used standalone from the CLI for debugging. |
| `tools/run.py` | TUI (Textual app). Owns the game loop: display narrative → get player input → call engine → display result. |

### The Game Loop (run.py)

1. **Mount**: Load engine, build system prompt (rules + character + world + log).
2. **Scene**: Call `engine.generate()` → receive narrative + choices.
3. **Display**: Show narrative in main pane, render choice buttons.
4. **Input**: Player clicks a choice or types free text, presses Enter.
5. **Validate**: A separate lightweight LLM call checks if the action is possible given the game state. If invalid, the turn is rejected with a failure narrative.
6. **Resolve**: Call `engine.generate(player_action)` → receive outcome + state changes.
7. **Archive**: Append the full turn (scene + action + outcome) to `book.md`.
8. **Apply**: Write state changes to `character.md`, `world.md`, `log/`, `ambience.md`, `journal.md`.
9. **Loop**: Display the next scene → go to step 3.

### The Engine (engine.py)

- `GameEngine` class loads config from `session/config.json`.
- `build_system_prompt()` assembles the DM prompt from game rules + current state.
- `build_user_message()` builds the per-turn message with player action context.
- `generate()` calls litellm, returns parsed `GenerationResult`.
- `parse_response()` extracts the JSON block from the LLM response.
- `apply_state()` writes state changes to session files.
- `archive_turn()` appends the narrative to `book.md`.

### LLM Output Format

The LLM must end every response with a JSON fenced code block:

```json
{
  "choices": ["Choice 1", "Choice 2"],
  "log_entry": "- **time** — description.",
  "ambience": "ambience_name_or_null",
  "character_updates": null,
  "world_updates": null,
  "journal_add": [],
  "journal_done": []
}
```

- `choices`: 2-4 action options for the player.
- `log_entry`: Single-line summary appended to today's log.
- `ambience`: One of: silence, calm, combat, dungeon, forest, tavern, tension, town, wilds.
- `character_updates`: Full character sheet markdown only if HP/cash/gear/stats changed.
- `world_updates`: Full world markdown only if NPCs/locations/threads changed.
- `journal_add` / `journal_done`: TODO list management.

### Session Config

```json
{
  "llm": {
    "model": "ollama/llama3.1",
    "api_key": null,
    "api_base": null,
    "temperature": 0.8
  }
}
```

The `model` field accepts any litellm provider string: `openai/gpt-4`,
`anthropic/claude-sonnet-4-20250514`, `ollama/llama3.1`, `groq/llama3-70b-8192`,
etc. Set `api_key` for remote providers.

## Running

```bash
# Start the game
./run.sh

# Or directly
python3 tools/run.py

# No music
python3 tools/run.py --no-music

# Test a generation from CLI (no TUI)
python3 tools/engine.py --action "I head to the market"
```

## Testing Commands
nWhen committing, also use the pre-commit validator to check for oversized Python files.

n## Pre-commit Validation
Before committing, run the pre-commit validator script to ensure no Python file is too large.

```bash
./pre-commit.sh
```

Always run tests before making changes. This prevents runtime errors like missing imports.

```bash
# Quick test (runs import and runtime validation)
./run.sh

# Test with engine action
./run.sh --action "I check on Rina"

# Run tests only
python3 tools/test_imports.py
python3 tools/test_runtime.py
```

### Test Coverage

- `tools/test_imports.py` — Checks for missing imports using AST analysis
- `tools/test_runtime.py` — Verifies module loads without errors, checks for missing classes/methods
- Both tests should pass before proceeding with development

## LLM Strategies

Two strategies for LLM interaction:

1. **"conversational"** — 3-phase approach (prose → summarize → extract)
2. **"tools"** — Single-call approach with tool blocks

Default is "tools" for faster single-call generation.

### Configuration

```json
{
  "llm": {
    "model": "openai/llama3",
    "api_key": "sk-bogus-key",
    "api_base": "http://localhost:8080/v1",
    "temperature": 0.8,
    "timeout": 120,
    "max_tokens": 10000,
    "strategy": "tools"
  }
}
```

### Important Notes

- The `random` module must be imported before use — it's used in dice rolling and die roll generation
- All LLM responses go through `_call_llm` which logs complete output with markers
- The engine extracts both `content` and `reasoning_content` fields from responses (for OpenAI-compatible servers)
- The `generate_with_tools_single()` method handles single-call tool-based generation

## Action Validation

After every turn is generated, a separate lightweight LLM call (`validate_turn` in `tools/engine_lib/validation.py`) checks whether the turn is valid given the character sheet and world state. This catches impossible actions like using items not in inventory, asserting false facts, or attempting nonsensical actions.

- Uses `VALIDATION_PROMPT` template with character + world state
- Low temperature (0.2), low max tokens (256)
- Expects JSON response: `{"valid": true/false, "reason": "..."}`
- If invalid, the turn is rejected with the reason as the narrative
- Operates in both "tools" and "conversational" strategies

## LLM Logging

The engine logs detailed information to `llm.log`:

```
============================================================
=== Turn — 2026-06-28 23:21:58 ===
============================================================
Player: I smash the demon
Dice: 2 (1d6)

[TOOL] Single call — 8615 chars system, 219 chars user
System preview: You are an RPG dungeon master. The player just took an action....
User preview: ## Situation...

┌─ Single tool call ───────────────────────────────────────────────────────────
├─ Model: openai/llama3 | Temp: 0.80 | Tokens: 4096
├─ Messages:
├   [SYSTEM]: You are an RPG dungeon master. The player just took an action.

Narrate the outcome in engaging, vivid prose. Use tools for any mechanics (rolls, damage, state changes). Only use ```tool blocks — no p...
├   [USER]: ## Situation
What do you do?

## Player's Request
I smash the demon

## Instructions
Advance the story based on the player's request. All state is shown above — write the outcome directly.

*A ...
└─ Response:
└   The die cast for this turn is a 2. The player wants to smash the demon. I need to narrate the outcome of that action, incorporating the die result and the combat mechanics.

First, determine if the attack hits. The demon is a large creature (size 5). I assume the player's DEX is 14, so the roll to hit is 1d6 with a 4+ favourable. The die result is 2, which is a failure. However, the player might have a modifier. The demon is a weaver? No, it's a demon. The player is Dillion, who just took -4 HP ...
└───────────────────────────────────────────────────────────────────────


[TOOL] got 17372 chars in 97396.3ms

[TOOL] no tool blocks found
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ Turn Details — 2026-06-28 23:23:36.097                                                                            │
├─────────────────────────────────────────────────────────────────────────────────────────┤
│ Input: I smash the demon              │
│ Last Prompt: What do you do?          │
│ Strategy: tools                                                         │
│ Dice: 2 (1d6)                                                                │
│ Model: openai/llama3 | Temp: 0.8 | Tokens: 10000                            │
│ Output: 0 chars (0 words)                                    │
│ Log Entry:                                                          │
│ Ambience: None                                                    │
│ Tool Calls: 0 ()       │
│─────────────────────────────────────────────────────────────────────────────────────────┘
```

### Debug Markers

- `┌─` / `└─` — Response markers
- `├─` — Message lines
- `[TOOL]` — Tool execution logs
- `[DEBUG LLM RESPONSE]` — Raw LLM response logging
- `[DEBUG RESPONSE LENGTH]` — Response length logging

## TUI Debug Tab

The TUI has a DEBUG tab that shows:

- LLM configuration
- Tool calls with arguments
- LLM errors with tracebacks
- Turn details (timestamps, dice rolls, response sizes, tool call counts)
- Phase progress (Phase 1/3, Phase 2/3, Phase 3/3)

## Key Code References

- `_call_llm` — Core LLM interaction with logging
- `generate_with_tools` — 3-phase conversational approach
- `generate_with_tools_single` — Single-call tool-based approach
- `_log_turn_details` — Comprehensive turn logging
- `_on_debug` — Structured debug output to TUI

## Common Errors & Fixes

### NameError: name 'random' is not defined

Add `import random` at the top of `tools/engine.py`. The `random` module is used in:
- Line ~488: Dice rolling in `_tool_roll`
- Line ~926: Random die roll in `generate_with_tools`
- Line ~1232: Random die roll in `generate_with_tools_single`

### NameError: name 'read_todo' is not defined

The `read_todo` function must be defined in `tools/run.py`. It reads TODO items from `journal.md`.

### NameError: name 'read_log_tail' is not defined

The `read_log_tail` function must be defined in `tools/run.py`. It reads the tail of the session log.

## Testing Workflow

1. **Before coding**: Run `./run.sh` to ensure imports and runtime are valid
2. **After coding**: Run `./run.sh --action "test action"` to test the engine
3. **Before committing**: Run both tests to ensure no missing imports

```bash
# Quick validation
./run.sh

# Test with engine action
./run.sh --action "I check on Rina"

# Manual tests
python3 tools/test_imports.py
python3 tools/test_runtime.py
```

## LLM Response Handling

The engine handles both `content` and `reasoning_content` fields from LLM responses:

```python
text = response.choices[0].message.content or response.choices[0].message.reasoning_content or ""
```

This allows compatibility with OpenAI-compatible servers that return content in the `reasoning_content` field instead of `content`.

## Timeout & Token Configuration

- Default timeout: 120 seconds (configurable in config.json)
- Default max tokens: 10000 (configurable in config.json)
- Adjust these values based on your LLM provider's limits

## Session Files

- `session/config.json` — LLM config (edit directly)
- `session/current_theme` — Active theme id (written by engine on init)
- `session/character.md` — PC state (written by engine)
- `session/world.md` — Realm state (written by engine)
- `session/book.md` — Story archive (written by engine)
- `session/journal.md` — TODO/DONE list (written by engine)
- `session/ambience.md` — Current ambience (written by engine)
- `session/ambience_options.md` — Working copy of ambience config (seeded from theme)
- `session/log/<date>.md` — Session logs (written by engine)
- `session/tweaks.md` — House rules (manual edit)
- `archive/<hero>-<timestamp>/` — Archived completed games

## Save Slots

- `saves/<slot>/` — Full session snapshots (no audio, includes theme ref)
- `saves/_autosave/` — Auto-save before loading another slot
- API in `state.py`: `save_game()`, `load_game()`, `list_saves()`, `delete_save()`

## Theme Management

Themes live in `themes/<id>/` and contain:
- `theme.json` — `{id, name, version, description}`
- `rules/` — Game mechanics (read by engine at runtime)
- `audio/` — Baseline ambience tracks (seeded to session/ on new game)
- `ambience_options.md` — Baseline ambience → file mapping
- `character_template.md` — Starting character sheet template

Engine reads rules from `themes/<current_theme>/rules/` dynamically.
`session/ambience_options.md` and `session/audio/` are working copies seeded from the theme on init.

## LLM Strategies Explained

### "conversational" Strategy

Uses three separate LLM calls:
1. **Prose** — Writes full book_log from context + player action
2. **Summarize** — Condenses book_log into one log line
3. **Extract** — Reads book_log and outputs tool calls for state changes

Retry loop: 3 attempts, Phase 3 fallback to Phase 1 if extraction fails.

### "tools" Strategy

Uses single LLM call with all tools available:
- System prompt instructs LLM to use tools for changes
- Single call outputs narrative + tool blocks together
- No retry loop — if it fails, turn fails
- Extracts tool blocks, applies changes, summarizes in one pass

## Debugging Tips

1. Check `llm.log` for detailed LLM interaction logs
2. Use the TUI's DEBUG tab for structured debug output
3. Run tests before making changes
4. Check config.json for LLM settings
5. Look for missing imports in the engine.py file
6. Verify that the LLM provider is correctly configured in config.json

## End Game Mechanics

The game ends when the LLM outputs the marker `### THE END` in the narrative. Detection is done by checking `END_MARKER in book_log` in `engine.py`.

### End Game Flow

1. LLM outputs narrative containing `### THE END`
2. Engine detects the marker and sets `TurnResult.game_over = True`
3. TUI shows the epilogue and disables input
4. A "Close the Book and Start a New One" button appears
5. On click: `state.archive_session()` copies `session/` to `archive/<hero-name>-<timestamp>/`, clears session state, and restarts the game loop

### Rules for the LLM

- The marker must be exactly `### THE END` (level-3 heading)
- After the marker: provide **Why**, **What Happened**, and **The World After** (2-3 paragraphs)
- Do NOT call state-changing tools after the marker
- Use `read_rules` with `category: "end_game"` for full details

### read_rules Categories

The `read_rules` tool accepts a `category` parameter:
- `mechanics` — Full mechanics reference (default)
- `core` — Condensed core rules
- `character_creation` — Character creation rules
- `end_game` — End-game closure rules
