Skip to content

WPF Audit And Next Build Pass

Purpose

This is the complete audit of the EchoSpire WPF MVP client as of March 2026, plus the recommended build order for the next implementation pass.

It exists to answer three practical questions:

  • where is the WPF client right now
  • what needs to change to make it feel like a game instead of a business app
  • what should be built next and in what order

Current State Summary

The WPF client has a solid technical foundation with clean MVVM architecture, dependency injection, async API integration, structured telemetry, and a disciplined boundary that keeps gameplay rules in Core.

The dark-gothic color palette and gold-accent theme are on-brand for the world.

The core problem is that only two of nine planned screens exist, and neither feels like a game yet. They are well-styled placeholders with a dark theme but no animation, no transitions, no cinematic moments, no faction identity, and no interactive gameplay surfaces.

Scorecard

Dimension Score Notes
Architecture and code quality 8 of 10 Clean MVVM, DI, separation of concerns
Visual theme and color 7 of 10 Dark-gothic palette works, gold accents are on-brand
Screen coverage 2 of 10 Two of nine screens exist
Animation and polish 2 of 10 Near-zero animation, instant transitions, stock controls
Game identity and immersion 3 of 10 No story delivery, no faction identity, no sound, no custom font
Interactivity and game loop 1 of 10 No combat, no map, no cards, no gameplay
Composable UI components 1 of 10 No reusable game controls such as cards, health bars, or storyteller

Screen Inventory

Screen Status Game Feel
Startup Loading Live Styled placeholder with stock ProgressBar
Opening Story Missing Five act images exist in assets but no view or ViewModel
Main Menu Live Best screen with hero pedestals and scale effects, but static
Hero Builder Missing Reference image exists but no view or ViewModel
Run Briefing Missing No assets, no code
Combat Encounter Missing No assets, no code
Realm Map Missing No assets, no code
Reward And Event Missing No assets, no code
Sanctuary And Recovery Missing No assets, no code
Settings And Accessibility Missing No assets, no code

What The Two Existing Screens Get Right

Theme.xaml foundation

The global theme resource dictionary sets up a respectable dark-gothic palette with window background at hex 0D1018, panel at 171C27, and gold accent at D6B96F. It includes custom scrollbar styling, button hover and pressed states, and a consistent text hierarchy with named styles like PanelBorderStyle, SurfaceBorderStyle, and AccentBodyTextStyle.

This is the strongest screen. It uses a Canvas-based hero pedestal layout with three character silhouettes, ScaleTransform on hover and selection, glowing Ellipse indicators under selected heroes, a background image with gradient overlay for proper game-menu composition, and gothic menu buttons with custom ControlTemplate. Dynamic text changes based on hero state.

What Needs To Change

No animations or transitions

Every screen change is an instant swap of ContentControl content. Games use fade-in and fade-out between screens, slide transitions for panels, storyboard animations for title reveals and button entrances, and parallax on background layers. The hover scale on hero slots uses Style.Triggers with static setters so the scale snaps instantly from 1.0 to 1.08 with no easing.

Stock WPF controls breaking immersion

The StartupLoadingView uses a default ProgressBar with IsIndeterminate True, which renders as the Windows system-chrome-blue pulsing bar. This is the most line-of-business-looking element in the app. A game would use a custom animated indicator such as a pulsing rune, swirling portal, or breathing logo.

No Opening Story screen

The UX flow map says Startup Loading leads to Opening Story which leads to Main Menu. The Opening Story is supposed to deliver the fall of the Aevum Spire across five acts. Five act images already exist under assets official ui screens opening-story. The API endpoint and IGameDataService.GetOpeningStoryAsync method both exist. But there is no view, no ViewModel, and no navigation route.

This is the single highest-impact missing piece for game feel. The player goes from a loading bar straight to hero selection with zero narrative context.

No Hero Builder screen

The Hero Builder is supposed to be the character creation flow where the player picks a faction, sees eligible classes, names the hero, and commits. All API endpoints exist. A reference image exists. But there is no view and no ViewModel. The New Hero button navigates to hero-builder which hits a placeholder case in MainWindowViewModel.

Duplicated local styles

Both StartupLoadingView and MainMenuView redefine the same styles locally including ScreenBackdropImageStyle, BackdropShadeBorderStyle, GamePanelBorderStyle, GameLogoTextStyle, GameSectionTitleTextStyle, OverlayLabelTextStyle, GameBodyTextStyle, and GameMutedTextStyle. These should be promoted to Theme.xaml as shared game-screen styles.

No sound system

Zero audio infrastructure. Games need ambient background loops per screen, UI interaction sounds, transition stingers, and music cues for faction identity.

No custom game font

The base font is Segoe UI. Titles use Palatino Linotype. Both are system fonts. A distinctive display font for headers would help differentiate the game feel.

The typography reference images at assets samples references typography are AI-generated concept art showing a fantasy typeface aesthetic at 1024 by 576 pixels on solid gray backgrounds with no transparency. They are not production sprite sheets and cannot be used directly as font assets. They serve as a mood reference for commissioning or selecting a real font.

WPF does not natively consume sprite-sheet fonts. The practical options for a custom game font in WPF are:

  • OTF or TTF font file: The cleanest path. Commission or license a fantasy display font matching the reference style, embed it as a project resource, and reference it in XAML via FontFamily. WPF renders OpenType and TrueType natively with full ClearType and anti-aliasing support. This is how production WPF games handle custom type.
  • Bitmap font with BitmapFont renderer: If the exact hand-drawn glyph style from the reference is essential, the letters can be extracted into a proper sprite sheet with transparent background, uniform glyph cells, and a mapping file. A custom WPF control would render text by slicing glyph rectangles from the atlas. This works but is heavy to maintain and loses ClearType.
  • SVG glyph font: For ornamental display text only, glyphs can be authored as SVG paths and rendered via WPF Path elements. This gives resolution independence but is impractical for body text.

Three font families have been downloaded to assets/ui/fonts/ under SIL Open Font License (free for commercial use, including paid Steam distribution):

Font Role Files
Cinzel Decorative Game logo, screen titles, dramatic headings CinzelDecorative-Regular/Bold/Black.ttf
Cinzel Menu items, Storyteller headings, UI labels Cinzel-Variable.ttf (weight axis 400–900)
Cormorant Garamond Body / narrative text, Storyteller dialogue CormorantGaramond-Variable.ttf, CormorantGaramond-Italic-Variable.ttf (weight axis 300–700)

Each folder also contains OFL.txt (the license). These fonts pair naturally: Cinzel Decorative for impact, Cinzel for structure, and Cormorant Garamond for readability. All three evoke a classical-gothic aesthetic that matches the reference mood art and the game's dark cathedral tone.

To use them in WPF, embed the TTF files as resources and reference them via FontFamily in XAML (e.g. FontFamily="/EchoSpire.Wpf;component/Fonts/#Cinzel Decorative").

No keyboard or gamepad input

All interaction is mouse-click. Roguelikes expect keyboard navigation through menus, Enter to confirm, and Escape to back.

The Storyteller Control

Why a reusable narrative control matters

Narrative delivery happens throughout the entire game, not just in the Opening Story. The same presentation pattern recurs in:

  • Opening Story: five-act lore before the first menu
  • Faction tutorial intro and outro narratives
  • Tutorial story beats triggered at specific nodes
  • Quest introductions, act transitions, and conclusions
  • Random events with narrative framing
  • Boss introductions and faction-versus-faction encounters
  • Campaign act transitions
  • Codex reveals and lore discoveries

A single composable Storyteller control driven by data eliminates the need to build one-off narrative screens for each of these contexts. Instead, any system that needs to tell a story passes a sequence of story beats to the same control.

What the Storyteller control needs to present

The control needs to handle three narrative presentation modes:

Cinematic narration is the mode used for the Opening Story and act transitions. It shows a large background image, an optional heading and act label, and body narrative text. No characters are speaking. The text is omniscient narration delivered over atmosphere art.

Dialogue is the mode used for faction handler briefings, tutorial guidance, quest conversations, and event framing. A character portrait appears on the left or right. The speaker is identified by name, faction, and role. Dialogue text appears in a styled speech area. The opposing side can show the player character or another NPC for back-and-forth exchanges.

Triggered beats is the mode used during gameplay when a story beat fires at a specific trigger point such as on_node_enter or on_combat_start. These are lighter-weight overlays that show a narrative text and optional hint, then dismiss after the player acknowledges.

Proposed data contract extension

The existing QuestStoryPanel model supports cinematic narration with Order, ActLabel, Heading, NarrativeText, and ImageAssetPath.

The Storyteller control needs a richer beat model that extends this to support dialogue. The proposed shape is:

StoryBeat
  Order              int
  BeatType            enum: Narration, Dialogue, TriggeredBeat
  BackgroundImagePath string (optional, inherits previous if null)
  Heading             string (optional)
  SubHeading          string (optional, e.g. act label)
  NarrativeText       string
  Hint                string (optional)
  Speaker             SpeakerPlacement (optional, null for pure narration)
  Respondent          SpeakerPlacement (optional, for the other side)
  TransitionStyle     enum: Cut, CrossFade, SlideLeft, SlideRight (default CrossFade)
  AutoAdvanceMs       int (optional, 0 means wait for player input)

SpeakerPlacement
  Name                string
  Role                string (optional, e.g. "High Commander" or "Handler")
  FactionId           Guid (optional, drives accent color)
  PortraitAssetPath   string
  Position            enum: Left, Right, Center
  Expression          string (optional, e.g. "neutral", "angry", "pleased")
  IsHighlighted       bool (true = full opacity, false = dimmed during other speaker turn)

This shape is JSON-serializable, database-authorable through Admin, and covers every narrative context in the game.

How the control works in WPF

The Storyteller is a UserControl that accepts a list of StoryBeat objects and presents them one at a time with player-driven advancement.

Layout structure:

┌──────────────────────────────────────────────────┐
│  [Background Image - full bleed]                 │
│  [Gradient overlay for text legibility]          │
│                                                  │
│  ┌──────────┐                    ┌──────────┐    │
│  │ Speaker  │                    │Respondent│    │
│  │ Portrait │                    │ Portrait │    │
│  │ (Left)   │                    │ (Right)  │    │
│  └──────────┘                    └──────────┘    │
│  ┌──────────────────────────────────────────┐    │
│  │  [Speaker Name + Role]                   │    │
│  │  ┌────────────────────────────────────┐  │    │
│  │  │  Narrative text or dialogue        │  │    │
│  │  │  with styled border treatment      │  │    │
│  │  └────────────────────────────────────┘  │    │
│  │  [Hint text if present]                  │    │
│  └──────────────────────────────────────────┘    │
│                                                  │
│                    [Continue]        [Skip All]   │
│  [Beat indicator: ● ● ○ ○ ○]                    │
└──────────────────────────────────────────────────┘

Key behaviors:

  • Background image crossfades when it changes between beats
  • Speaker portraits slide in from their respective sides
  • The currently-speaking portrait is full opacity while the listener dims to about 60 percent
  • Narrative text reveals progressively with a configurable speed or completes instantly if the player clicks during reveal
  • A styled border treatment frames the text area matching the faction accent color when a faction is associated
  • Beat indicator dots show progress through the sequence
  • Continue advances to the next beat, Skip All jumps to the end
  • When the last beat completes, the control raises a Completed event so the parent screen can navigate
  • Keyboard support: Space or Enter to advance, Escape to skip

Animation and transition system

Every transition in the Storyteller uses WPF Storyboard animations with easing functions. Nothing should snap or pop. The control should feel like turning pages in a motion comic.

Beat-to-beat transitions:

When the player advances to the next beat, the outgoing content fades out over approximately 300 milliseconds and the incoming content fades in over 400 milliseconds. The exact behavior depends on the beat TransitionStyle:

  • CrossFade: both background and text panel cross-dissolve simultaneously. The outgoing opacity animates from 1 to 0 while the incoming animates from 0 to 1, overlapping by about 200 milliseconds so neither frame is fully black.
  • Cut: instant swap with no animation. Used for triggered beats during gameplay where speed matters.
  • SlideLeft: the outgoing content translates left by 80 pixels while fading out. The incoming content enters from 80 pixels right of its final position and slides into place. This suits back-and-forth dialogue where the speaker changes sides.
  • SlideRight: opposite of SlideLeft.

All transitions use a CubicEase with EasingMode EaseOut for natural deceleration.

Speaker portrait animations:

When a speaker appears for the first time, their portrait slides in from their side of the screen over 500 milliseconds with a slight scale-up from 0.92 to 1.0 and opacity from 0 to 1. When a speaker becomes the active talker, their portrait animates from dimmed (opacity 0.45, scale 0.96) to highlighted (opacity 1.0, scale 1.0) over 350 milliseconds. The previously-active speaker dims in reverse over the same duration.

When a speaker exits (not present in the next beat), they slide back out toward their side over 400 milliseconds with opacity fading to 0.

Background image transitions:

Background changes use a dual-Image technique. Two Image elements are layered in the same Grid cell. The current background is in the front layer. When the background changes, the new image loads into the back layer at opacity 0, then the front layer fades from opacity 1 to 0 over 600 milliseconds while the back layer fades from 0 to 1 simultaneously. After completion, the references swap so the new image becomes the front layer. This avoids any black flash between backgrounds.

Text advancement system

The narrative text area does not use scrolling. Instead, it uses a progressive text reveal that feels cinematic rather than utilitarian.

Character-by-character reveal:

When a new beat displays, the NarrativeText content is fully set in the TextBlock but the text is revealed character by character using an opacity mask or a clipping rectangle that expands. The reveal speed is configurable, defaulting to approximately 30 characters per second for narration and 45 characters per second for dialogue, which gives narration a weightier pace.

Implementation uses a DispatcherTimer that increments a character index. A custom attached property or a dedicated TextRevealBehavior controls how many characters are visible. The simplest WPF approach is to set the TextBlock Text property incrementally, but this can cause layout jitter. The smoother approach is to render the full text but use a RectangleGeometry Clip that grows horizontally line by line, which keeps the layout stable.

Player interaction during reveal:

  • If the player clicks, presses Space, or presses Enter while text is still revealing, the reveal completes instantly and all text becomes visible.
  • A second click or press after the text is fully revealed advances to the next beat.
  • This two-phase input (first click completes reveal, second click advances) is the standard pattern used by Hades, Fire Emblem, Persona, and most narrative games. Players who want to read at their own pace can let the reveal play. Impatient players can double-tap to skip through quickly.

Visual treatment during reveal:

A small pulsing indicator appears at the bottom-right of the text panel after the full text has been revealed to signal that the player can advance. This can be a small glowing diamond, chevron, or faction-themed ornament that breathes with a gentle opacity animation between 0.5 and 1.0 on a 1.2-second loop.

During text reveal, this indicator is hidden. It only appears after the last character is shown.

Heading and subheading entrance:

The heading text (act label and heading) enters separately from the body text. When a beat has a heading, it fades in and slides down slightly over 400 milliseconds before the body text reveal begins. If the heading has not changed from the previous beat, it stays in place and only the body text re-reveals.

Beat progress indicator animation:

The beat dots at the bottom of the control animate individually. The current beat dot scales up from 1.0 to 1.3 and changes from muted to the faction accent color over 300 milliseconds. Completed beat dots stay filled at the accent color at normal scale. Upcoming beats remain muted outlines.

Faction-aware styling:

When a SpeakerPlacement includes a FactionId, the control can tint the text border and speaker name to the faction accent color:

  • Valerii: iron gray with amber highlights
  • Syntacta: cold blue with white data-glow
  • Aethari: gold and prismatic shimmer
  • Annalis: spectral white with pale blue
  • Salvari: rust orange with industrial green

This makes every narrative moment immediately communicate faction identity without requiring custom screens per faction.

How the existing data maps to the Storyteller

The current QuestStoryPanel records map directly to Narration-type beats. The migration is mechanical:

  • QuestStoryPanel.Order becomes StoryBeat.Order
  • QuestStoryPanel.ActLabel becomes StoryBeat.SubHeading
  • QuestStoryPanel.Heading becomes StoryBeat.Heading
  • QuestStoryPanel.NarrativeText becomes StoryBeat.NarrativeText
  • QuestStoryPanel.ImageAssetPath becomes StoryBeat.BackgroundImagePath
  • BeatType is Narration (no speaker)

The current TutorialStoryBeat records map to TriggeredBeat-type beats. These can optionally add a speaker (the faction handler) for immersion:

  • TutorialStoryBeat.NarrativeText becomes StoryBeat.NarrativeText
  • TutorialStoryBeat.Hint becomes StoryBeat.Hint
  • BeatType is TriggeredBeat
  • Speaker can be populated with the faction handler character

Future campaign dialogue content uses the Dialogue beat type with both Speaker and Respondent populated.

Where the Storyteller control fits in the screen flow

Context Integration Point
Opening Story screen Full-screen Storyteller with five Narration beats
Tutorial intro Storyteller overlay before first node with IntroNarrative
Tutorial node story beats Triggered Storyteller overlay on node enter
Tutorial outro Storyteller overlay after boss defeat with OutroNarrative
Quest act transitions Storyteller between campaign acts
Boss introductions Storyteller dialogue with boss character before combat
Random events Storyteller with event framing and optional choice buttons
Faction handler briefings Storyteller dialogue during run briefing

Pass 1: Foundation polish

These are fast wins that shift the feel from admin app to game shell without building new screens.

  1. Promote duplicated styles from StartupLoadingView and MainMenuView into Theme.xaml as shared game-screen resource keys.
  2. Replace the stock ProgressBar on StartupLoadingView with a custom animated loading indicator.
  3. Add Storyboard-based easing to hero slot hover and select ScaleTransforms so they animate smoothly instead of snapping.
  4. Add screen-transition animations using a fade or crossfade wrapper around the ContentControl in MainWindow.
  5. Add a pulsing or breathing glow animation on the game logo on both screens.

Pass 2: Build the Storyteller control

This is the highest-leverage new component because it unblocks multiple screens.

  1. Define the StoryBeat, SpeakerPlacement, BeatType, TransitionStyle, and SpeakerPosition data models in Contracts so they are portable to Unity later.
  2. Build the StorytellerControl UserControl with the core layout: dual-layer background images, left and right portrait slots, heading and subheading area, narrative text panel with styled border, hint area, beat progress indicator, and continue and skip controls.
  3. Implement the text advancement system with character-by-character reveal using DispatcherTimer and a clipping or incremental approach. Wire the two-phase input pattern where first input completes the reveal and second input advances to the next beat.
  4. Implement the beat-to-beat transition system with CrossFade, Cut, SlideLeft, and SlideRight Storyboard animations using CubicEase with EaseOut. Use the dual-Image technique for background crossfades.
  5. Implement speaker portrait entrance, exit, and active-speaker highlight animations with slide-in, opacity, and scale transitions.
  6. Implement heading entrance animation where the heading fades in and slides down before the body text reveal begins.
  7. Add the pulsing advance indicator that appears after text reveal completes.
  8. Add faction-aware accent coloring on the text frame border and speaker name.
  9. Add beat progress dot animation with scale-up and accent color for the current beat.
  10. Add keyboard support: Space or Enter to advance or complete reveal, Escape to skip all.
  11. Raise the Completed event when the last beat is acknowledged so parent screens can navigate.

Pass 3: Build the Opening Story screen

With the Storyteller control built, this screen is thin.

  1. Create OpeningStoryViewModel that calls GetOpeningStoryAsync and maps the response into StoryBeat list.
  2. Create OpeningStoryView that hosts the Storyteller control full-screen.
  3. Wire the opening-story navigation route in MainWindowViewModel.
  4. Wire the flow: Startup Loading to Opening Story to Main Menu.
  5. Handle first-visit versus returning-player logic for when to show or skip the story.

Pass 4: Build the Hero Builder screen

  1. Create HeroBuilderViewModel with steps for faction selection, class selection, naming, and confirmation.
  2. Create HeroBuilderView with a multi-step layout.
  3. Step 1: Faction selection showing five faction cards with distinct color identity, lore tagline, and leader name. Selecting a faction reveals its description and eligible classes.
  4. Step 2: Class selection showing the eligible classes for the chosen faction with archetype description and starting deck summary.
  5. Step 3: Name input with AI-generated name suggestions from the existing API endpoint.
  6. Step 4: Confirmation panel showing the full hero summary before creation.
  7. Wire API data binding for factions, eligible classes, name suggestions, and hero creation.
  8. Add faction-themed visual feedback: when a faction is selected the panel background and accent color should shift to match.

Pass 5: Build the gameplay loop screens

These are the core roguelike loop. Each screen should reuse the Storyteller control for any narrative moments within it.

  1. Run Briefing: confirm hero, show stakes and modifiers, start or continue run.
  2. Combat Encounter: card hand, enemy intents, energy, HP bars, action resolution. This is the most complex screen and the core of the game.
  3. Realm Map: node graph with branching paths and icons per node type.
  4. Reward And Event: card reward picks, random events with Storyteller integration, shops.
  5. Sanctuary And Recovery: rest, heal, save, exit run safely.
  6. Settings And Accessibility: audio, display, accessibility, keybindings.

Pass 6: Composable game UI controls

Build reusable controls that multiple screens need.

  • GameCard: card face with name, cost, type, description, and art placeholder
  • HealthBar: segmented or smooth with shield overlay
  • EnergyCounter: current max with glow
  • StatusEffectIcon: icon with stack count and tooltip
  • EnemyIntentIcon: attack, defend, buff, special
  • FactionBadge: faction icon with name and accent color
  • MapNode: icon with state and connection lines
  • GameTooltip: styled tooltip with dark panel and gold border

Architecture Notes

What is healthy

  • MVVM with ObservableObject and AsyncCommand is clean
  • DI with IServiceProvider and factory delegates is correct
  • Navigation via INavigationService event subscription works
  • Data-template-driven view resolution in MainWindow.xaml is the right WPF pattern
  • Session state, API client, and game data service abstractions are properly separated
  • Core and Contracts boundary is respected with no gameplay logic in WPF
  • Telemetry infrastructure is in place

Minor improvement opportunity

The MainWindowViewModel.Navigate method uses a switch statement that will grow as screens are added. A dictionary-based factory registration would scale better but this is not urgent.

Relationship To Other Official Docs

  • Use wpf-ux-flow-map.md for the complete screen flow and asset structure.
  • Use wpf-mvp-kickoff.md for the WPF MVP boundaries and migration rules.
  • Use master-todo.md for the full cross-discipline backlog.
  • Use next-steps.md for the short list of immediate priorities.
  • Use technical-architecture.md for system boundaries and required contracts.

Maintenance Rule

When the WPF build pass priorities change, update this page. If a new audit or scope adjustment happens, record it here rather than leaving findings only in chat.