Spaces:
Sleeping
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 UImood_deltas— ±0.1 per axis, applied to raw mood after the Soul returnsnote_about_opponent— queued for post-match memory generationsave_memory— optionalInlineMemoryRequest; if set,app/memory/inline_save.pypersists it asynchronously mid-matchreferenced_memory_ids— validated as a subset of what the Subconscious surfacedgame_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 > 2100oraggression ≥ 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. Requirestorch+ weights (~300 MB, cached inMAIA2_CACHE_DIR).stockfish_engine.py— Stockfish UCI, variable Elo viaUCI_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)
- Client connects to Socket.IO
/playnamespace withplayer_idcookie;server.pyauthenticates, joins the socket to roommatch:<match_id>. - Client emits
make_move {uci}. room_server.pyvalidates the session is a participant (not spectator), acquiresmatch_lock(match_id)fromapp/concurrency/locks.py.- Calls
apply_player_move_streamed(match, move_input, emitters)inapp/matches/streaming.py:- Validates move legality against
chess.Board; raisesIllegalMoveif invalid. - Applies player move; emits
player_move_applied. - Emits
agent_thinkingwith 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
asynciotasks:run_subconscious(...)on the current board, andengine.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 → emitsagent_chat(ifspeakis non-null) andmood_update.- Applies
mood_deltasfrom Soul; re-smooths; persists. - Checks terminal state; if game over → emits
match_ended, starts post-match processor.
- Validates move legality against
- 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 viaasyncio.run_coroutine_threadsafeto the main event loop (app/sockets/processor_callback.py). Clients that reconnected can fall back to pollingGET /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 atRESIGN_THRESHOLD_CP = −500centipawns for 3 consecutive turns. - Character's turn: the same
_run_engine_and_agentspipeline imported fromstreaming.py— identical Soul + Subconscious + engine sequence as the human path.
Both sides emit events to the match:<match_id> 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:
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 viamemory_gen_target/memory_gen_min/memory_gen_maxinapp/config.py.Inline (mid-match) (
app/memory/inline_save.py):SoulResponse.save_memorycarries anInlineMemoryRequest. If set, anasyncio.create_taskfires and persists the memory without blocking the turn. The turn returns before the save completes — this is fire-and-forget.Post-match (
app/post_match/memory_gen.py): LLM generates 1–3MATCH_RECAP/OPPONENT_SPECIFICmemories 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:
- Receive socket event; validate and authenticate immediately (synchronous, fast).
- Acquire per-session or per-match lock from
app/concurrency/locks.py. - Launch
asyncio.create_task(...)for LLM work — neverawaitthe LLM call inline on the event handler. - 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 sessionmatch_lock(match_id)— serializes character turns within a match; prevents concurrentmake_moveevents 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):
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):
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:
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 HTTPOnChainClayLedger: 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:
- Contract address and ABI from Bhaven
- On agent creation:
balanceOf(owner_wallet)→ list token IDs →tokenURI(tokenId)→ fetch metadata JSON → extractimageandtraits - Store
token_idasmetropolis_token_id(String(80)); use metadataimageasavatar_image_url; injecttraitsinto 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 unauthenticatedget_optional_player(request)— returnsNonefor 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.