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/containsCatalogPackageBuilder,HeroPackageBuilder, andFactionCampaignBuilder, served byPackagesController, with asset manifests inInfrastructure/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¶
- 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.
- Cleanly separate authored content (designer-owned, cacheable) from runtime state (per-run, mutable).
- Make assets CDN-friendly with content-hashed URIs and delta-fetchable catalogs.
- Keep one source of truth: admin edits domain entities; the server composes packages from them.
- 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.
Questowns itsBiome→ embed.Heroowns itsFactionandClass(lite views) → embed.Campaignowns its quest list and embeds lite quest summaries (so the campaign-overview screen renders without N quest fetches); the fullQuestPackageis 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
assetscontainer, 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:
- Lookup in
campaignstable → if found, useFactionCampaignBuilder. - Else if id matches the QuickPlay namespace → use
QuickPlayCampaignBuilder. - 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
Biomefrom catalog. - Real
NodeTemplatesmap (from the biome). - Generated node graph (
MapGeneratoralready does this). - Synthesized id deterministic per
(heroId, biomeId, runIndex). - Empty
StoryPanels, no boss override, biome-default reward pool.
GET /packages/quest/{id} route resolver:
- Lookup in
queststable → persisted quest. - Else if id matches synthesized-quest namespace →
SynthesizedQuestBuilderregenerates from(heroId, biomeId, runIndex). - 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 —
LastQuickPlayCompletedAtupdates → 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 =sincecursor)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
*Refexists in the catalog at version>= MinCatalogVersion. - Every
AssetPathreachable from referenced visuals/effects/audio exists inAssetManifestwith 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-Match → 304 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¶
- Card data home — small enough to live in
DataCatalog, or large enough to warrant a separateCardCatalogPackage? Lean:DataCatalog. - Effect parameter shape — typed per effect kind, or
Dictionary<string,object>? Lean: typed records per kind. - Catalog delta format — return only new entries (proposal) plus
If-None-MatchETag for full fetches. Both supported. - Localization — out of scope for this doc; future additive layer (
l10noverlay packages keyed by locale). - 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.