EchoSpire — Architecture Document¶
Status: Current — verified 2026-08-27. The project tree and test counts were corrected in that pass: the previous version omitted
EchoSpire.Wpf(the shipping client),Contracts,Infrastructure,SvgTools, andwww-react, and listedEchoSpire.AdminandEchoSpire.Unity, neither of which is insrc/. This is the single technical truth. The system runs SQL Server + Kusto.
Last updated: March 5, 2026
What Is EchoSpire?¶
EchoSpire is a deck-building roguelike in the vein of Slay the Spire, set in a fractured reality called the Echo. Players choose a faction and class, build a deck of cards, traverse procedurally-generated rift maps, fight turn-based battles, and try to defeat a faction-specific boss. Deaths are explained in-lore as "failed echoes" — your consciousness snaps back to a safe zone, making permadeath feel earned rather than punitive.
Five factions. Five classes. Every combination plays differently because factions define why you fight and classes define how you fight.
Solution Layout¶
EchoSpirePortals.slnx
├── src/
│ ├── EchoSpire.Core ← Pure C# game logic library (no framework deps)
│ ├── EchoSpire.API ← ASP.NET Core Web API (data, auth, telemetry)
│ ├── EchoSpire.Contracts ← Shared DTOs between API and clients
│ ├── EchoSpire.Infrastructure← API client and cross-cutting infrastructure
│ ├── EchoSpire.Wpf ← WPF client — THE SHIPPING CLIENT
│ ├── EchoSpire.ConsoleGame ← CLI game client (original Core proof)
│ ├── EchoSpire.Simulation ← Headless batch runner for balance testing
│ ├── EchoSpire.SvgTools ← SVG asset tooling
│ ├── admin-react ← React admin dashboard (replaced Blazor EchoSpire.Admin)
│ └── www-react ← Public web front end
├── tests/ ← ~693 test methods, 2026-08-27
│ ├── EchoSpire.Core.Tests ← ~443 (combat, effects, mechanics, economy, RNG, telemetry)
│ ├── EchoSpire.API.Tests ← ~208 (integration, auth, seeding, AI providers)
│ ├── EchoSpire.ConsoleGame.Tests ← ~22 (enemy actions)
│ ├── EchoSpire.Wpf.Tests ← ~15
│ └── EchoSpire.Simulation.Tests ← ~5
├── FactionTutorials/ ← Narrative design docs for each faction's tutorial
├── story/bible/ ← story canon (see story/claude.md)
├── _archive/gdd/ ← superseded Game Design Documents (frozen)
└── _archive/docs-official/ ← superseded specs, incl. TechRequirements v1 (2026-02-27)
and the GDD consistency audit (Issues.txt)
Why This Structure?¶
The project is split this way for a specific reason: EchoSpire.Core is the game, everything else is infrastructure.
Core has zero dependencies on ASP.NET, Unity, or any database. It contains all combat rules, card effects, map generation, economy logic, and state management as plain C#. This means:
- The Console Game can run the full game by implementing
IGameUIwith terminal rendering - The Simulation Engine can run thousands of headless games without any UI
- Unity (when built) will implement the same
IGameUIinterface with 3D rendering - All three consume the same combat system, same card resolver, same effect pipeline
The API doesn't contain game logic — it stores and serves game data (card definitions, enemy configs, faction metadata). The game logic lives in Core and operates on that data.
Dependency Graph¶
EchoSpire.Core (standalone — no project references)
↑
├── EchoSpire.API (serves game data, stores heroes/runs)
├── EchoSpire.ConsoleGame (CLI client, calls API for data)
├── EchoSpire.Simulation (headless runner, calls API for data)
└── EchoSpire.Admin (admin UI, calls API for CRUD)
Every consumer talks to Core directly for game logic and to the API over HTTP for persistence. This is intentional — it means the game can technically run fully offline if you hardcode the data (which the tutorials already do).
Core Systems — How the Game Actually Works¶
The Combat System¶
This is the heart of the game. It's built around three concepts: a state machine, an event pipeline, and composable handlers.
CombatStateMachine¶
Located in Combat/CombatStateMachine.cs. Manages three phases: Init → Execution → Conclusion.
A combat turn flows like this:
- Player plays cards —
PlayCard()routes throughCardResolver - Player ends turn —
EndPlayerTurn()triggers the full turn cycle: - Fire
TurnEndingevent (handlers do cleanup, status ticks) - Discard hand (except Retain cards)
- Enemy phase: each enemy executes its intent pattern
- Remove dead enemies/constructs
- Check win/loss conditions
- Start new turn: reset energy, draw hand, fire
TurnStarting/TurnStarted
Why an Event Pipeline?¶
The CombatHandlerPipeline fires CombatEvent objects through registered ICombatHandler instances. Every mechanic in the game is a handler — class mechanics, faction passives, relics, status effects.
This matters because mechanics compose without knowing about each other. The Density handler (Anchor class) retains block on TurnStarting. The Stasis handler cancels enemy actions on BeforeEnemyAction. The Relic handler triggers on AfterDamageDealt. None of them reference each other. They all just subscribe to events.
Handler priorities enforce ordering: - 0–49: Core systems (Stasis, status drain) - 50–99: Class mechanics (Density, Echo-Lock, Overload, Constructs, Mutation) - 100–149: Faction passives - 200–249: Relics - 500+: Telemetry
CombatHandlerFactory¶
CombatHandlerFactory.RegisterHandlers() reads the player's ClassMechanicId and FactionPassiveId from RunState and registers the right handlers. This is where the class/faction combination becomes mechanically real.
ClassMechanicId Handler
───────────────────── ──────────────────
DENSITY DensityHandler (Anchor)
ECHO_LOCK EchoLockHandler (Drifter)
OVERLOAD OverloadHandler (Conduit)
CONSTRUCT ConstructHandler (Machinist)
MUTATION MutationHandler (Catalyst)
FactionPassiveId Handler
───────────────────── ──────────────────
STASIS_LOCK StasisLockPassive (Valerii)
CALIBRATION_PROTOCOL CalibrationProtocolPassive (Axiom)
GILDED_SYPHON GildedSyphonPassive (Syndicate)
TEMPORAL_ECHO TemporalEchoPassive (Censors)
SCRAP_PROTOCOL ScrapProtocolPassive (Salvari)
CardResolver¶
CardResolver.TryPlayCard() is the card-play pipeline:
- Check if card has
Unplayablekeyword → reject - Check energy cost (Overload keyword allows energy debt)
- Remove card from hand
- Fire
BeforeCardPlayed(handlers can cancel) - Resolve targets based on
TargetTypeenum - Execute each
CardEffectthrough theEffectRegistry - Route card to ExhaustPile (if Exhaust keyword) or DiscardPile
- Fire
AfterCardPlayed
The Effect System¶
Cards don't contain logic — they contain effect IDs and parameter bags. A card definition looks like:
{
"name": "Strike",
"effects": [
{ "effectId": "DEAL_DAMAGE", "params": { "baseDamage": 6 } }
]
}
The EffectRegistry maps effect IDs to IEffect implementations. Each effect receives an EffectContext (combat state, source, target, pipeline reference) and a JsonObject of parameters.
Effects implemented:
| Effect ID | What It Does |
|---|---|
DEAL_DAMAGE |
Damage through block, respects STRENGTH/BREACH/ECHO_BURN modifiers |
GAIN_BLOCK |
Grant block, respects STRUCTURAL_FRACTURE penalty |
APPLY_STASIS |
Stackable status — freezes enemies, retains player block |
APPLY_STATUS |
Generic status application by ID |
APPLY_ECHO_LOCK |
Drifter mechanic — 5-stack trigger for bonus damage |
GAIN_DENSITY |
Anchor mechanic — increases block retention |
GAIN_ENERGY |
Refund or generate energy |
DRAW_CARDS |
Draw N cards from draw pile |
HEAL |
Restore HP |
DEPLOY_CONSTRUCT |
Machinist — place a construct in a grid slot (max 3) |
OVERCLOCK_CONSTRUCT |
Trigger construct's auto-effect, construct takes 50% self-damage |
ECHO_BURN |
Syndicate — self-damage for damage bonus on next attack |
ECHO_RECALL |
Censors — pull a card from discard |
LOGIC_STREAM |
Axiom — guaranteed card draw |
MUTATE_CARD |
Catalyst — transform a card into its Anomaly version |
Why data-driven effects? Because card definitions live in the database, not in code. A game designer can create a new card in the admin portal by picking an effect ID and setting parameters — no code changes needed. The effect implementations are the "verbs" of the game; cards are sentences composed from those verbs.
The Map System¶
MapGenerator.GenerateRealm() creates a procedural rift map from a seeded RNG fork.
Maps are directed acyclic graphs organized in rows: - Row 0: Starting combat node - Rows 1–(n-2): 2–3 nodes per row with random types - Final row: Boss node
Node types are selected by weighted random: Combat 45%, Elite 12%, Event 15%, Shop 8%, Rest 10%, Treasure 5%, Anchor 5%. Edges connect adjacent rows with at least one connection per node.
MapGenParams controls all generation parameters (node counts, branching factors, weights, transit interrupt chances). These are stored in the database and served by the API, so balance tweaks don't require code changes.
Fog of War: only revealed nodes are visible. Visiting a node reveals its neighbors. Anchor nodes reveal the entire map.
The RNG System¶
SeededRNG is the determinism backbone. A single 64-bit unsigned seed controls the entire run. The key feature is namespace forking:
var masterRng = new SeededRNG(seed);
var realmRng = masterRng.Fork("realm", 0); // Independent stream for realm 0
var combatRng = masterRng.Fork("combat", 3); // Independent stream for combat 3
var rewardRng = masterRng.Fork("reward", 3); // Independent stream for rewards
Each fork produces an independent, deterministic stream. This means: - Same seed + same choices = byte-identical run - Combat RNG doesn't affect map generation - Reward RNG doesn't affect enemy behavior
Why this matters: Reproducibility for bug reports, simulation analysis, and competitive seeded runs.
The Economy System¶
EconomyManager handles all resource math: gold rewards, card costs, shop prices, heal amounts, relic pricing, splice (card upgrade) costs.
All values come from EconomyConfig, which maps to economy_params in the database. Current defaults:
- Combat reward: 15 gold / Elite: 30 / Boss: 75
- Card removal: 75 gold
- Rest healing: 30% of max HP
- Card sell prices: Common 15 / Uncommon 30 / Rare 60
- Splice (upgrade) prices: Retain 75 / Exhaust 50 / DamageBoost 100 (+3 damage)
State Management¶
RunState is the complete snapshot of a game in progress:
- Player identity: HeroId, ClassId, FactionId
- Resources: CurrentLife, MaxLife, Gold
- Deck & relics
- Map state:
List<RealmState>with nodes, edges, visited/revealed sets - Mechanic state:
ClassMechanicId,FactionPassiveId,MechanicCounters - Progression: CurrentRealmIndex, CurrentNodeId, Outcome
- Metadata: Seed, StartedAt, CompletedAt
CombatState is the per-combat snapshot: player entity, enemies, hand/draw/discard/exhaust piles, energy, turn number, protectables, constructs.
The RNG is reconstructed from the seed on load — it's not serialized.
The AI System¶
IDecisionPolicy defines how automated agents play:
CardPlay? ChooseCard(CombatState state);
int ChoosePath(RunState state, List<int> availableNodeIds);
Card? ChooseReward(RunState state, List<Card> options);
bool UseAbility(CombatState state);
Currently one implementation: RandomPolicy — picks random playable cards, random paths, random rewards, 30% chance to use abilities. This is the baseline for simulation balance testing. Smarter policies (greedy, heuristic, MCTS) are planned.
The UI Abstraction¶
IGameUI defines every interaction the game needs from a display layer:
- Screen management:
ShowScreen(),Divider(),WaitForKey() - Information:
Info(),Warning(),Error(),Success() - Combat rendering:
ShowCombatState(),ShowCombatVictory() - Map rendering:
ShowMap(),ShowNodeChoice() - Shop/event/rest screens
- Input:
GetPlayerChoice(),GetText() - Narrative:
ShowNarrative(),ShowStoryBeat()
ConsoleGameUI implements this with terminal rendering via ConsoleRenderer. Unity will implement the same interface with 3D visuals. The game logic never knows or cares which renderer is active.
The Telemetry System¶
GameTelemetrySession records structured events through IGameTelemetryWriter:
Event types: RunStarted, RunEnded, PhaseStarted, PhaseEnded, CombatStarted, CombatEnded, TurnEnded, CardPlayed, EnemyAction, GoldChanged, DeckChanged, RelicAcquired, NodeEntered, EventChoice, ErrorOccurred
Writers:
- KustoTelemetryWriter → Azure Data Explorer (production analytics)
- JsonFileTelemetryWriter → local NDJSON files (dev/fallback)
- CompositeTelemetryWriter → chains multiple writers
- NullTelemetryWriter → no-op (testing)
Every combat action, every economic transaction, every map choice is recorded. This is designed to feed automated balance analysis — "which faction/class combo has the highest win rate?" and "where do players die most often?"
The API Layer¶
Purpose¶
The API doesn't run game logic. It stores game data (card definitions, enemy stats, faction configs) and player data (heroes, saved runs). The game clients load data at startup, then run entirely in Core.
Database (SQL Server via Stored Procedures)¶
Tables with JSON support for flexible data:
| Table | Purpose | JSON Columns |
|---|---|---|
cards |
Card definitions | Effects |
enemies |
Enemy definitions | IntentPattern, PassiveEffects |
factions |
Faction metadata | PassiveMechanic, CampaignData |
classes |
Class definitions | InnateAbility, UltimateAbility, MechanicParams |
relics |
Relic definitions | Effect |
heroes |
Player heroes | Appearance |
saved_runs |
Saved game state | StateJson |
faction_tutorials |
Tutorial configs | (entire row is structured) |
economy_params |
Economy tuning | (key-value) |
map_params |
Map generation tuning | (structured) |
random_events |
Random event definitions | Choices, Outcomes |
quest_templates |
Quest definitions | (structured) |
snapshots |
Versioned data snapshots | Data |
audit_log |
Design change tracking | Details |
Authentication¶
JWT Bearer tokens with HS256 signing. Roles: - Player: Game endpoints (heroes, runs, game data reads) - Designer: Admin write access (create/edit game data) - Analyst: Admin read access (telemetry queries) - SuperAdmin: Everything
Key Endpoints¶
| Route | Auth | Purpose |
|---|---|---|
POST /api/v1/auth/login |
None | Get JWT token |
GET /api/v1/gamedata/{cards,enemies,classes,factions,relics} |
Player | Load game data |
GET /api/v1/heroes |
Player | List player's heroes |
POST /api/v1/heroes |
Player | Create hero (validates class/faction restriction) |
POST /api/v1/runs/start |
Player | Start a new run |
PUT /api/v1/runs/state |
Player | Save run state |
GET /api/v1/runs/state/{id} |
Player | Load saved run |
POST /api/v1/admin/{entity} |
Designer | Create/update game data |
POST /api/v1/telemetry/query |
Analyst | Run KQL queries |
POST /api/v1/simulation/batch |
Designer | Queue simulation batch |
GET /api/v1/health |
None | Health check |
Infrastructure Services¶
The API uses provider interfaces with graceful fallback:
| Service | Primary | Fallback |
|---|---|---|
| Cache | RedisCacheProvider |
InMemoryCacheProvider |
| Job Queue | RedisJobQueue |
InMemoryJobQueue |
| Message Bus | RedisMessageBus |
InMemoryMessageBus |
| AI Names | AnthropicCompletionProvider or OllamaCompletionProvider or OpenAiCompletionProvider |
FallbackHeroNameGenerator |
| Telemetry | KustoTelemetryWriter |
JsonFileTelemetryWriter |
This means the API runs on a laptop with zero external dependencies (uses all in-memory fallbacks).
The Orchestration Layer¶
GameRunner — Main Game Loop¶
GameRunner is the entry point for a real game session. It:
- Loads all game data from the API via
GameDataClient - Shows the main menu — hero list, new character creation, or quit
- Character creation flow: faction selection → class selection → AI name generation → hero saved to API → auto-launch faction tutorial
- Run flow: generate seed → build RunState → generate map → traverse nodes → combat/shop/event/rest at each → boss fight → victory or death
- Save/restore: RunState serialized to JSON, stored via API, reconstructed on load
TutorialRunner — Faction Tutorials¶
Each faction has a scripted tutorial with:
- Fixed encounters (specific enemies at specific nodes)
- Story beats with trigger conditions (narrative text + gameplay hints)
- Template variables ({SurviveTurns} resolved from encounter data)
- A Protection Rift encounter (defend an entity for N turns)
- Unique scar cards on failure, unique rewards on success
- A boss fight at the end
Tutorial data is defined in static *TutorialData.cs classes (one per faction), not loaded from the API. This ensures tutorials always work even without database connectivity.
The Admin Portal¶
React app (src/admin-react, Vite + MUI) with pages for every game entity type: Cards, Classes, Enemies, Factions, Relics, Economy Params, Map Params, Random Events, Quest Templates, Tutorials, Effects, and a Simulation dashboard. It replaced a Blazor Server admin, which has been removed from src/.
Talks to the API via EchoSpireApiClient, which auto-acquires JWT tokens using admin credentials. Supports publish/rollback for versioned data snapshots.
The Simulation Engine¶
Headless batch runner for automated balance testing. Takes command-line args:
- --runs: Number of games to simulate
- --seed: Base seed (incremented per run)
- --policy: Decision policy ID (currently RANDOM)
- --difficulty: Difficulty tier
Uses BatchRunner to execute runs in a tight loop, recording telemetry to Kusto for analysis (the February spec named ClickHouse; the system runs Kusto — see the status block above). Each run creates a RunState, simulates combat sequences, and records outcomes.
Cross-Cutting Design Decisions¶
Why data-driven everything?¶
Card definitions, enemy stats, economy parameters, map generation weights — all stored in the database. The code contains mechanics (how damage works, how block works) but not content (which cards exist, how much damage Strike does). A designer should be able to create a new card without touching C#.
Why deterministic RNG?¶
Three reasons: 1. Bug reproduction — "seed 48293748 crashes on turn 3 of combat 2" is actionable 2. Balance analysis — run 10,000 games with the same seed across different policies to isolate decision quality from luck 3. Competitive — seeded daily runs where everyone faces the same map and enemies
Why the handler pipeline instead of inheritance?¶
Because mechanics overlap. A Syndicate Machinist has the Gilded Syphon passive (faction), the Construct mechanic (class), any equipped relics, and any accumulated status effects. All of these need to react to the same combat events. A handler pipeline lets them compose independently. Adding a new relic never requires modifying the Construct handler.
Why a UI abstraction?¶
The game needs to run in three contexts:
1. WPF — the shipping client (implemented; content still partly fixtures)
2. Console — for development and testing (implemented; the original proof of Core)
3. Headless — for simulation (no UI at all, uses NullGameUI)
4. Unity — a later presentation upgrade, after the product is validated. Not on the
Kickstarter path.
All share the same game loop. The UI interface is the seam.
Why API-first instead of embedded database?¶
Multiple clients consume the same data: the WPF client (the shipping client), Console Game, the React admin portal, and the Simulation Engine — with Unity as a later presentation upgrade. One source of truth in SQL Server, one API to access it. Also enables: - Hot-patching card balance without deploying a client update - Admin portal for designers who don't write code - Telemetry ingestion from any client
Why static tutorial data instead of database?¶
Tutorials are the first thing a player experiences. They can't fail because the database is down or because someone accidentally deleted a card definition. Static data in code guarantees tutorials always work. Each faction's tutorial data class defines all encounters, enemies, cards, story beats, and scar cards as compile-time constants.
Tech Stack Summary¶
| Layer | Technology |
|---|---|
| Runtime | .NET 10 |
| API Framework | ASP.NET Core |
| ORM | ADO.NET (Microsoft.Data.SqlClient) |
| Database | SQL Server (Azure SQL) |
| Cache | Redis (with in-memory fallback) |
| Telemetry | Azure Data Explorer / Kusto (with NDJSON file fallback) |
| Auth | JWT Bearer (HS256) |
| Admin UI | React (admin-react); the Blazor admin was removed |
| Game Client | WPF (shipping), Console (dev), Unity (later upgrade) |
| AI Names | Anthropic / OpenAI / Ollama (with offline fallback) |
| Tests | xUnit, ~693 test methods across 5 projects (2026-08-27) |