# Architecture Chess Club is a FastAPI + Socket.IO application. Domain logic splits across four named layers; infrastructure is SQLite + optional Redis + Google Gemini API + two chess engines. --- ## The four-layer split ### Soul — `app/agents/soul.py` The LLM agent. Runs after the engine move every character turn. Receives a structured `SoulInput` bundle: a `BoardSummary` (prose description of the position — not raw FEN), the current `MoodState`, surfaced memories from the Subconscious, the engine move just played, any pending player chat, and recent match history. Returns a `SoulResponse` (Pydantic schema in `app/schemas/agents.py`) containing: - `speak` — nullable; silence is the default (most moves are silent) - `emotion` + `emotion_intensity` — drives the character video/indicator in the UI - `mood_deltas` — ±0.1 per axis, applied to raw mood after the Soul returns - `note_about_opponent` — queued for post-match memory generation - `save_memory` — optional `InlineMemoryRequest`; if set, `app/memory/inline_save.py` persists it asynchronously mid-match - `referenced_memory_ids` — validated as a subset of what the Subconscious surfaced - `game_action` — controls pre-match room flow (`start_game`, `start_match_vs_kenji`, `propose_goal`); always `"none"` during an active match Does **not** pick chess moves. LLM failures degrade silently to a neutral empty response so gameplay never stalls. The Soul also has lightweight variants for agent contexts: `run_agent_soul_in_match()` (agent responding to player chat mid-match), `run_agent_soul_in_match_move()` (agent's own move turn), and `run_agent_soul_for_room()` (agent pre-match room). ### Subconscious — `app/agents/subconscious.py` Memory retrieval agent. Runs **before** the Soul, concurrently with the engine, on the post-player-move board position (not the post-engine position). Multi-axis scoring across all memories for the character or agent: | Axis | Weight | |---|---| | Semantic similarity (cosine on 384-dim embeddings) | 0.35 | | Trigger keyword match | 0.25 | | Opponent relevance | 0.15 | | Mood alignment | 0.10 | | Recency penalty | −0.15 | If the top-1 score beats top-2 by > 0.15, returns the top 5 directly. Otherwise sends the top 8 to Gemini Flash Lite for a structured re-rank with one-sentence retrieval reasons per memory. Per-match cache with a 3-turn TTL keyed by `(last_player_uci, chat_hash, mood_polarity_bucket)` prevents redundant LLM re-ranks on identical context. Returns 0–5 `SurfacedMemory` objects for the Soul's context window. ### Director — `app/director/` Deterministic code; no LLM calls. Entry point: `choose_engine_config(character, mood, match_context)` in `app/director/director.py`. Computes: - **Effective Elo** = `current_elo + 100·confidence − 150·tilt`, clamped to `[floor_elo, max_elo]` - **Engine selection**: Stockfish beast-mode when `effective_elo > 2100` or `aggression ≥ 9 ∧ confidence > 0.7`; Maia-2 for 1100–1900 range; Stockfish fallback below Maia's range or when Maia-2 isn't installed - **Time budget**: 0.5s (patience=1) → 5s (patience=10), discounted by aggression, capped at 8s Mood state (`MoodState(aggression, confidence, tilt, engagement)`, all `[0,1]`) is managed by `app/director/mood.py`. Exponential smoothing with τ = 3 moves. Persisted to Redis keyed by match ID; falls back to in-process dict if Redis is absent. The Soul's `mood_deltas` are applied via `apply_deltas()` then `smooth_mood()` after each character turn — the Soul nudges mood, the Director reads it. Elo ratchet logic lives in `app/director/elo.py`: ±10% of signed outcome delta per match, clamped ±30; floor rises +25 after 3 consecutive matches where current Elo > floor + 100. ### Body — `app/engine/` The chess engines. Abstract interface: `ChessEngine` ABC + `EngineConfig` + `MoveResult` in `app/engine/base.py`. Three implementations: - **`maia2_engine.py`** — Maia-2 rapid neural network, 1100–1900 Elo band, human-like piece preference. Requires `torch` + weights (~300 MB, cached in `MAIA2_CACHE_DIR`). - **`stockfish_engine.py`** — Stockfish UCI, variable Elo via `UCI_Elo`, returns top-3 candidate moves with centipawn evaluations. - **`mock_engine.py`** — First legal move, fully deterministic. Always available; used in tests and dev-without-engines. Engine lifecycle: `app/engine/registry.py`. Board → prose summary: `board_to_english()` in `app/engine/board_abstraction.py`. This function is why the Soul never receives raw FEN — it gets material counts, castling rights, pinned/hanging pieces, king safety flags, and game phase as readable English. Prevents LLM chess hallucinations (the Soul making up moves it claims to play). Shuffle guard: `app/engine/diversity_guard.py::filter_shuffle_moves()` filters candidates that return a piece to a square it recently left (lookback: 6 plies). Applied to Maia's candidate list before the final move is chosen. --- ## How a single match flows Two orchestration paths share the Soul/Subconscious/engine infrastructure. ### Human-vs-character **Key files:** `app/sockets/server.py` (event handler), `app/sockets/room_server.py` (per-match room), `app/matches/streaming.py` (turn loop) 1. Client connects to Socket.IO `/play` namespace with `player_id` cookie; `server.py` authenticates, joins the socket to room `match:`. 2. Client emits `make_move {uci}`. 3. `room_server.py` validates the session is a participant (not spectator), acquires `match_lock(match_id)` from `app/concurrency/locks.py`. 4. Calls `apply_player_move_streamed(match, move_input, emitters)` in `app/matches/streaming.py`: - Validates move legality against `chess.Board`; raises `IllegalMove` if invalid. - Applies player move; emits `player_move_applied`. - Emits `agent_thinking` with eta hint: `engine_time_budget + agent_thinking_soul_overhead_seconds` (default 1.5s), rounded to 0.5s. This is a soft estimate, not a hard timeout. - Launches two concurrent `asyncio` tasks: `run_subconscious(...)` on the current board, and `engine.get_move(config)`. - When Subconscious completes → emits `memory_surfaced` (memory ribbon populates while engine still thinking). - When engine completes → emits `agent_move` (board updates). - `run_soul(character, soul_input)` with engine move + surfaced memories → emits `agent_chat` (if `speak` is non-null) and `mood_update`. - Applies `mood_deltas` from Soul; re-smooths; persists. - Checks terminal state; if game over → emits `match_ended`, starts post-match processor. 5. Post-match processor (`app/post_match/processor.py`) runs in a background daemon thread. Five steps: engine analysis → feature extraction → Elo ratchet → memory generation → narrative summary. Status streams back via `asyncio.run_coroutine_threadsafe` to the main event loop (`app/sockets/processor_callback.py`). Clients that reconnected can fall back to polling `GET /api/matches/{id}/post_match_status`. The `memory_surfaced` → `agent_move` ordering is the headline UX property: the memory ribbon populates during the "thinking" state rather than after the move lands. ### Agent-vs-character **Key files:** `app/sockets/room_server.py` (deploy trigger), `app/matches/agent_streaming.py` (server-side loop) The match is fully automated. After the player deploys their agent from the agent room, `room_server.py` creates the match and fires `asyncio.create_task(run_agent_match(...))`. The watching player is a spectator in their own match — they see events but don't make moves. The loop in `agent_streaming.py` alternates: - **Agent's turn**: engine picks a move from the agent's Elo configuration; `run_agent_soul_in_match_move()` generates chat. Auto-resign triggers at `RESIGN_THRESHOLD_CP = −500` centipawns for 3 consecutive turns. - **Character's turn**: the same `_run_engine_and_agents` pipeline imported from `streaming.py` — identical Soul + Subconscious + engine sequence as the human path. Both sides emit events to the `match:` Socket.IO room. `agent_streaming.py` imports private helpers from `streaming.py` (`_run_engine_and_agents`, `_finalize_outcome`, `_persist_engine_move`, etc.) — this is the main coupling between the two flows. ### Pre-match rooms Pre-match chat (human talking to a character before starting): Socket.IO `/room` namespace, `app/sockets/room_server.py`. The `SoulResponse.game_action` field drives match initiation — when the Soul returns `start_game` (character-side room) or `start_match_vs_kenji` (agent-side room), the server emits `game_started {match_id, redirect_url}` and the browser redirects automatically. --- ## Memory model ### Storage `Memory` rows (`app/models/memory.py`) carry either `character_id` or `agent_id` — exactly one is non-null. Character memories belong to a character (preset or user-created); agent memories belong to the player's agent. No cross-ownership. Each memory has: `narrative` (prose, 1–3 sentences), `triggers` (keyword list, ≥3 required), `valence` (float sentiment), `scope` (enum: `character_lore` / `opponent_specific` / `cross_player`), `embedding` (JSON array, 384 floats), `surface_count` (retrieval counter). ### Generation Three paths: 1. **Preset seeding** (`app/characters/memory_generator.py`): LLM generates 30–50 memories at first startup from backstory, voice, and quirks. Embeddings created inline at save. Configurable via `memory_gen_target` / `memory_gen_min` / `memory_gen_max` in `app/config.py`. 2. **Inline (mid-match)** (`app/memory/inline_save.py`): `SoulResponse.save_memory` carries an `InlineMemoryRequest`. If set, an `asyncio.create_task` fires and persists the memory without blocking the turn. The turn returns before the save completes — this is fire-and-forget. 3. **Post-match** (`app/post_match/memory_gen.py`): LLM generates 1–3 `MATCH_RECAP` / `OPPONENT_SPECIFIC` memories after each game. Validates ≥3 triggers per memory; retries once with explicit feedback if validation fails. Deduplicates against existing memories at 0.92 cosine similarity threshold before persisting. ### Retrieval `app/agents/subconscious.py` (see above). Vector search is in-Python cosine similarity via NumPy in `app/memory/vector_store.py` — no external vector DB. The interface (`upsert`, `search`, `get_embedding`) is designed to be swapped to sqlite-vec without changing any callers. --- ## Streaming architecture | Namespace | File | Purpose | |---|---|---| | `/room` | `app/sockets/room_server.py` | Pre-match chat (both character rooms and agent rooms) | | `/play` | `app/sockets/server.py` + `room_server.py` | In-match gameplay, post-match streaming | | Lobby/matchmaking | `app/sockets/lobby_server.py` | PvP lobby and matchmaking queue | | Presence | `app/sockets/presence_server.py` | Live online count | All LLM work follows the same pattern — this is the non-negotiable rule for new features: 1. Receive socket event; validate and authenticate immediately (synchronous, fast). 2. Acquire per-session or per-match lock from `app/concurrency/locks.py`. 3. Launch `asyncio.create_task(...)` for LLM work — **never** `await` the LLM call inline on the event handler. 4. Emit events as work completes. The synchronous-HTTP path (`app/matches/service.py::apply_player_move`) was replaced in Phase 3b specifically because inline `await` on LLM calls blocked all other socket events. That antipattern must not come back. --- ## Concurrency primitives `app/concurrency/locks.py` — two dict registries of `asyncio.Lock` objects: - `chat_session_lock(session_id)` — serializes LLM calls within a single pre-match chat session - `match_lock(match_id)` — serializes character turns within a match; prevents concurrent `make_move` events from racing Both are context managers (`async with match_lock(match_id):`). Both are in-process dict-backed. **Multi-worker swap seam:** The module docstring spells it out: replace the `dict[str, asyncio.Lock]` with a Redis `SET NX EX` pattern using `redis.asyncio`. The call sites don't change — they only see the context manager interface. All state that would break under multi-worker (disconnect registry, spectator counts, Socket.IO room membership) has the same swap point. Memory note: the dicts grow monotonically — one Lock per unique session/match ID, never freed. At current scale (dozens of concurrent sessions) negligible. At scale, add TTL eviction. --- ## Feature flag system Three boolean settings in `app/config.py`, loaded via pydantic-settings from env vars: | `Settings` field | Env var | Default | |---|---|---| | `show_player_agents` | `SHOW_PLAYER_AGENTS` | `True` | | `show_clay_economy` | `SHOW_CLAY_ECONOMY` | `False` | | `show_agent_autonomy` | `SHOW_AGENT_AUTONOMY` | `False` | **Route guard pattern** (`app/web/routes.py`): ```python def _require_player_agents() -> None: if not get_settings().show_player_agents: raise HTTPException(status_code=404) # Applied with: @router.get("/agents", dependencies=[Depends(_require_player_agents)]) ``` Routes that should be hidden return 404 (not 403 — the flag makes the feature non-existent, not forbidden). **Template injection** (module level in `app/web/routes.py`): ```python templates.env.globals["feature_flags"] = { "show_player_agents": _ff.show_player_agents, "show_clay_economy": _ff.show_clay_economy, "show_agent_autonomy": _ff.show_agent_autonomy, } ``` Templates access `feature_flags.show_player_agents` to conditionally render nav links, wager panels, and autonomy controls. **Adding a new flag:** (1) Add a `bool` field to `Settings` in `app/config.py`. (2) Inject into `templates.env.globals` in `app/web/routes.py`. (3) Write a `_require_X()` guard function. (4) Apply `Depends(_require_X)` on hidden routes. --- ## Seam-swap interfaces These are the three places where Lucas needs to wire Chess Club into the wider Metropolis platform. They're designed to be reimplemented without touching call sites. ### `ClayLedger` — `app/economy/clay_ledger.py` Current implementation: `SqliteClayLedger` — all wagering in Chess Club's own SQLite DB. Abstract interface: ```python class ClayLedger: def get_balance(self, player_id: str) -> int: ... def debit(self, player_id, amount, reason, match_id=None) -> ClayTransaction: ... def credit(self, player_id, amount, reason, match_id=None) -> ClayTransaction: ... def transfer(self, from_id, to_id, amount, reason, match_id=None) -> tuple[...]: ... def transactions_for_player(self, player_id, limit=50, reason=None) -> list[...]: ... ``` All call sites import via `from app.economy.clay_ledger import get_ledger, InsufficientFunds`. `get_ledger()` returns the module-level singleton initialized in `app/main.py`. To swap: subclass `ClayLedger`, implement the five methods, update `get_ledger()`. No other files change. Two likely replacement implementations: - **`MetropolisInternalLedger`**: delegates to Bhaven's internal balance API over HTTP - **`OnChainClayLedger`**: calls a Polygon ERC-20 contract (ABI from PolygonScan, `transfer` → on-chain) ### `PlayerAgent.metropolis_token_id` — `app/models/player_agent.py:31` Currently `nullable=True`, always null. Intended to bind a player agent to a Metropolis NFT when MetropolisX integration lands: 1. Contract address and ABI from Bhaven 2. On agent creation: `balanceOf(owner_wallet)` → list token IDs → `tokenURI(tokenId)` → fetch metadata JSON → extract `image` and `traits` 3. Store `token_id` as `metropolis_token_id` (String(80)); use metadata `image` as `avatar_image_url`; inject `traits` into personality generation ### Auth seam — `app/auth.py` Currently cookie-based. Cookie name: `PLAYER_COOKIE` (plain player UUID, no signing by default; `SESSION_SECRET` enables signing). Two entry points used by all route handlers: - `require_player(request)` — raises 401 (API) or redirects to `/login` (HTML) if unauthenticated - `get_optional_player(request)` — returns `None` for unauthenticated requests To swap to JWT/SSO with the Metropolis platform: replace these two functions in `app/auth.py`. No other files need to change. --- ## Tech stack reference | Package | Min version | Role | |---|---|---| | Python | 3.11 | Runtime | | FastAPI | 0.115 | HTTP framework + dependency injection | | SQLAlchemy | 2.0 | ORM + session factory | | Pydantic v2 | 2.7 | Schema validation; structured LLM output | | Alembic | 1.13 | Schema migrations (Phase 3a+) | | python-socketio | 5.11 | Real-time Socket.IO server | | google-genai | 0.3 | Gemini API client (Soul + memory generation) | | sentence-transformers | 2.7 | Memory embeddings (`all-MiniLM-L6-v2`, 384 dims) | | chess | 1.10 | Board state, move legality, PGN parsing | | maia2 | 0.1 | Human-like engine (1100–1900 Elo) | | Stockfish (Python) | 3.28 | High-skill engine + post-match analysis | | argon2-cffi | 23.1 | Password hashing (Phase 4 email/password auth) | | Redis | 5.0 | Mood persistence (optional; in-process fallback when absent) | | NumPy | 1.24 | Cosine similarity for memory retrieval | LLM model: configured via `gemini_model` in `app/config.py` (current default: `gemini-3.1-flash-lite-preview`). Change there to upgrade.