Now I have a thorough understanding of the entire codebase. Let me analyze the code in detail.
## 1. Model Configuration
In `game_engine.py:32-36`, the `gemini_model()` function:
```python
def gemini_model():
model = os.environ.get("GEMINI_TEXT_MODEL") or os.environ.get("GEMINI_MODEL") or "gemini-2.5-flash"
if "live" in model:
return "gemini-2.5-flash"
return model
```
The .env file has `GEMINI_MODEL=gemini-3.1-flash-live-preview`.
So `gemini_model()` will:
1. Check `GEMINI_TEXT_MODEL` env var → not set
2. Check `GEMINI_MODEL` env var → `"gemini-3.1-flash-live-preview"`
3. Check if "live" is in the model → YES, "live" is in "gemini-3.1-flash-live-preview"
4. Return `"gemini-2.5-flash"` (hardcoded fallback)
So the **actual model used** is `gemini-2.5-flash`, regardless of what the .env says, because the `if "live" in model` check catches "gemini-3.1-flash-live-preview" and replaces it with "gemini-2.5-flash".
## 2. How replies are generated (full flow)
### Flow: user interaction → reply generation
1. **`POST /api/interact`** in `app.py:97-98` calls `resolve_interaction(payload)`
2. **`resolve_interaction(payload)`** in `game_engine.py:644-705`:
- Normalizes state (line 645)
- Extracts intent (lines 646-651)
- Applies schedule effects if type is "schedule" (line 661)
- Applies gift effects if type is "gift" (line 662)
- Selects scene (line 663) — picks a node from `ROUTES` based on state
- **Composes prompt** (line 664) — `compose_prompt(state, intent, node, event, gift, schedule_effects)`
- **Calls Gemini** (line 667): `call_gemini(prompt)` wrapped in `validate_ai()`
- **On failure** (lines 668-670): falls back to `mock_gemini(prompt)` wrapped in `validate_ai()`
- **Applies results** (line 671): `apply_scene_result(...)`
- Returns the response dict (lines 689-705)
3. **`compose_prompt()`** in `game_engine.py:371-417` builds a long prompt instructing the model to act as a VN scene author. It includes:
- Scene/world context
- Bagger character description
- Style bible rules
- Game state, location, relationship data
- The player's free text message
- Gift/schedule effects
- Instructions to return **valid JSON** with specific fields: `reply`, `emotionalRead`, `bondDelta`, `trustDelta`, `warmthDelta`, `depthDelta`, `courageDelta`, `suggestedFlags`, `routePressure`, `nextTone`, `memory`, `visual`
4. **`call_gemini(prompt)`** in `game_engine.py:420-444`:
- **If `BAGGER_MOCK_GEMINI == "1"`**: calls `mock_gemini(prompt)` → static text
- **If no API key**: calls `mock_gemini(prompt)` → static text
- **Otherwise**: Makes a **non-streaming** HTTP POST to the Gemini API
- URL: `https://generativelanguage.googleapis.com/v1beta/models/{gemini_model()}:generateContent?key={api_key}`
- Temperature: 0.86
- maxOutputTokens: 1100
- `thinkingConfig: {"thinkingBudget": 0}` (no thinking/reasoning)
- `responseMimeType: "application/json"` (forces structured JSON output)
- Parses the JSON response back
5. **`validate_ai(data, node)`** in `game_engine.py:533-551` sanitizes the AI output:
- If `data.get("reply")` is empty/falsy → **falls back to `mock_gemini("")["reply"]`** (static text!)
- Clamps all numeric fields to valid ranges
- Validates `nextTone` and `visual` against allowed values, defaulting to "listening"
- Cleans/filters other fields
6. **`mock_gemini(prompt)`** in `game_engine.py:457-478` — the static fallback:
- Always returns the same paragraph regardless of context (except minor variations based on matching "freund" or "work" in the prompt)
- The reply text is always: *"Der Motor wurde leiser, als haette die Szene ploetzlich weniger Platz fuer Ausreden. Meine Schaufel blieb ueber dem Boden stehen. Wenn du morgen wiederkommst, werde ich so tun, als haette ich nicht darauf gewartet. Aber die Lampe in meiner Kabine weiss es besser."*
- emotionalRead, bondDelta etc. vary slightly based on detecting "friend" or "work" in the player's message
7. **`call_gemini_for_ending(state, route, ending)`** in `game_engine.py:520-530`:
- If `BAGGER_MOCK_GEMINI == "1"`: returns `FALLBACK_ENDINGS[kind]` (static pre-written text)
- Calls `call_gemini(prompt)` for the ending
- If that fails: falls back to `FALLBACK_ENDINGS[kind]`
- `FALLBACK_ENDINGS` has pre-written text for each ending type (lines 510-517)
## 3. Where static/fallback text is
- **`mock_gemini()`** at `game_engine.py:457-478` — the main fallback for `reply`, `emotionalRead`, `memory`, `visual` fields
- **Line 539**: `validate_ai()` falls back to `mock_gemini("")["reply"]` if AI reply is empty
- **Lines 668-670**: `resolve_interaction()` catches errors and falls back to `mock_gemini(prompt)`
- **`FALLBACK_ENDINGS`** at `game_engine.py:510-517` — pre-written ending prose
- **Lines 524-525, 530**: `call_gemini_for_ending()` falls back to FALLBACK_ENDINGS
## 4. Streaming vs non-streaming
**Non-streaming only.** The API call at line 441 uses `urllib.request.urlopen()` which is a synchronous HTTP request to `generateContent` (not `streamGenerateContent`). There is no streaming anywhere in the codebase.
## 5. Key observations about fields
- **`reply`**: Generated by AI in `call_gemini()`, validated by `validate_ai()`, fallback to `mock_gemini()["reply"]`
- **`emotionalRead`**: From AI, or set to `"fallback after {error}"` in the error handler (line 670)
- **`visual`**: From AI, validated to be one of the allowed set, defaults to `"listening"`
- **`memory`**: From AI, or a fallback generated string in `apply_scene_result()` line 606
- **`endingProse`**: From `call_gemini_for_ending()`, which uses `FALLBACK_ENDINGS` (static)
## 6. Caching / pregeneration
There is **no caching** of dialogue anywhere. Every `resolve_interaction` call hits the Gemini API anew. The `.bak` files suggest manual backups, not caching.
## 7. game_data.py pre-written content
`game_data.py` has pre-written dialogue in:
- **`CHOICE_SETS`** (lines 306-362): These are the player's choice labels and messages, not the AI bagger replies
- **No pre-written bagger dialogue** — all bagger speech comes from the AI or the `mock_gemini` fallback
## 8. What would need to change to use gemini-3.1-flash-lite
The .env has `GEMINI_MODEL=gemini-3.1-flash-live-preview` but the code replaces "live" models with "gemini-2.5-flash".
To use `gemini-3.1-flash-lite` (or the correct model ID `gemini-3.1-flash-lite-preview` or whatever the actual ID is):
1. **Remove/modify the "live" check** in `gemini_model()` at `game_engine.py:34-35`
2. **Set either `GEMINI_TEXT_MODEL` or `GEMINI_MODEL`** to the desired model ID (e.g., `gemini-3.1-flash-lite-preview`)
3. ✅ The API key is already set in the .env file
The current behavior is that `gemini-3.1-flash-live-preview` gets downgraded to `gemini-2.5-flash` because of the "live" substring check.Here is the complete analysis:
---
## 1. Model Currently Configured: `gemini-2.5-flash`
**Why:** The `.env` sets `GEMINI_MODEL=gemini-3.1-flash-live-preview`, but `game_engine.py:32-36` has:
```python
def gemini_model():
model = os.environ.get("GEMINI_TEXT_MODEL") or os.environ.get("GEMINI_MODEL") or "gemini-2.5-flash"
if "live" in model: # ← THIS LINE
return "gemini-2.5-flash" # ← ALWAYS OVERRIDES
return model
```
Since `"gemini-3.1-flash-live-preview"` contains "live", the function **always returns `"gemini-2.5-flash"`**. The "live" guard was presumably added because the `streamGenerateContent` endpoint isn't used here, but it silently blocks any model ID containing "live" — including non-streaming preview models.
---
## 2. Complete Flow: User Interaction → Reply
**Step 1: HTTP Handler** (`app.py:97-98`)
- `POST /api/interact` → calls `resolve_interaction(payload)`
**Step 2: resolve_interaction** (`game_engine.py:644-705`)
- Normalizes game state (line 645)
- Extracts `intent` (type, message, route, etc.) (lines 646-651)
- Applies schedule effects (work/study/rest) (line 661)
- Applies gift effects (line 662)
- Selects a scene `node` from `ROUTES` based on state (line 663)
- **Composes a prompt** via `compose_prompt()` (line 664) — a structured German-language prompt with VN context, character voice, scene premise, player's free-text, relationship stats, etc.
- **Calls Gemini** (line 667): `validate_ai(call_gemini(prompt), node)`
- If error → **silent fallback**: `validate_ai(mock_gemini(prompt), node)` (lines 668-670)
- **Applies results** to game state via `apply_scene_result()` (line 671)
- Resolves ending (line 672) → if ending triggered, calls `call_gemini_for_ending()` (line 673)
- Returns response dict with `reply`, `emotionalRead`, `visual`, `memory`, `endingProse`, etc. (lines 689-705)
**Step 3: call_gemini** (`game_engine.py:420-444`)
- If `BAGGER_MOCK_GEMINI=1` → returns `mock_gemini()` static text immediately (line 421-422)
- If no API key → returns `mock_gemini()` (line 424-425)
- Otherwise: **non-streaming** HTTP POST to `https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}`
- Temperature 0.86, maxOutputTokens 1100
- `thinkingConfig: {"thinkingBudget": 0}`
- `responseMimeType: "application/json"` (forces structured JSON output)
- Parses JSON response and returns dict
**Step 4: validate_ai** (`game_engine.py:533-551`)
- Sanitizes all AI output fields
- If `reply` is empty → **falls back to `mock_gemini("")["reply"]`** (line 539)
- Clamps deltas, validates tone/visual enums, filters flags
---
## 3. Static/Fallback Text Locations
| Location | Lines | What | When Used |
|---|---|---|---|
| `mock_gemini()` | `457-478` | Full static reply JSON (always same paragraph about "Motor leiser") | `BAGGER_MOCK_GEMINI=1`, no API key, or AI call fails |
| `resolve_interaction` catch block | `668-670` | `mock_gemini(prompt)` | Any HTTP/network/JSON/timeout error from Gemini |
| `validate_ai` empty reply fallback | `539` | `mock_gemini("")["reply"]` | AI returns empty/null reply |
| `FALLBACK_ENDINGS` dict | `510-517` | 6 pre-written ending prose paragraphs (bad/missed/friendship/normal/true/secret) | `BAGGER_MOCK_GEMINI=1` or AI call fails in `call_gemini_for_ending()` |
---
## 4. Streaming vs Non-Streaming
**Non-streaming only.** The call at `game_engine.py:441` uses `urllib.request.urlopen()` to hit the `:generateContent` REST endpoint. There is zero streaming anywhere in the codebase — no SSE, no `streamGenerateContent`, no async.
---
## 5. How Each Field Is Generated
| Field | Source | File:Lines | Note |
|---|---|---|---|
| `reply` | AI → `call_gemini()` → `validate_ai()` | `420-444`, `533-551` | Falls through to `mock_gemini` on any failure |
| `emotionalRead` | AI → `validate_ai()` | same | On error: set to `"fallback after {error}"` (line 670) |
| `visual` | AI → `validate_ai()` | `549-550` | Must be one of `{listening,shy,proud,guarded,digging,crisis,confession}`, else `"listening"` |
| `memory` | AI `memory` field OR fallback string | `606` | Fallback: `"Tag {day}, {period}: {name} erinnert sich an {chapter}."` |
| `endingProse` | `call_gemini_for_ending()` → `FALLBACK_ENDINGS` | `520-530`, `510-517` | Static pre-written prose unless AI is live and succeeds |
| `deltas` (bond/trust/warmth/depth/courage) | AI → clamped in `validate_ai()` | `541-545` | Bond: -8..12, trust/warmth/depth: -8..12, courage: -5..8 |
| `routePressure` | AI → clamped in `validate_ai()` | `547` | Each pressure: -2..3 |
---
## 6. What to Change to Use a Non-"live" Model (e.g., gemini-3.1-flash-lite)
The model ID in `.env` is `gemini-3.1-flash-live-preview`. To actually use it (or `gemini-3.1-flash-lite-preview` / the final `gemini-3.1-flash-lite`):
1. **`game_engine.py:34-35`** — remove or fix the "live" guard:
```python
# Current (broken):
if "live" in model:
return "gemini-2.5-flash"
# Fix: just remove these two lines entirely,
# or make the check specific:
if "live" in model and "streamGenerateContent" not in current_function:
return model # don't silently downgrade
```
2. **Set the correct model ID** in `.env`:
```
GEMINI_MODEL=gemini-3.1-flash-lite
```
(or `gemini-3.1-flash-lite-preview` if still in preview)
3. **Check `maxOutputTokens`** (`game_engine.py:430`) — 1100 should be fine for flash-lite
4. **Check `temperature`** (`game_engine.py:429`) — 0.86 may need tuning for a lite model
5. **Check `thinkingConfig`** (`game_engine.py:431`) — `{"thinkingBudget": 0}` is fine (disables thinking)
The API key (line 11 of `.env`) and infrastructure are already in place. The model URL is constructed dynamically at line 436 using `gemini_model()`, so swapping the env var will automatically update the endpoint.