VR Roguelike · Systems Overview
Game Mechanics Map
A single-page reference for designers and engineers. It mirrors the current code (ignoring stale .md docs) so you can see how loops, stats, drops, and progression really work.
Core Loop
The game follows a classic roguelike structure: prepare for a run, fight enemies in waves, make between-wave purchases/upgrades, repeat, and convert earnings into permanent progression.
Run Initialization
- Character Selection: Player picks a character via CharacterSelectionManager. This loads base stats from CharterDataSO (Attack, AttackSpeed, MaxHealth, MoveSpeed, etc.), visuals (mesh, material), audio clips (damage/dodge SFX), and the upgrade config for meta progression.
- Meta Upgrades Applied: MetaUpgradeManager.ApplyMetaUpgrades() runs, adding permanent stat bonuses for each stat the player has leveled via gems.
- Weapon Selection: Three random weapons appear; player picks one at levels 0–3 (random). This weapon is added to the first weapon slot; others remain empty for shop purchases.
- Currency Reset:
gold = 0(runs are ephemeral; gems persist across runs). - Game State → GAME: Player enters the first wave.
Wave Execution
- Enemy Spawning: WaveManager or EndlessWaveManager spawns enemies at a configurable distance band around the player (offset 5–15 units). Each enemy runs a spawn animation (flickering indicator, disabled collider) for ~0.3s before becoming active.
- Player Combat: Player moves via XR thumbsticks. Weapons auto-aim at the nearest enemy within range and fire on timers. Damage is rolled (base + stats + crit check). On hit, Enemy takes damage, fires damage event (Enemy._onDamageTaken), spawns VFX, and dies if health ≤ 0.
- Rewards on Kill: Each enemy death triggers:
- XP grant (Enemy._xpValue, typically 1 per normal enemy, more for bosses).
- Drop roll: DropManager checks drop chances for cash/gem/chest.
- Unlock event: GameManager.OnEnemyKilled fires with enemy type name.
- Player Damage/Death: Enemies attack the player; PlayerHealth.TakeDamage() applies armor reduction and dodge check. On zero HP, state switches to GAMEOVER.
- Wave Completion: After all enemies in a segment/wave die (or time expires in endless):
- Structured: WaveManager checks if there are more segments. If yes, start the next segment; if no, mark wave complete and award gold (waveNumber × baseGoldPerWave).
- Endless: Wave counter increments, difficulty scales, and next wave starts (boss wave every Nth).
Between-Wave Phase
- Check for Upgrades/Chests: GameManager.WaveCompletedCallback() checks:
- Did player level up this wave? (PlayerXP.HasLeveledUp())
- Did player collect a chest? (WaveTransitionManager._chestCollected > 0)
- If Yes → WaveTransition State:
- If chests exist, show one ChestObjectContainer: take a random ObjectDataSO (adds stats) or recycle for gold.
- If levels exist, show 3 random stat upgrade choices. Each choice is a random stat with a random value (ranges per stat). Boss waves grant 2× bonuses.
- After choosing, return to WaveTransition for the next choice or proceed to SHOP.
- If No → Shop State: Show 6 items: mix of weapons (random) and objects (random). Prices scale by weapon level. Player can buy items if gold allows or reroll (costs gold, respects locks).
- Next Wave: Return to GAME state; next wave spawns.
Run Conclusion
- On GAMEOVER: GameManager.SetGameState() triggers gold-to-gem conversion:
gemsEarned = Mathf.FloorToInt(gold / 100). CurrencyManager adds gems to persistent storage. - On STAGECOMPLETE: (Structured mode only) All waves finished; gold is not converted (or is awarded as final reward).
- Save & Return: ES3SaveManager saves gem currency, meta upgrade levels, and character unlock progress. Player returns to MENU.
Game States & Flow
| State | Triggered By | Key Behaviors | Transitions To |
|---|---|---|---|
MENU |
App launch; GAMEOVER/STAGECOMPLETE. | Idle state. Can access CHARACTER SELECTION, SETTINGS, TUTORIAL, IAP SHOP, etc. | SELECTMODE, CHARACHTERSELECTION, etc. |
SELECTMODE |
Player chooses game mode. | Display mode selection UI (Structured or Endless). | CHARACHTERSELECTION |
CHARACHTERSELECTION |
Mode selected. | Display character roster. Player picks character; meta upgrades applied. CharacterSelectionManager fires _onCharacterSelected. |
WEAPONSELECTION |
WEAPONSELECTION |
Character selected. | 3 random weapons displayed. Player must select one or cannot start (WeaponSelectionManager.CanStartGame() enforced). Start button enabled on selection. | GAME |
GAME |
Weapon selected; TryStartGame() called. | Time.timeScale = 1. Input active; waves run; enemies spawn and attack. Weapons fire; player can take damage or die. | WAVETRANSITION, SHOP, GAMEOVER, STAGECOMPLETE |
WAVETRANSITION |
Wave complete AND (player leveled OR chest collected). | Pause gameplay. Show stat upgrade panels (if levels) or chest loot panel (if chests). Player chooses reward. | SHOP or GAME (via WaveCompletedCallback). |
SHOP |
WAVETRANSITION complete or wave end with no upgrades/chests. | Display 6 random items (weapons/objects). Reroll available. Lock items to persist. Buy items. Next wave button appears. | GAME |
GAMEOVER |
Player health ≤ 0. | Trigger gold→gem conversion. Show game over UI. Save gems and progress. Offer restart or return to menu. | MENU or restart (reload scene) |
STAGECOMPLETE |
Structured mode: final wave cleared. | Show stage completion UI. Optionally grant final gold bonus. Save progress. | MENU |
State Pattern: All state changes call GameManager.SetGameState(), which notifies every IGameStateListner in the scene. This ensures UI, managers, and systems stay in sync.
Modes: Structured vs Endless
Structured Mode
- Architecture: WaveManager holds an array of Wave structs. Each Wave contains a list of WaveSegment structs. Each segment defines: _name (for UI), _spawnSequence (enemies/second), _enemies (array of EnemySpawnData with weighted chances), and _spawnOnce (if true, this segment spawns a boss once and waits for its death).
- Progression:
- On GAME state, WaveManager starts the first segment of wave 0.
- Segment duration (_currentSegmentDuration) is set to the configured wave duration.
- Enemies spawn according to _spawnSequence (e.g., 2 enemies/sec = spawn every 0.5s).
- If _spawnOnce: one boss spawns, timer stops, and game waits for boss death.
- Otherwise: timer counts down; when time expires, move to next segment.
- When all segments done, wave completes, gold is awarded (waveNumber × _goldPerWave), and next wave starts.
- Boss Waves: When a segment has _spawnOnce = true, it’s a boss beat. One boss enemy (e.g., BossEnemy with custom AI) spawns. Timer UI is hidden. On boss death, GameManager.OnBossKilled fires (for unlock tracking), and the next segment begins.
- Wave Count: Waves are finite; after the last wave, state switches to STAGECOMPLETE.
- Example Flow: Wave 0 has 3 segments: [10s normals (2/sec), 5s elite mix, 1× boss]. 15s total, ~70–80 enemies, then boss. Player fights ~85 enemies total, earns 50 gold (1 × 50), and moves to wave transition.
Endless Mode
- Architecture: EndlessWaveManager spawns from prefab arrays: _normalEnemyPrefabs and _bossPrefabs. No pre-authored waves; enemies spawn dynamically.
- Wave Duration: Each wave has a fixed time window (_baseWaveDuration, e.g., 30s for normal, 60s for boss). Every Nth wave (configured _bossInterval, e.g., 5) is a boss wave.
- Difficulty Scaling Per Wave:
- Spawn rate: baseRate × (1 + _spawnRateIncreasePerWave × (waveIndex – 1))
- Enemy health: baseHealth × (1 + _healthScalePerWave × (waveIndex – 1))
- Enemy damage: baseDamage × (1 + _damageScalePerWave × (waveIndex – 1))
- Boss health: baseHealth × (1 + _healthScalePerWave × (waveIndex – 1) × 2)
- Boss damage: baseDamage × (1 + _damageScalePerWave × (waveIndex – 1) × 1.5)
- Boss Waves: On boss waves, one boss spawns. Minions also spawn throughout the fight (slower spawn rate). Wave timer continues; when time expires and boss is dead, wave complete. If boss still alive, wave extends until boss dies.
- Unlock Tracking: CharacterUnlockManager.UpdateEndlessWaveProgress(waveIndex) is called each wave, allowing characters to unlock based on reaching certain endless wave numbers.
- Endless Progression: Waves never stop; player can farm indefinitely until they die.
Comparison Table
| Aspect | Structured | Endless |
|---|---|---|
| Waves | Finite, predefined | Infinite |
| Enemy Spawning | Segments with weighted spawn tables | Random prefabs from pool |
| Difficulty | Authored per wave | Automatic scaling |
| Bosses | 1 boss per final segment | Every Nth wave |
| Gold Rewards | Per-wave bonus | Per-kill drops only |
| Run End | Stage complete after last wave | Only on death |
Player: Control, Health, XP
Movement & Input
- InputManager: Detects VR headset via XRSettings.isDeviceActive. On VR, reads both left and right thumbsticks; uses whichever has higher magnitude. On non-VR, uses default Move action from InputActionAsset.
- Player Movement: PlayerController applies moveVector × _moveSpeed to Rigidbody2D.linearVelocity. Only active during GAME state; other states freeze movement.
- MoveSpeed Stat: Base speed is _baseMoveSpeed. Actual speed is _baseMoveSpeed × (1 + MoveSpeed% / 100).
Health System
- Initialization: _maxHealth = _baseMaxHealth + (int)AddedHealth from stats. Player starts at full health.
- Damage Reduction: When taking damage,
realDamage = incomingDamage × Clamp(1 - (armor / 1000), 0, 10000). High armor values can reach 100% reduction. - Dodge: Before damage is applied,
if Random(0, 100) < dodgeChance: ignore damage. Fires PlayerHealth.onAttackDodged event (cosmetic dodge indicator). - LifeSteal: When enemies take damage, PlayerHealth listens to Enemy._onDamageTaken. Healing = damageDealt × (lifeSteal% / 100). Clamped to max health.
- Death: On health ≤ 0, GameManager.SetGameState(GAMEOVER).
Regeneration
- Setup: _healthRecoveryRate is a stat (e.g., 1.0 = 1 tick/sec). _healthRecoveryDuration = 1 / rate.
- Tick Logic: Each tick, restore up to 0.1 HP (Mathf.Min(0.1, maxHealth – current)). Only during GAME state.
- Use Case: Allows passive healing between fights to reward high HealthRecoverySpeed upgrades.
Experience & Leveling
- XP Formula: _requireXP = (level + 1) × 5. E.g., level 0→1 needs 5 XP, level 1→2 needs 10 XP.
- Kill Rewards: Enemy._xpValue is per-enemy (typically 1). On death, Enemy._onEnemyKilledWithXP fires this value.
- Level Up: When _currentXP ≥ _requireXP, increment level and _levelsEared. Reset _currentXP to 0.
- Between Waves: WaveTransitionManager checks PlayerXP.HasLeveledUp(). If _levelsEared > 0, decrement and return true. Each true triggers a stat upgrade button.
- Pending Tokens: Levels don’t auto-grant stats; they queue upgrade tokens consumed in WAVETRANSITION.
Stats System
PlayerStatsManager holds three dictionaries per character:
- _playerStats: Base stats from CharterDataSO (character selection).
- _addends: Run-time bonuses (from upgrades in WAVETRANSITION).
- _objectStats: Bonuses from ObjectDataSO (collected passives).
GetStatsValue(stat) = base + addend + object. All UI and systems read via GetStatsValue().
| Stat | Source | What it influences | Formula/Bounds |
|---|---|---|---|
| Attack | Base, upgrades, objects | Weapon base damage | weaponDamage × (1 + attack% / 100) |
| AttackSpeed | Base, upgrades, objects | Weapon fire rate | delay = baseDelay / (1 + attackSpeed% / 100) |
| CriticalChance | Base, upgrades, objects | Crit trigger chance | 0–100%, rolled per hit |
| CriticalPercent | Base, upgrades, objects | Crit damage multiplier | critDamage = base × (1 + percent) |
| MoveSpeed | Base, upgrades, objects | Player velocity | velocity = baseSpeed × (1 + %/100) |
| MaxHealth | Base, upgrades, objects | Health pool | max = base + added |
| Range | Base weapon, upgrades, objects | Weapon target radius | range = base + added |
| HealthRecoverySpeed | Base, upgrades, objects | Heal tick frequency | ticks/sec = rate |
| Armor | Base, upgrades, objects | Flat damage reduction | reduction = 1 – (armor/1000) |
| Luck | Base, upgrades, objects | Random upgrade drops | Used in future features |
| Dodge | Base, upgrades, objects | Avoid damage chance | 0–100%, rolled per hit |
| LifeSteal | Base, upgrades, objects | Heal from damage dealt | heal = damageDealt × (steal% / 100) |
Combat: Weapons
Weapon Acquisition & Lifecycle
- Starter Weapon: On WEAPONSELECTION, three random weapons appear with random levels 0–3. Player must pick one or cannot start game. This weapon is added via PlayerWeapon.TryAddWeapon().
- Shop Purchases: In SHOP state, random weapons appear at level 0–1. If player has free weapon slots (PlayerWeapon._weaponPositions is an array of max 4–6), they can buy. On purchase, WeaponStatsCalculator.GetPurchasePrice(weapon, level) is deducted from gold.
- Recycling: Player can recycle a weapon via the UI. Recycle price = WeaponStatsCalculator.GetRecyclePrice(weapon, level). Gold is refunded; weapon slot is freed.
- Weapon Upgrade: Not implemented via in-game purchasing (but framework exists). Weapon.Upgrade() increments Level and recalculates stats.
Range Weapons (Guns, Bows, etc.)
- Aiming: RangeWeapon.AutoAim() finds nearest enemy within _range. If found, rotate transform.up toward target; else lerp to neutral (up). Uses _aimLerp speed.
- Firing: When timer ≥ _attackDelay, call Shoot(). Spawn bullet from pool, set velocity to forward × _movementSpeed, apply damage (with crit roll).
- Bullet Pooling: ObjectPool reduces allocations. On hit or lifetime expiry, bullet is released back to pool.
- Damage Calculation:
GetDamage(out isCrit) { if Random(0,101) ≤ critChance: return damage × critPercent; else: return damage; } - Hit Detection: Bullet.OnTriggerEnter2D checks if collider is in _enemyLayer. If yes, call Enemy.TakeDamage(damage, isCrit) and return bullet to pool.
Melee Weapons (Swords, Axes, etc.)
- Aiming: MeleeWeapon.AutoAim() finds nearest enemy. If found, rotate toward target; else lerp neutral. Uses _aimLerp.
- Attack Loop: State machine: Idle or Attack. In Idle, increment _attackTimer. When timer ≥ _attackDelay, transition to Attack (play animation via _animator.Play(“Attack”)).
- Damage Application: During attack animation, overlap box (Physics2D.OverlapBoxAll) is checked at _transformHitPoint. For each enemy in the box not yet in _damagedEnemies, call TakeDamage() and add to list. List clears on attack end.
- Stat Modulation: _animator.speed = 1 / _attackDelay, so animation duration matches attack delay.
Weapon Stat Calculation
- WeaponStatsCalculator.GetStats(): Takes WeaponDataSO (base stats) and level. Applies multiplier:
multiplier = 1 + (level / 3). Each stat in BaseStats × multiplier. - Player Stat Modulation: RangeWeapon/MeleeWeapon.UpdateStats() applies player stats:
- damage = base × (1 + playerAttack% / 100)
- attackDelay = baseDelay / (1 + playerAttackSpeed% / 100)
- critChance × (1 + playerCritChance% / 100)
- critPercent += playerCritPercent
- range += playerRange / 15
- Frequency: UpdateStats() called when PlayerStatsManager updates (character selection, stat bonuses, object adds).
Weapon Events & Audio
- SFX: PlayAttackSound() plays WeaponData.AttackSound with random pitch (0.8–1.2) if AudioManager.IsSFXOn.
- Events: Critical hits fire GameManager.OnCriticalHit (for achievement tracking). Enemy damage events publish GameManager.OnDamageDealt.
Enemies & Bosses
Enemy Base Class & Lifecycle
- Spawn Sequence: On instantiation, StartSpawnSequence() hides visuals and disables collider/movement/animator. Shows spawn indicator, scales it with LeanTween, then enables gameplay components after ~0.3s (8 tween loops × 0.04s cycle).
- Health & Damage: _health starts at _maxHealth. TakeDamage(damage) applies damage and fires _onDamageTaken event (used by player for lifesteal, damage tracking, damage text). On death, PassAway() fires _onPassAway (for drops), _onEnemyKilledWithXP (for XP), and unlocks (GameManager.OnEnemyKilled).
- Scaling (Endless): ScaleStats(healthMult, damageMult) is called per-wave. _maxHealth and (if alive) _health are multiplied. Subclasses override to scale attack stats.
- Detection Radius: Used by melee to maintain distance. Displayed in editor gizmos if _showDetectionRadius = true.
Melee Enemies
- Behavior: State machine with distance management. If sqrDistance > sqrRadius: follow player. If sqrDistance < 0.8× sqrRadius: move away (kite). If in band: stop and attack on timer.
- Attack: On timer trigger and distance check, call Player.TakeDamage(_damage). Simple contact damage model; no projectiles.
- Optimization: Uses sqrMagnitude comparisons to avoid sqrt() per frame.
Ranged Enemies
- Components: RangeEnemy + RangeEnemyAttack. RangeEnemy manages movement; RangeEnemyAttack manages shooting.
- Movement: If distance > _detectionRadius: follow. If distance < _minAttackDistance: move away. Else: stop and attack.
- Shooting: RangeEnemyAttack.AutoAttack() fires on a timer. Bullet is spawned at _attackPoint, velocity set to direction × 5 m/s. Uses pooling (ObjectPool).
- Sprite Flipping: transform.localScale.x flips based on player position relative to enemy (visual only).
Boss Enemies
- Architecture: Custom state machine: Idle → Moving → Attacking → Idle loop. Also has Dead state.
- Idle: Waits for random duration (1–_maxIdleDuration). Plays “Idle” animation.
- Moving: Walks to random position within map bounds (-14,14 X, -6,6 Y). Uses Vector3.MoveTowards with _moveSpeed. Plays “Moving” animation.
- Attacking: Plays “Attack” animation. Calls Attacking() which fires 8 bullets in a radial pattern (45° increments). Uses RangeEnemyAttack.InstantShoot().
- Health Bar: Canvas-based slider shows current HP / max HP. Updated on damage via DamageTakenCallback().
- Death Event: PassAway() fires _onBossPassAway (instead of _onPassAway), triggering chest drop and unlock progression.
- Scaling: Boss health scales 2× faster than normal enemies; damage scales 1.5× faster.
Enemy Events & Progression
| Event | Fired By | Listeners | Use |
|---|---|---|---|
| _onDamageTaken | Enemy.TakeDamage() | PlayerHealth, DamageTextManager | LifeSteal, damage numbers |
| _onPassAway | Enemy.PassAway() | DropManager | Roll and spawn cash/gem/chest |
| _onBossPassAway | BossEnemy.PassAway() | DropManager | Guaranteed chest drop |
| _onEnemyKilledWithXP | Enemy.PassAway() | PlayerXP | Award XP |
| GameManager.OnEnemyKilled | Enemy.PassAway() | CharacterUnlockManager, AchievementManager | Unlock progression, achievements |
| GameManager.OnBossKilled | WaveManager (on boss defeat) | CharacterUnlockManager, AchievementManager | Boss-specific unlocks |
Drops, Currency & Commerce
Drop System
- Drop Manager: Listens to Enemy._onPassAway and _onBossPassAway. On each normal kill, rolls:
- Random 0–100. If ≤ _gemDropChance (e.g., 10): spawn gem.
- Else if ≤ _gemDropChance + _cashDropChance (e.g., 10+50 = 60): spawn cash.
- Else: no drop.
- Boss Drops: BossEnemyPassedAwayCallback() directly instantiates a chest (guaranteed). Also rolls for normal drops independently.
- Drop Positioning: Dropped items spawn at enemy position + random offset (±2 units X/Y). On collection, items lerp to player over ~1s via DroppableCurrency.MoveToPlayer().
- Chest Frequency: On normal kills, TryDropChest() rolls _chestDropChance (e.g., 5%). If true, chest instantiates (non-pooled).
Currency Types & Conversion
- Gold (Run Currency): Ephemeral per run. Dropped as Cash items. Used for shop purchases and rerolls. Starts at 0; resets on GAMEOVER.
- Gems (Persistent Currency): Cross-run currency. Dropped as Gem items. Used for meta upgrades and character unlocks. Persists via ES3SaveManager.SaveImmediate().
- Gold → Gem Conversion: On GAMEOVER, CurrencyManager.ConvertGoldToGemsOnRunEnd() executes:
gemsEarned = Mathf.FloorToInt(gold / 100). Gems are added immediately; gold is not reset until next run start.
Currency Manager
- Singleton: Persists across scenes (DontDestroyOnLoad). Manages both gold and gems.
- Events: OnCurrencyChanged fires when gold changes; UI updates in real-time via CurrencyText components.
- Shop Integration: HasEnoughtCurrency(price) and UseCurrency(price) are called by ShopManager to validate and deduct purchases.
- Save/Load: Gems saved to ES3SaveManager.KEY_GEM_CURRENCY immediately on change. Gold is not saved (runs are ephemeral).
Object System (Passive Items)
- ObjectDataSO: Defines passive items (e.g., amulets, talismans). Each has Icon, Name, Price, RecyclePrice, Rarity, and BaseStats (dictionary of stats).
- Acquisition: Objects appear in shop randomly. Can also drop from chests as treasure rewards.
- Application: PlayerObject.AddObject() adds ObjectDataSO to inventory and calls PlayerStatsManager.AddObject(objectData.BaseStats), applying all stats immediately.
- Recycling: PlayerObject.RecycleObject() removes item from inventory, refunds RecyclePrice gold, and subtracts stats via RemoveObjectStats().
- Stacking: Multiple copies of the same object are allowed (UI shows stack count if implemented).
Between-Wave Progression & Choices
WaveTransition State
- Entry: GameManager.WaveCompletedCallback() checks PlayerXP.HasLeveledUp() and WaveTransitionManager._chestCollected > 0. If either true, enter WAVETRANSITION.
- Chest Queue: WaveTransitionManager.TryOpenChest() pops one chest from the queue and shows ChestObjectContainer (UI with Take/Recycle buttons). Selecting either returns to TryOpenChest() for the next choice.
- Stat Upgrades: If no chests, show 3 random stat upgrade buttons via ConfigureUpgradeContainerButtons():
- Random stat from Enum.GetValues(Stats).
- Random value per stat (ranges vary: e.g., Attack +1-4, HealthRecoverySpeed +1-5%).
- Boss wave bonus: values are doubled (isBossWave check via EndlessWaveManager.IsBossWave()).
- On selection, GetActionToPerform() returns a lambda that calls PlayerStatsManager.AddPlayerStat(stat, value).
- Exit: After all choices (chests and upgrades), BonusSelectedCallback() enables the shop button and calls GameManager.WaveCompletedCallback() again, which typically enters SHOP state.
Shop State
- Setup: ShopManager.Configure() spawns 6 items:
- Random 2–4 weapons at levels 0–1 with random properties.
- Random 2–4 objects.
- Locked items from previous shop are preserved (IsLocked flag).
- Pricing: Weapons use WeaponStatsCalculator.GetPurchasePrice(weapon, level). Objects use ObjectDataSO.Price.
- Purchasing: ShopItemContainer.Purchase() calls ShopManager.TryPurchaseWeapon() or PurchaseObjectData(). On success, currency is deducted and item is added to player inventory.
- Reroll: ShopManager.Reroll() costs _rerollPrice gold and calls Configure() again. Locked items are preserved.
- Next Wave: Shop button press calls GameManager.WaveCompletedCallback(), which re-enters GAME with the next wave.
Upgrade Value Generation
GetActionToPerform() generates values per stat type:
| Stat | Min Range | Max Range | Boss Multiplier |
|---|---|---|---|
| Attack | 1 | 5 | ×2 |
| AttackSpeed | 1% | 5% | ×2 |
| CriticalChance | 1% | 5% | ×2 |
| CriticalPercent | 0.01x | 2x | ×2 |
| MoveSpeed | 1% | 5% | ×2 |
| MaxHealth | 1 | 5 | ×2 |
| Range | 1 | 10 | ×2 |
| HealthRecoverySpeed | 1% | 5% | ×2 |
| Armor | 1% | 5% | ×2 |
| Luck | 1% | 5% | ×2 |
| Dodge | 1% | 5% | ×2 |
| LifeSteal | 1% | 5% | ×2 |
Meta Progression & Character Unlocks
Meta Upgrade System
- MetaUpgradeManager (Singleton, DontDestroyOnLoad): Tracks permanent upgrades per character. Dictionary<string, dictionary> stores upgrade levels for each character and stat.
- Upgrade Levels: Each stat can be upgraded from 0 to maxLevel (configured per character in CharacterUpgradeConfigSO). Costs increase exponentially:
cost(level) = baseCost × 2^level. - Bonus per Level: Each level grants bonusPerLevel stat points (configured). Total bonus = level × bonusPerLevel.
- Purchasing: MetaUpgradeManager.PurchaseUpgrade(stat) checks gem cost, deducts gems via CurrencyManager, increments level, and saves immediately via ES3SaveManager.SaveImmediate().
- Application: On character selection, PlayerStatsManager calls MetaUpgradeManager.ApplyMetaUpgrades(this, characterName), which adds all upgrade bonuses to _addends via AddPlayerStat().
- Per-Character Progression: Each character has independent upgrade levels, allowing specialization strategies.
Character Unlock System
- CharacterUnlockManager (Singleton): Listens to unlock progression events:
- GameManager.OnWaveCompleted: Track structured wave clears.
- GameManager.OnBossKilled: Track boss defeats.
- EndlessWaveManager reports highest wave reached (UpdateEndlessWaveProgress).
- GameManager.OnEnemyKilled: Track cumulative kills (type-specific).
- Unlock Rules: Each CharacterUnlockRuleSO defines:
- Trigger: wave clear, boss kill, endless wave X, kill Y of type Z, total kills N.
- Target: specific character or rarity (unlock all rare characters).
- Reward: character becomes available for purchase/selection.
- Purchasing Unlocks: Unlocked characters can be purchased via gems in CHARACTER SELECTION menu (CharacterUnlockManager.PurchaseUnlock(character)).
- Save/Load: Unlock progress saved to ES3SaveManager.KEY_CHARACTER_UNLOCKS and loaded on app start.
Achievement Integration
- Events Fired: Unlocks, crits, lifesteal, damage dealt, and boss kills all fire events that AchievementManager listens to.
- Achievement Triggers: E.g., “Deal 1M damage in one run”, “Kill 100 enemies with fire type”, “Win 10 runs”, etc.
- Notification: AchievementNotificationUI displays on-screen when achievements unlock (with sound/haptics).
Data Sources & Configuration
- CharterDataSO: base stats, visuals, audio, unlock rules, upgrade config.
- WeaponDataSO: base weapon stats, pricing, animation override, SFX.
- ObjectDataSO: passive stat bundles sold in shop or granted from chests.
- StatIconDataSO: icons for stat displays.
- Resources/…: ResourceManager loads all SOs for weapons, objects, and characters.
Data Persistence & Save System
ES3SaveManager (Easy Save 3 Integration)
- Singleton: Wraps Easy Save 3 API for consistency.
- Keys:
- KEY_GEM_CURRENCY: Gem balance (saved immediately on change).
- KEY_META_UPGRADES: Per-character upgrade levels (saved immediately).
- KEY_CHARACTER_UNLOCKS: Character unlock status (saved on unlock).
- Save Timing: Gem and upgrade changes call SaveImmediate() to ensure data isn’t lost. This is critical data that must persist.
- Load Timing: ES3SaveManager.OnDataLoaded event fires after file load; managers hook into this to restore state.
- Migration: MetaUpgradeManager handles backwards compatibility with old save keys.
Run-Time vs Persistent
- Ephemeral (Not Saved): Gold (run currency), current stats (recalculated each run), weapons/objects in current inventory, player level/XP.
- Persistent (Saved): Gems, meta upgrade levels, character unlocks, achievement progress.
- Cleared on New Run: Gold resets to 0 via CurrencyManager.ResetRunCurrency(). All run-time stats reset when character is selected.
Data Flow on App Lifecycle
- App Start: ES3SaveManager.Start() loads ES3 file → OnDataLoaded fires.
- CurrencyManager.Start(): Loads gems from ES3SaveManager.
- MetaUpgradeManager.Start(): Loads upgrade levels from ES3SaveManager.
- CharacterUnlockManager.Start(): Loads unlock progress from ES3SaveManager.
- Character Selection: MetaUpgradeManager.SetCurrentCharacter() applies upgrades to PlayerStatsManager.
- Run Execution: All run-time data (gold, XP, weapons) are generated fresh.
- On GAMEOVER: Gold → gems conversion, then ES3SaveManager.SaveImmediate() for gems and unlocks.
Technical Architecture & Patterns
Singleton & DontDestroyOnLoad Pattern
- Managers Using This Pattern: GameManager, InputManager, CurrencyManager, MetaUpgradeManager, ES3SaveManager, WaveManager, EndlessWaveManager, CharacterUnlockManager, AchievementManager.
- Benefit: Ensures only one instance exists; persists across scene loads.
- Initialization: Check if instance == null in Awake(); if not, Destroy(gameObject). DontDestroyOnLoad set in early managers (CurrencyManager, MetaUpgradeManager).
Event System
- Static Actions: GameManager and managers define public static Action or Action events for key game moments (wave complete, boss killed, enemy killed, state change).
- Subscription: Managers and UI systems subscribe in Awake() or Start(). Unsubscribe in OnDestroy() to avoid memory leaks.
- Broadcasting: Events fire at key moments (enemy death, boss defeat, wave complete). Multiple listeners can react (DropManager, XP, unlock, achievement).
IGameStateListner Interface
- Purpose: Allows any GameObject with this interface to react to state changes.
- Broadcast: GameManager.SetGameState() finds all IGameStateListner in scene and calls GameStateChangedCallback(newState).
- Example Listeners: WaveManager, ShopManager, WaveTransitionManager, UIManager.
Object Pooling
- Used For: Bullets (player and enemy), cash/gem drops, UI elements.
- Implementation: ObjectPool from Unity. CreateBullet(), OnTakeFromPool(), OnReturnToPool(), OnDestroyBullet() callbacks.
- Benefit: Reduces allocation overhead and GC pressure during intense waves.
Scriptable Objects for Configuration
- Use Cases: Character data (CharterDataSO), weapons (WeaponDataSO), passive items (ObjectDataSO), upgrade configs (CharacterUpgradeConfigSO), stat icons (StatIconDataSO).
- Benefit: Data can be edited in editor without code changes. Easily add new characters, weapons, or items by creating new SOs.
VR Integration
- Input: InputManager detects XRSettings.isDeviceActive and switches between XR and non-VR input handling.
- Haptics: HapticFeedbackManager provides haptic feedback on damage taken, critical hits, and UI interactions.
- Platforms: Game supports Meta/Oculus, Steam VR, and generic XR devices via XRI (XR Interaction Toolkit).