Skip to content

EchoSpire Package Model

STATUS: PARTIALLY IMPLEMENTED. Verified 2026-08-27.

This document previously read "Design discussion. No code committed. Awaiting decision before implementation." That was false: src/EchoSpire.API/PackageBuilders/ contains CatalogPackageBuilder, HeroPackageBuilder, and FactionCampaignBuilder, served by PackagesController, with asset manifests in Infrastructure/Assets/.

Treat the document as design intent that code has partly overtaken. Before relying on any section, check it against PackageBuilders/. Package-model expansion is explicitly deferred until after the Kickstarter slice.

Revision 2: Adds Campaign as a first-class authored aggregate, splits the playable-content package out as QuestPackage, and introduces hero-keyed QuickPlay as a synthesized campaign.

1. Goals

  1. Give every client (WPF, Unity, Console) a self-contained, versioned bundle of authored content per screen/flow — no chasing follow-up calls to render a page.
  2. Cleanly separate authored content (designer-owned, cacheable) from runtime state (per-run, mutable).
  3. Make assets CDN-friendly with content-hashed URIs and delta-fetchable catalogs.
  4. Keep one source of truth: admin edits domain entities; the server composes packages from them.
  5. Support Faction campaigns (authored, persisted) and QuickPlay (procedurally generated, hero-scoped) through the same package shape.

2. Core Concepts

2.1 The four packages

Package Aggregate root Purpose Lifetime
CatalogPackage (no aggregate) Shared pool of visuals, effects, audio, and small data records (cards, enemies, artifacts, consumables) Long-lived, delta-fetchable
HeroPackage Hero Everything needed for hero-select / inventory / deck screens Per hero, cached by version
CampaignPackage Campaign Campaign-overview screen: cover art, ordered quest list, progress, intro/outro panels Per campaign, cached by version
QuestPackage Quest Everything needed to play one quest (one biome, its node templates, enemies, rewards, story panels) Per quest, cached by version

2.2 Two flavors of versioning

Every package carries:

  • PackageVersion — integer. Bumped manually when the DTO shape changes. Breaking.
  • ContentVersion — string (sha256 of payload). Changes whenever designer edits underlying data. Non-breaking. Used as ETag.

The catalog additionally carries:

  • CatalogVersion — monotonic integer. Bumped any time any visual / effect / audio / data record is added or replaced.

Every reference (VisualRef, CardRef, EnemyRef, etc.) carries MinCatalogVersion. The client rule:

On receiving any non-catalog package:
  required = max(ref.MinCatalogVersion for all refs in package)
  if local CatalogVersion < required:
      GET /packages/catalog?since=<localVersion>   // delta only
      merge into local pool
  resolve all refs locally

2.3 Embed vs. reference

Rule: embed 1:1 ownership, reference many:many sharing.

  • Quest owns its Biome → embed.
  • Hero owns its Faction and Class (lite views) → embed.
  • Campaign owns its quest list and embeds lite quest summaries (so the campaign-overview screen renders without N quest fetches); the full QuestPackage is fetched only when the player commits.
  • Cards, visuals, effects, audio, enemies → referenced; resolved via Catalog.

3. Asset Delivery

3.1 Storage layout

Every binary asset (image, audio, video) is stored as an immutable, content-hashed file. The catalog's AssetManifest lists them as relative paths plus a ContentHash.

assets/
  visuals/
    biomes/submerged-archive/starmap-far-9c2f1e.webp
    cards/valerii/iron-oath-7a3b22.webp
  audio/
    biomes/submerged-archive/ambient-2e8f01.ogg

3.2 Base URL resolution

The client resolves relative paths via an IAssetBaseResolver configured per environment:

Env Base URL
Local dev http://localhost:5000/assets/
PseudoProd http://pseudoprod-host/assets/
Production https://<cdn-endpoint>/assets/ (Azure CDN front of Blob Storage)

3.3 Production: Azure Blob + CDN

  • Storage: Azure Storage Account, single assets container, public read.
  • CDN: Azure Front Door or Azure CDN in front of the container.
  • Immutability: content-hashed filenames mean the CDN can cache forever. New version = new filename = new cache entry. No purge needed.
  • SAS: not used for public art. Reserved for future user-generated content.

3.4 CI guardrail

Build pipeline computes content hashes for all assets in assets/ and verifies that any asset referenced by the published catalog exists at the expected hash. Mismatch fails the build.

4. The Campaign Aggregate (Authored)

4.1 Shape

Campaign (authored, GameAsset, lifecycle: Dev → Staged → Published → Archived)
├─ Id, Key (slug, e.g. "valerii-iron-vigil")
├─ FactionId           // exactly one for v1; unique constraint enforces 1:1
├─ Kind: CampaignKind  // Faction (authored) — see §5 for QuickPlay
├─ Name, Tagline, Description
├─ SortOrder
├─ Visual: VisualRef   // cover art / banner
├─ AmbientAudio: AudioRef?
│
├─ IntroStoryPanels: StoryPanel[]    // shown before first quest
├─ OutroStoryPanels: StoryPanel[]    // shown after final quest
│
├─ Quests: CampaignQuestEntry[]      // ordered
│    CampaignQuestEntry {
│      QuestId
│      Order: int
│      UnlockRule: CampaignUnlockRule
│      IsOptional: bool
│      InterstitialStoryPanels: StoryPanel[]?
│    }
│
└─ CompletionReward: CampaignCompletionReward?

4.2 Unlock rule

CampaignUnlockRule = oneof {
  AlwaysUnlocked
  AfterQuests { questIds: Guid[] }
  AfterOrder  { order: int }
}

For v1's linear campaigns, every entry except the first uses AfterOrder { order = previousOrder }.

4.3 Persistence

Table Notes
campaigns FK to factions.Id. Unique index on FactionId for v1 (drop later if multi-campaign-per-faction is needed).
campaign_quests Join with Order, UnlockRule (JSONB), IsOptional, InterstitialStoryPanels (JSONB).

Quest.CampaignId is not added — quests stay independent. The join table owns the relationship. QuickPlay-style or standalone quests don't need a fake campaign.

4.4 Per-hero progression

CampaignProgress {
  HeroId, CampaignId          // composite key
  CompletedQuestIds: Guid[]
  CurrentQuestId?
  StartedAt, LastPlayedAt, CompletedAt?
}

Heroes are the unit of progression in EchoSpire. Different heroes of the same faction each have their own arc.

CampaignProgress rows are only written for Kind = Faction. QuickPlay does not accumulate campaign progress; it tracks state via a column on heroes (see §5.5).

5. QuickPlay (Synthesized Campaign)

QuickPlay is a procedurally generated, story-free 3-biome run that uses the hero's currently unlocked card pool. It ships as a CampaignPackage so the client renders it through the same UI as Faction campaigns — one screen, one shape, two kinds.

5.1 CampaignKind enum

enum CampaignKind {
  Faction       // authored, persisted, the main story arc per faction
  QuickPlay     // synthesized, hero-scoped, 3-biome procedural run
  Endgame       // (reserved) authored, unlocks after Faction completion
  Skirmish      // (reserved) 1v1 / battle-only content, hero-scoped
}

Only Faction and QuickPlay ship in v1. Reserved kinds signal intent without committing code.

5.2 Hero-keyed synthetic id

QuickPlay campaigns are not persisted but still need stable, addressable ids:

QuickPlayCampaignId(heroId) = Guid(QUICKPLAY_NS, heroId.ToString())

One QuickPlay campaign id per hero, stable forever. /packages/campaign/{id} resolves via the route resolver:

  1. Lookup in campaigns table → if found, use FactionCampaignBuilder.
  2. Else if id matches the QuickPlay namespace → use QuickPlayCampaignBuilder.
  3. Else 404.

5.3 Builder algorithm

QuickPlayCampaignBuilder.Build(heroId):
  hero = repo.LoadHero(heroId)

  // Card pool — feature-flag aware
  cardPool = featureSwitches.IsEnabled("dev.unlockAllCards", evalContext)
             ? hero.Faction.AllPublishedCards
             : hero.UnlockedCards

  // Stable RNG seed: derived from hero + last QuickPlay completion
  seed = HashSeed(heroId, hero.LastQuickPlayCompletedAt)
  rng  = new SeededRng(seed)

  // Procedurally pick 3 biomes
  biomes = quickPlayGenerator.PickBiomes(
             count: 3,
             rng:   rng,
             constraints: { faction = hero.FactionId, tierRange = hero.TierRange })

  // Each biome → a synthesized quest (no story, biome defaults for everything)
  quests = biomes.Select(b => SynthesizeQuest(b, hero, cardPool, rng)).ToList()

  return CampaignPackage {
    Campaign = {
      Id           = QuickPlayCampaignId(heroId),
      Kind         = QuickPlay,
      Name         = "Quick Play",
      Faction      = hero.Faction (lite),
      Quests       = quests,             // 3 entries, all AlwaysUnlocked
      IntroStoryPanels = [],
      OutroStoryPanels = [],
      CompletionReward = null,
    }
  }

5.4 Synthesized quests

Each generated quest is a QuestPackage shape but not persisted. Properties:

  • Real Biome from catalog.
  • Real NodeTemplates map (from the biome).
  • Generated node graph (MapGenerator already does this).
  • Synthesized id deterministic per (heroId, biomeId, runIndex).
  • Empty StoryPanels, no boss override, biome-default reward pool.

GET /packages/quest/{id} route resolver:

  1. Lookup in quests table → persisted quest.
  2. Else if id matches synthesized-quest namespace → SynthesizedQuestBuilder regenerates from (heroId, biomeId, runIndex).
  3. Else 404.

5.5 Seed stability rule

The seed is derived from (heroId, hero.LastQuickPlayCompletedAt):

  • Stable while the player browses the QuickPlay overview — same 3 biomes shown until commit.
  • Rolls when a QuickPlay run completes — LastQuickPlayCompletedAt updates → next visit shows new biomes.

Storage: a single column on the heroes table:

heroes.LastQuickPlayCompletedAt: timestamptz NULL

No side table. Add only if QuickPlay accumulates more state later (streak, current run id, etc.).

5.6 Dev card-unlock — feature switch

Reuses the existing FeatureSwitch infrastructure. No new mechanism, no Hero.IsSandbox flag.

Feature switch:  "dev.unlockAllCards"
Type:            Boolean
Default:         false
Allow list:      [dev/QA user ids]

Both HeroPackageBuilder and QuickPlayCampaignBuilder consult it. When on, the hero's card pool is the full faction AllPublishedCards; when off, UnlockedCards. One source of truth.

Properties:

  • Per-user override via allow list — even in prod-shaped envs, only dev accounts get it.
  • Auditable — feature-switch CRUD already logs changes.
  • Toggleable at runtime — no rebuild.
  • Sandbox is a runtime authorization concern, not hero data.

6. Package Shapes (Conceptual)

These are conceptual shapes for discussion. Field names and exact types will be refined when we draft the actual records in EchoSpire.Contracts.

6.1 Common types

VisualRef    { Id: Guid, Version: int, MinCatalogVersion: int }
EffectRef    { Id: Guid, Version: int, MinCatalogVersion: int }
AudioRef     { Id: Guid, Version: int, MinCatalogVersion: int }
CardRef      { Id: Guid, Version: int, MinCatalogVersion: int }
EnemyRef     { Id: Guid, Version: int, MinCatalogVersion: int }

AssetEntry {
  RelativePath: string         // e.g. "visuals/cards/valerii/iron-oath-7a3b22.webp"
  ContentHash:  string         // sha256
  SizeBytes:    long
  MimeType:     string
}

LayoutHints {
  HeroAnchorNorm:    (x,y)?
  EnemyAnchorsNorm:  (x,y)[]?
  CameraFocusNorm:   (x,y)?
  SafeAreaNorm:      rect?
}

StoryPanel {
  Order, Heading, Text, Visual: VisualRef, TextLocation?
}

6.2 CatalogPackage

CatalogPackage {
  PackageVersion, ContentVersion, CatalogVersion, GeneratedAt

  AssetCatalog {
    Visuals: [
      { Id, Version, IntroducedAtCatalogVersion,
        Layers: [ { Role, ZIndex, AssetPath, Anchor?, ScaleHint? } ] }
    ]
    Effects: [
      { Id, Version, IntroducedAtCatalogVersion,
        Type (Particle | Light | Shader | Sound),
        AssetPath?, Params: {...} }
    ]
    Audio:   [ { Id, Version, IntroducedAtCatalogVersion, AssetPath } ]
    AssetManifest: AssetEntry[]
  }

  DataCatalog {
    Cards:       [ { Id, Version, Name, Cost, Text, Effects, VisualRef } ]
    Enemies:     [ { Id, Version, Name, Stats, IntentScript, VisualRef } ]
    Artifacts:   [ ... ]
    Consumables: [ ... ]
  }
}

A delta call (?since=N) returns only entries with IntroducedAtCatalogVersion > N, plus the latest AssetManifest slice covering them.

6.3 HeroPackage

HeroPackage {
  PackageVersion, ContentVersion, RequiredCatalogVersion, GeneratedAt

  Hero {
    Id, Name, Description
    Visual: VisualRef
    LastQuickPlayCompletedAt?      // for client to detect QuickPlay roll

    Faction (embedded, lite) {
      Id, Key, Name, Description, Visual: VisualRef
    }

    Class (embedded, lite) {
      Id, Key, Name, Description, Visual: VisualRef
    }

    AvailableCards: CardRef[]   // everything this hero could ever draft
    UnlockedCards:  CardRef[]   // currently unlocked subset (or full pool when dev flag on)
    StartingDeck:   CardRef[]
  }
}

6.4 CampaignPackage

CampaignPackage {
  PackageVersion, ContentVersion, RequiredCatalogVersion, GeneratedAt

  Campaign {
    Id, Key, Name, Tagline, Description
    Kind: CampaignKind
    Visual: VisualRef
    AmbientAudio: AudioRef?
    IntroStoryPanels: StoryPanel[]    // empty for QuickPlay
    OutroStoryPanels: StoryPanel[]    // empty for QuickPlay

    Faction (embedded, lite) {
      Id, Key, Name, Description, Visual: VisualRef
    }

    Quests: [
      {
        QuestId
        Order
        UnlockRule
        IsOptional
        InterstitialStoryPanels: StoryPanel[]?  // null for QuickPlay
        QuestSummary {
          Name, Tagline, DifficultyTier
          BiomeKey, BiomeName, BiomeVisual: VisualRef
        }
      }
    ]

    CompletionReward?               // null for QuickPlay
  }

  Progress?                         // null for QuickPlay; for Faction = CampaignProgress slice
}

6.5 QuestPackage

QuestPackage {
  PackageVersion, ContentVersion, RequiredCatalogVersion, GeneratedAt

  Quest {
    Id, Name, Description
    QuestType, DifficultyTier
    FactionId?
    StoryPanels: StoryPanel[]       // empty for synthesized QuickPlay quests
    RewardPoolCards: CardRef[]
    BossEnemies:     EnemyRef[]

    Biome (embedded) {
      Key, Name, Type (enum), Description

      StarMapVisuals {
        AccentColorHex, FogColorHex
        Layers: [ { Key, ParallaxFactor, Visual: VisualRef } ]
      }

      AmbientAudio: AudioRef?
      EnemyPool:    EnemyRef[]

      NodeTemplates: {
        Combat:     NodeTemplate
        Elite:      NodeTemplate
        Boss:       NodeTemplate
        Shop:       NodeTemplate
        Sanctuary:  NodeTemplate
        Treasure:   NodeTemplate
        Event:      NodeTemplate
        Anchor:     NodeTemplate
        Protection: NodeTemplate
        Transit:    NodeTemplate
      }
    }
  }
}

NodeTemplate {
  NodeType
  Visuals: [ { Role (Background|Midground|Foreground|Overlay),
               ZIndex, Visual: VisualRef, Anchor?, ScaleHint? } ]
  Effects: [ { EffectRef, AnchorNorm: (x,y), Layer? } ]
  AmbientAudio: AudioRef?
  LayoutHints?
}

7. Server-Side Composition

7.1 Builder interface

IPackageBuilder<TKey, TPackage> {
  Task<TPackage> BuildAsync(TKey key, CancellationToken ct);
}

Concrete builders:

  • CatalogPackageBuilder : IPackageBuilder<int?, CatalogPackage> (key = since cursor)
  • HeroPackageBuilder : IPackageBuilder<Guid, HeroPackage>
  • FactionCampaignBuilder : IPackageBuilder<Guid, CampaignPackage> (persisted campaigns)
  • QuickPlayCampaignBuilder : IPackageBuilder<Guid, CampaignPackage> (synthesized; key = heroId)
  • QuestPackageBuilder : IPackageBuilder<Guid, QuestPackage> (persisted quests)
  • SynthesizedQuestBuilder : IPackageBuilder<Guid, QuestPackage> (QuickPlay quests)

A thin route resolver dispatches /packages/campaign/{id} and /packages/quest/{id} to the right builder by inspecting the id namespace.

7.2 Validator

IPackageValidator {
  Task ValidateAsync(IPackage package, CancellationToken ct);
}

Enforces, before the response is returned:

  • Every *Ref exists in the catalog at version >= MinCatalogVersion.
  • Every AssetPath reachable from referenced visuals/effects/audio exists in AssetManifest with a matching hash.
  • RequiredCatalogVersion >= max(MinCatalogVersion across all refs).

A failed validation is a server bug — it returns 500 and logs loudly. Clients never see a broken package.

8. API Surface

Route Returns Cache
GET /packages/catalog Full CatalogPackage ETag = ContentVersion
GET /packages/catalog?since=<n> Delta CatalogPackage ETag = ContentVersion
GET /packages/heroes [ { HeroId, FactionId, ContentVersion } ] short TTL
GET /packages/hero/{heroId} HeroPackage ETag = ContentVersion
GET /packages/campaigns?heroId={id} [ { CampaignId, FactionId, Kind, ContentVersion } ] — one Faction entry + one QuickPlay entry per owned hero short TTL
GET /packages/campaign/{campaignId} CampaignPackage (resolver picks Faction or QuickPlay builder) ETag = ContentVersion
GET /packages/quest/{questId} QuestPackage (resolver picks persisted or synthesized builder) ETag = ContentVersion

All routes honor If-None-Match304 Not Modified. Clients persist ContentVersion per package.

9. Player Flow

1.  Login → /packages/heroes
2.  Pick hero → /packages/hero/{heroId}
3.  Hero overview screen offers two buttons:

      ┌─────────────────────────────┐  ┌─────────────────────────────┐
      │ Continue Campaign           │  │ Quick Play                  │
      │ (Iron Vigil — 3/7 quests)   │  │ (3-biome run, your unlocks) │
      └─────────────────────────────┘  └─────────────────────────────┘
            │                                      │
            ↓                                      ↓
4a. /packages/campaign/{factionCampaignId}  4b. /packages/campaign/{quickPlayId}
            │                                      │
            ↓                                      ↓
5.  Campaign-overview screen with quest grid (same UI for both)
            │
            ↓
6.  Pick quest → /packages/quest/{questId}  (persisted or synthesized)
7.  Play → SavedRun stamps CampaignId + QuestId
8.  Completion:
      Faction kind   → CampaignProgress updated, may unlock next quest
      QuickPlay kind → heroes.LastQuickPlayCompletedAt updated → seed rolls

SavedRun.CampaignId becomes always populated (Faction id or QuickPlay synthetic id). Telemetry queries become uniform: "runs in QuickPlay vs. Iron Vigil" is one query.

10. Migration Plan (Phased)

Phase Scope Risk Notes
0 Approve this design doc This document
1 Add empty package DTOs to EchoSpire.Contracts (no controllers yet) Low Pure additive
2 Implement Campaign entity + campaigns / campaign_quests tables; admin-react CRUD Low New entity; doesn't break anything
3 Implement CatalogPackageBuilder + /packages/catalog route alongside GameDataSnapshot Low New endpoint, old keeps working
4 Implement HeroPackageBuilder + /packages/hero/{id} (consults dev.unlockAllCards) Medium First client-facing package
5 Implement FactionCampaignBuilder + QuestPackageBuilder + routes Medium
6 Implement QuickPlayCampaignBuilder + SynthesizedQuestBuilder + route resolvers Medium Reuses MapGenerator
7 Migrate WPF hero-select to HeroPackage Medium Validate the pattern end-to-end
8 Migrate WPF campaign / quest screens High Touches the most code
9 Migrate Unity client to packages Medium Uses same DTOs
10 Deprecate GameDataSnapshot for client use; keep for admin/seed Low
11 Move asset delivery to Azure CDN base URL in production Medium Configuration + smoke test

Each phase ships independently. No big-bang cutover.

11. Open Questions / Decisions Pending

  1. Card data home — small enough to live in DataCatalog, or large enough to warrant a separate CardCatalogPackage? Lean: DataCatalog.
  2. Effect parameter shape — typed per effect kind, or Dictionary<string,object>? Lean: typed records per kind.
  3. Catalog delta format — return only new entries (proposal) plus If-None-Match ETag for full fetches. Both supported.
  4. Localization — out of scope for this doc; future additive layer (l10n overlay packages keyed by locale).
  5. Reserved kinds (Endgame, Skirmish) — design later when they're actually needed; the discriminator pattern keeps them additive.

12. Risks Recap

Risk Mitigation
Premature granularity Start with four packages. Add only when a screen demands it.
Version-skew bugs (refs to missing catalog entries) IPackageValidator enforces at compose time. CI verifies asset hashes.
Designers swap an asset without bumping version CI guardrail: hash mismatch fails the build.
QuickPlay seed surprises (biomes change mid-browse) Seed stable until LastQuickPlayCompletedAt updates.
Dev card-unlock leaks to prod Feature switch gated by allow-list of dev user ids; auditable through existing CRUD.
Migration cost while clients still use GameDataSnapshot Phased plan. Old and new endpoints coexist.
CDN cache poisoning Content-hashed filenames make this impossible by construction.

13. Why This Pattern Fits

This design echoes well-trodden patterns from production game development:

  • Manifests / loadout bundles (Destiny, Hearthstone): server composes per-screen bundles.
  • Unity Addressables / Unreal pak chunks: ref-by-key, version-aware, CDN-backed asset resolution.
  • CQRS read models: write-side entities stay normalized; read-side projections are tailored to consumers.

EchoSpire fits the pattern's preconditions: multiple clients (WPF, Unity, Console), remote assets, content-driven gameplay, and a desire to keep clients dumb about server schema. Adding Campaign as an authored aggregate and folding QuickPlay into the same shape gives the player-facing UI a uniform contract regardless of whether the content was authored or generated.


End of proposal. Ready for review.