Skip to content

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, and www-react, and listed EchoSpire.Admin and EchoSpire.Unity, neither of which is in src/. 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 IGameUI with terminal rendering
  • The Simulation Engine can run thousands of headless games without any UI
  • Unity (when built) will implement the same IGameUI interface 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:

  1. Player plays cardsPlayCard() routes through CardResolver
  2. Player ends turnEndPlayerTurn() triggers the full turn cycle:
  3. Fire TurnEnding event (handlers do cleanup, status ticks)
  4. Discard hand (except Retain cards)
  5. Enemy phase: each enemy executes its intent pattern
  6. Remove dead enemies/constructs
  7. Check win/loss conditions
  8. 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:

  1. Check if card has Unplayable keyword → reject
  2. Check energy cost (Overload keyword allows energy debt)
  3. Remove card from hand
  4. Fire BeforeCardPlayed (handlers can cancel)
  5. Resolve targets based on TargetType enum
  6. Execute each CardEffect through the EffectRegistry
  7. Route card to ExhaustPile (if Exhaust keyword) or DiscardPile
  8. 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:

  1. Loads all game data from the API via GameDataClient
  2. Shows the main menu — hero list, new character creation, or quit
  3. Character creation flow: faction selection → class selection → AI name generation → hero saved to API → auto-launch faction tutorial
  4. Run flow: generate seed → build RunState → generate map → traverse nodes → combat/shop/event/rest at each → boss fight → victory or death
  5. 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. WPFthe 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)