26 KiB
Progression (成长系统)
Status: Designed Author: SepComet + agents Last Updated: 2026-04-29 Implements Pillar: [To be defined in game-pillars.md — Progression serves the "collection and mastery" fantasy; pillar text not yet written]
Overview
Progression is the permanent state manager that persists across runs. It owns the read/write of unlock state (themes, difficulty tiers, component pools) and the lifetime statistics record. It does not hold run-level state (Gold, Inventory, Tower configs — those live and die within a run). On every RunEnd, the Node System sends final run stats to Progression; Progression evaluates whether any unlock thresholds are crossed; if so, the player's unlock pool expands. The player sees this as "my account is more powerful" — new options available at the start of every subsequent run.
Player Fantasy
"Complete your collection. Fill every gap. Nothing left on the table."
The Progression fantasy is the feeling of relentless collection — every run earns something toward a permanent expansion of what's possible. A component type unlocked, a difficulty tier cracked, a theme revealed. The next run the player opens, they notice the new option immediately and feel the game acknowledge their effort. The goal is to empty the unlock list — to have seen everything the game offers. This is the roguelike's fundamental pull: the set of available tools today is larger than it was ten runs ago.
The player should feel:
- Driven by gaps — the unlock list shows what's missing; completing a set feels like closing a circuit
- Rewarded for exploration — trying a new difficulty or theme unlocks more content as a side effect
- Invested in permanence — nothing earned is ever lost; the account is a record of everything accomplished
Detailed Design
Core Rules
SR1. Data Persistence: ProgressionData is a persistent save-file object. It is loaded at game start and saved after every RecordRunEnd() call. It holds: UnlockedDifficulties, UnlockedThemes, UnlockedComponentPools, UnlockedStartingLoadouts, CompletionCounts, and LifetimeStats.
SR2. Unlock Evaluation Trigger: On every RunEnd with bossDefeated = true, the Node System calls Progression.RecordRunEnd(runStats). Progression evaluates all unlock conditions against the current ProgressionData. Unlocked items are added to the appropriate pool immediately and an UnlockResult[] is returned. Loss runs record stats only; no unlock evaluation.
SR3. Difficulty Unlock Chain: Normal is always unlocked. Hard unlocks when player defeats Boss on Normal. Expert unlocks when player defeats Boss on Hard. Nightmare unlocks when player defeats Boss on Expert.
SR4. Theme Unlock: Themes are parallel — any theme whose unlock condition is met becomes available. Player selects theme at run start from the New Game screen. Unlock condition per theme stored in DRTheme.UnlockCondition.
SR5. Component Pool Unlock: Component pools (rarity tiers) unlock based on difficulty and win-count conditions. Pools are additive — when a pool unlocks, its components become available in shop and drop tables. DRComponentPool.UnlockCondition defines the gating condition per pool.
SR6. Starting Loadout Selection: At run start, player chooses one loadout from all UnlockedStartingLoadouts. Starting bonuses are applied immediately when the run begins: gold added to PlayerInventoryComponent.Gold, pre-built towers assembled and placed in roster.
SR7. Lifetime Stats: LifetimeStats is updated on every run end (win or loss): TotalRunsStarted++, TotalGoldEarned += gold, FurthestNodeReached = max(previous, nodesCompleted), etc. Win stats updated only on bossDefeated = true.
SR8. Unlock Feedback: On successful unlock evaluation, a UnlockedEventArgs is fired. UI listens and shows an animated toast popup listing the newly unlocked item(s). The toast appears on the RunEnd victory screen before returning to menu.
States and Transitions
Progression is purely passive — it has no runtime state machine. It exposes interfaces that other systems call.
| State | Description |
|---|---|
Loaded |
Save file loaded into memory. Progression data is current. |
Evaluating |
RecordRunEnd() is executing; unlock conditions are being checked. |
Dirty |
New unlocks found; waiting for save. |
Saved |
Dirty state persisted to disk. |
No user-facing state machine — UI screens that display Progression data (Profile, New Game) are owned by the UI layer, not by Progression.
Interactions with Other Systems
| System | Direction | Interface |
|---|---|---|
| Node System | Receives from | RecordRunEnd(runStats) — called on every run end (win or loss). runStats contains {goldEarned, nodesCompleted, bossDefeated, coinsEarned, componentsDropped}. |
| Shop System | Reads from | GetUnlockedComponentPools() — shop uses this to determine which component rarities appear in BuildShopGoods(). |
| UI / New Game Screen | Reads from | GetUnlockedDifficulties(), GetUnlockedThemes(), GetUnlockedStartingLoadouts() — populate run setup UI. |
| UI / Profile Screen | Reads from | GetLifetimeStats() — displays career statistics. |
| UI / Run End Screen | Receives from | OnUnlockedEventArgs — triggers toast popup for new unlocks. |
| Event System | Soft read | Event outcomes do not directly affect Progression. Events may reference GetLifetimeStats() for conditional text. |
Formulas
1. UnlockEvaluation — Run-End Unlock Check
The UnlockEvaluation(runStats) function is called by Progression.RecordRunEnd() on every winning run. It checks all locked unlockables and returns a list of UnlockResult objects for newly unlocked items.
Function signature:
UnlockResult[] UnlockEvaluation(RunStats runStats)
Variables:
| Variable | Symbol | Type | Range | Description |
|---|---|---|---|---|
| bossDefeated | b | bool | {true, false} | Whether Boss was defeated this run |
| difficulty | d | DifficultyType | Normal..Nightmare | Difficulty at run start |
| totalWins | w | int | ≥ 0 | Cumulative wins across all runs |
| totalEnemiesDefeated | e | int | ≥ 0 | Cumulative enemies killed (lifetime) |
| nodesCompleted | n | int | 0–10 | Nodes cleared this run |
Output Range: 0 to N newly unlocked items per run. In practice, typically 0–2.
A. Difficulty Unlock (chain)
nextDifficulty(d) = {
Normal → Hard,
Hard → Expert,
Expert → Nightmare,
Nightmare → null (no next tier)
}
IF b == true AND nextDifficulty(d) != null THEN
unlock(nextDifficulty(d))
Example: Player defeats Boss on Normal → nextDifficulty(Normal) = Hard → Hard is unlocked.
B. Theme Unlock (per-theme condition from DRTheme)
FOR each locked theme T:
IF T.condition.type == WinOnDifficulty
AND b == true AND d >= T.condition.targetDifficulty
OR T.condition.type == TotalWins
AND w >= T.condition.targetWins
OR T.condition.type == Mixed
AND b == true AND d >= T.condition.minDifficulty
AND w >= T.condition.minWins
THEN unlock(T)
Example: Frost theme has condition WinOnDifficulty(Normal). Player wins on Normal → Frost unlocked.
C. Component Pool Unlock (rarity tiers)
poolUnlocked(r, d, w) = (
(r == White) OR
(r == Green AND d >= Normal) OR
(r == Blue AND d >= Hard AND w >= 2) OR
(r == Purple AND d >= Expert AND w >= 5) OR
(r == Red AND d >= Nightmare AND w >= 10)
)
FOR each locked component pool P:
IF poolUnlocked(P.rarity, d, w) == true THEN unlock(P)
Example: Player wins on Hard (d=Hard, w now = 1). Blue pool: d >= Hard (true), w >= 2 (false) → not yet. After 2 total wins: Blue pool unlocks.
D. Starting Loadout Unlock (milestone conditions)
FOR each locked loadout L:
IF L.condition.type == TotalWins AND w >= L.condition.targetWins
OR L.condition.type == EnemiesDefeated AND e >= L.condition.targetEnemies
OR L.condition.type == NodesCompleted AND n >= L.condition.targetNodes
OR L.condition.type == Combined AND w >= L.condition.wins
AND e >= L.condition.enemies
THEN unlock(L)
Example: "Starter Pack" loadout has condition TotalWins(3). After 3rd win → unlocked.
2. LifetimeStats.Update
Called on every RecordRunEnd() for both win and loss runs.
LifetimeStatsUpdate(runStats):
// All runs
totalRunsStarted += 1
totalGoldEarned += runStats.goldEarned
furthestNodeReached = max(furthestNodeReached, runStats.nodesCompleted)
totalNodesCompleted += runStats.nodesCompleted
totalEnemiesDefeated += runStats.enemiesDefeatedThisRun
totalComponentsCollected += runStats.componentsCollectedThisRun
// Win runs only (bossDefeated == true)
IF runStats.bossDefeated == true:
totalWins += 1
winsByDifficulty[runStats.difficulty] += 1
winsByTheme[runStats.theme] += 1
bossesDefeated += 1
Output: LifetimeStats updated in-place. No return value.
3. StartingBonusResolve
Called at run start when player selects a starting loadout.
StartingBonus StartingBonusResolve(loadoutId):
LOADOUT = DRStartingLoadout[loadoutId]
IF LOADOUT == null:
return StartingBonus { goldAmount=0, prebuiltTower=null }
goldAmount = LOADOUT.goldBonus
IF LOADOUT.hasPrebuiltTower == true:
prebuiltTower = AssembleTower(LOADOUT.prebuiltTowerComponents)
ELSE:
prebuiltTower = null
return StartingBonus { goldAmount, prebuiltTower }
Edge case: If loadoutId not found → return {goldAmount=0, prebuiltTower=null}. If tower components unavailable → return {goldAmount=LOADOUT.goldBonus, prebuiltTower=null}.
4. Starting Gold Cap Check
When StartingBonusResolve returns a gold amount, it is added to the run's starting gold, which is then subject to MaxPlayerGold (9999) per the Shop GDD.
effectiveStartingGold = Min(defaultStartGold + goldAmount, MaxPlayerGold)
defaultStartGold comes from DRRunConfig.StartGold.
Edge Cases
- If player loses any run: No unlock evaluation. LifetimeStats still updated (run count, gold, furthest node). Losses contribute to stats but not unlocks.
- If all unlock conditions already satisfied:
UnlockEvaluationreturns empty array. No-op. Player keeps their unlocks. - If save file is corrupted or missing on load: Initialize fresh
ProgressionDatawith defaults (Normal difficulty only, Plain theme only, no bonus loadouts). Log error. - If
RecordRunEnd()is called twice for the same run: Deduplicated by run ID. Only first call processes. - If
totalEnemiesDefeatedortotalComponentsCollectedwould overflow: Uselong(Int64) for these cumulative fields.intfor other counters. - If
runStats.goldEarned < 0: Treat as 0, log error. Gold should never be negative. - If pre-built tower components are not yet unlocked: Return
{goldAmount=LOADOUT.goldBonus, prebuiltTower=null}. Log warning. Player can still start run. - If
defaultStartGold + goldBonus > MaxPlayerGold(9999): Cap at 9999. Excess discarded. - If Alt+F4 mid-run (no RunEnd dispatched): No stats recorded for that partial run. Next run starts clean.
- If two unlocks trigger in same run: Both appear in the
UnlockResult[].UnlockedEventArgsfired once with all new unlocks. Player sees both in toast. - If difficulty enum is invalid in runStats: Skip difficulty unlock evaluation. Log error. Other unlocks (themes, pools) still processed.
- If player wins on Hard but has not unlocked Hard (impossible by SR3):
UnlockEvaluationstill processes — the difficulty field in runStats reflects what was played. Player having unlocked Hard is pre-checked at run start, not re-checked at evaluation. - If pre-built tower loadout selected but player has no inventory space: Pre-built tower is placed directly into combat roster (slot 1), not inventory. No inventory space required.
Dependencies
Upstream Dependencies (what Progression depends on)
| System | Type | Interface | Status |
|---|---|---|---|
| Node System | Hard | RecordRunEnd(runStats) is called by the Procedure layer after every run end. runStats contains {difficulty, theme, goldEarned, nodesCompleted, bossDefeated, coinsEarned, componentsDropped}. No run-level state is stored in Progression between calls. |
GDD exists (design/gdd/node-system.md) — In Review |
| Shop System | Soft | Progression reads GetUnlockedComponentPools() to determine which component rarities appear in shop. If shop needs to filter by rarity, it calls this method. |
GDD exists (design/gdd/shop.md) — In Design |
Downstream Dependents (what depends on Progression)
| System | Type | Interface | Status |
|---|---|---|---|
| Node System | Hard | Node System's RunEnd state cannot persist without Progression. All acceptance criteria involving Progression.RecordRunEnd() and Progression.GetLifetimeStats() are blocked until this GDD is completed. |
GDD exists — In Review; blocked by this GDD |
| Shop System | Soft | Shop may read GetLifetimeStats() for conditional text or future features (e.g., "you've spent X gold across all runs"). Not currently required. |
GDD exists |
| UI / New Game Screen | Hard | Populates difficulty, theme, and starting loadout selection from GetUnlockedDifficulties(), GetUnlockedThemes(), GetUnlockedStartingLoadouts(). |
Pending implementation |
| UI / Profile Screen | Hard | Displays lifetime statistics from GetLifetimeStats(). |
Pending implementation |
| UI / Run End Screen | Hard | Listens for OnUnlockedEventArgs. Shows toast popup listing new unlocks on victory. |
Pending implementation |
| Event System | Soft | Events may read GetLifetimeStats() for conditional flavor text (e.g., "You've won X times"). Not required for MVP. |
GDD exists |
Bidirectional Consistency Check
- Node System → upstream (writes to Progression via
RecordRunEnd) ✅ - Progression → downstream to Node System (Node System blocked until Progression exists) ✅
- Shop System → reads component pool unlock state ✅
- Event System → soft dependency, no hard coupling ✅
Tuning Knobs
All unlock conditions are data-table-driven. No code changes are required to add or modify unlock conditions.
Data-Driven Tuning Tables
| Table | Controls | Designer Knobs |
|---|---|---|
DRDifficultyTier |
Difficulty unlock chain | nextTierId, unlockConditionType, unlockThreshold |
DRTheme |
Theme unlock conditions | unlockConditionType, targetDifficulty, targetWins, minWins, minDifficulty |
DRComponentPool |
Component pool rarity gates | rarity, minDifficulty, minWins |
DRStartingLoadout |
Starting loadout conditions and bonuses | conditionType, targetWins, targetEnemies, targetNodes, goldBonus, hasPrebuiltTower, prebuiltTowerComponents |
Runtime Tuning Knobs
| Knob | Default | Safe Range | Extreme: Too Low | Extreme: Too High |
|---|---|---|---|---|
MaxPlayerGold |
9999 | 5000–99999 | Starting bonuses feel large; shop loses tension | Gold feels pointless; player never feels rich |
DefaultStartGold (DRRunConfig.StartGold) |
varies | 0–5000 | Player starts too poor; first shop feels bad | Player starts too rich; first shop trivial |
DRStartingLoadout.goldBonus |
varies | 0–5000 | Bonus too low; no meaningful start change | Bonus too high; shop becomes irrelevant |
DRComponentPool.minWins |
varies | 0–50 | Higher pools accessible too early; power spike | Higher pools locked too long; mid-game feels boring |
DRTheme UnlockDifficulty |
varies | varies | Easier themes → faster content exhaustion | Harder themes → grindy; "one more run" becomes frustrating |
Knob Interactions
- Starting gold bonus + MaxPlayerGold: If
defaultStartGold + goldBonus > MaxPlayerGold, excess is silently discarded. Ensure bonuses respect the cap. - Difficulty unlock + Component pool: When a new difficulty tier unlocks, the component pool for that tier becomes available. Ensure pool content (components) exists before the difficulty is unlockable.
- Theme unlock conditions: Some themes reference
minWins. Ensure the intended playtime before unlocking matches the expected pacing curve.
Visual/Audio Requirements
Progression is a data-layer system with no inherent visual or audio identity. The player's interaction with Progression is mediated entirely through UI screens (New Game, Profile, Toast). No dedicated VFX or audio events are owned by the Progression system itself.
However, the Toast Popup (Section UI Requirements) does have visual requirements: the unlock notification should use the game's geometric shape vocabulary (diamonds, triangles, hexagons) consistent with the shop rarity shimmer and node completion VFX.
Visual Style for Toast Popup:
- Shape: diamond outline frame around unlock icon
- Rarity color coding: theme unlocks use theme's accent color; difficulty unlocks use difficulty's color; component pool unlocks use the newly available rarity's color
- Entry animation: scale 0.8x → 1.0x, 250ms ease-out
- Particle burst on unlock: geometric particles matching the unlock category
Audio for Toast Popup:
- Ascending 3-note arpeggio (C4-E4-G4) at volume 0.5, 200ms
- Distinct from shop purchase arpeggio (which is rarity-keyed and longer)
UI Requirements
New Game Screen (Run Setup)
Trigger: Player selects "New Run" from main menu.
Layout:
- Title: "New Run"
- Difficulty row: horizontal list of unlocked difficulties; locked ones shown as silhouettes with lock icon and tooltip showing unlock condition
- Theme row: horizontal grid of theme cards; locked themes hidden or shown as silhouettes
- Starting Loadout row: horizontal list of owned loadouts; radio-button selection (one active at a time)
- Start Run button: bottom-center, large, disabled until at least one valid combination is selected
Unlock Feedback on Screen:
- When a new unlock becomes available between when the player last viewed New Game and pressed Start, the newly available item pulses briefly (once) to draw attention
Constraint: Player cannot select a locked difficulty or theme. UI enforces this; no runtime validation needed.
Profile Screen (Lifetime Statistics)
Trigger: Player selects "Profile" or "Stats" from main menu.
Layout:
- Header: total runs, total wins, win rate (percentage)
- Primary stats grid: Total Gold Earned, Furthest Node Reached (1–10), Bosses Defeated
- Secondary stats (tabbed or collapsible): Components Collected, Wins by Difficulty (table), Wins by Theme (table)
- No editing — display only
Constraint: All stats are read from GetLifetimeStats(). No modification is possible from this screen.
Toast Popup (Unlock Notification)
Trigger: OnUnlockedEventArgs fires on RecordRunEnd with non-empty UnlockResult[].
Layout:
- Position: bottom-center of screen, above Run End screen content
- Content: unlock category icon (geometric shape), unlocked item name, "UNLOCKED" label
- Shape: diamond outline frame
- Entry: scale 0.8x → 1.0x, 250ms ease-out
- Exit: fade out over 200ms on click or after 5 seconds
Constraints:
- Multiple unlocks in same run: show one toast per item, staggered 300ms apart
- If player clicks away immediately, toast dismisses immediately — do not block
Accessibility
- All unlock conditions shown as text in tooltip (no icon-only indicators)
- Toast is keyboard-accessible (Enter/Space to dismiss)
- Profile screen stats are screen-reader friendly with labeled values
Acceptance Criteria
Data Persistence
- GIVEN a new player starts the game for the first time, WHEN they begin a run, THEN only Normal difficulty, Plain theme, and the default starting loadout are available.
- GIVEN the player's save file is corrupted or missing, WHEN the game loads, THEN a fresh
ProgressionDatais initialized with defaults (Normal only, Plain only, no bonus loadouts) and no crash occurs.
Unlock Evaluation
- GIVEN a player completes a run with
bossDefeated=false(loss), WHENRecordRunEndis called, THENtotalRunsStartedincrements,totalGoldEarnedincreases bygoldEarned,furthestNodeReachedandtotalNodesCompletedupdate correctly, but no unlock evaluation occurs. - GIVEN a player defeats the Boss on Normal difficulty for the first time, WHEN
RecordRunEndis called withbossDefeated=trueanddifficulty=Normal, THEN Hard difficulty is unlocked and appears in the New Game screen. - GIVEN a player defeats the Boss on Hard difficulty, WHEN
RecordRunEndis called, THEN Expert difficulty is unlocked. - GIVEN a player defeats the Boss on Expert difficulty, WHEN
RecordRunEndis called, THEN Nightmare difficulty is unlocked.
Theme Unlock
- GIVEN a theme has
UnlockCondition = WinOnDifficulty(Normal)and the player defeats the Boss on Normal, WHENRecordRunEndcompletes, THEN that theme appears in the theme selection on the New Game screen. - GIVEN a theme has
UnlockCondition = TotalWins(5)and the player earns their 5th total win, WHENRecordRunEndcompletes, THEN that theme becomes available in the New Game screen.
Component Pool Unlock
- GIVEN a player wins on Normal difficulty for the first time, WHEN
RecordRunEndcompletes, THEN the Green component pool is unlocked and Green rarity components appear in the shop's offered goods. - GIVEN a player has 2+ wins and wins on Hard difficulty, WHEN
RecordRunEndcompletes, THEN the Blue component pool is unlocked.
Starting Loadout
- GIVEN a player has 3 total wins and has unlocked Hard, WHEN they complete a run on Hard difficulty, THEN any Starting Loadout with condition
TotalWins(3)becomes unlocked and visible in the loadout selection. - GIVEN a player selects a Starting Loadout with
goldBonus=500, WHEN the run begins, THENPlayerInventoryComponent.GoldequalsdefaultStartGold + 500, capped atMaxPlayerGold(9999). - GIVEN a player selects a Starting Loadout with a pre-built tower, WHEN the run begins, THEN slot 1 of the combat roster contains the pre-built tower assembled from the loadout's components.
- GIVEN a player's pre-built tower loadout references components not yet unlocked, WHEN
StartingBonusResolveis called, THEN the gold bonus is applied,prebuiltTowerisnull, and a warning is logged.
Lifetime Stats (All Runs)
- GIVEN a player completes 3 runs with
nodesCompletedof 4, 7, and 5 respectively, WHENLifetimeStatsis queried, THENtotalNodesCompletedequals 16. - GIVEN a player Alt+F4s mid-run without triggering
RunEnd, WHEN the game restarts, THEN no stats from that partial run appear inLifetimeStats.
Lifetime Stats (Win Runs Only)
- GIVEN a player wins a run, WHEN
RecordRunEndis called withbossDefeated=true, THENtotalWinsincrements,winsByDifficulty[difficulty]increments,winsByTheme[theme]increments, andbossesDefeatedincrements. - GIVEN
totalEnemiesDefeatedwould exceedint.MaxValuewith a large cumulative value, WHENLifetimeStats.Updateis called, THEN the field useslong(Int64) and does not overflow.
Unlock Feedback
- GIVEN a player wins a run that triggers two new unlocks simultaneously, WHEN
RecordRunEndcompletes, THEN the Run End screen shows a toast popup listing all newly unlocked items.
Edge Cases
- GIVEN
RecordRunEndhas already been called for run ID "abc123", WHEN it is called again with the same run ID, THENLifetimeStatsonly increments once and only one unlock evaluation occurs. - GIVEN
runStats.goldEarnedis negative (e.g., −100) due to an upstream bug, WHENLifetimeStats.Updateis called, THENtotalGoldEarnedincreases by 0, not −100, and the error is logged.
Open Questions
1. Standalone Binary Achievements
Status: OPEN — Should there be standalone binary achievements separate from the 4 unlock categories (e.g., "Defeat 100 Bosses", "Collect 1000 Components", "Win on all difficulties")? These would be display-only badges with no gameplay effect. Currently all unlocks are gated by the 4 categories. Adding achievements as a 5th category would increase content without adding mechanical variety.
2. Cloud Save Sync
Status: OPEN — Any cloud sync considerations for ProgressionData across devices? Single-player desktop game — likely local-only. If multiplayer or cross-device play is ever added, ProgressionData would need serialization parity and conflict resolution.
3. Starting Loadout Components Catalog
Status: OPEN — When a pre-built tower loadout references prebuiltTowerComponents, which component pool do those components draw from? If the referenced components are from a pool the player hasn't unlocked yet, StartingBonusResolve returns null tower (per edge case). Should pre-built loadouts source from a special "Starter" component pool that's always available, rather than the normal pool?
4. "First Time" Callback to Audio/VFX
Status: OPEN — The GDD specifies a Toast popup for unlock feedback. Should there also be a dedicated "first time ever" callback signal that the audio system can hook for a distinct sound on the very first unlock of any category? Or is the Toast audio sufficient?