# Decay Grid Architecture Refactor Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Fix 6 concrete architectural problems in order of dependency — effect stacking, interface coupling, parallel collections, hardcoded floors, split power-up logic, and a god object — each phase leaving the game in a playable, compiling state. **Architecture:** Ports & Adapters is already the structure; we're deepening it. Domain stays pure C#, Infrastructure stays Unity-aware. No new patterns introduced — just existing boundaries enforced properly. **Tech Stack:** C# 10, Unity 6, Unity Test Runner (EditMode for domain logic), LeanTween, New Input System --- ## Phase 1 — Test Infrastructure **Files:** - Create: `Assets/Tests/EditMode/DecayGrid.Tests.EditMode.asmdef` - Create: `Assets/Tests/EditMode/StatusManagerTests.cs` - [ ] **Step 1: Create test directory** ```bash mkdir -p "/mnt/drive/dev/Decay Grid/Assets/Tests/EditMode" ``` - [ ] **Step 2: Create assembly definition** Create `Assets/Tests/EditMode/DecayGrid.Tests.EditMode.asmdef`: ```json { "name": "DecayGrid.Tests.EditMode", "rootNamespace": "DecayGrid.Tests", "references": [ "UnityEngine.TestRunner", "UnityEditor.TestRunner" ], "includePlatforms": [ "Editor" ], "excludePlatforms": [], "allowUnsafeCode": false, "overrideReferences": true, "precompiledReferences": [ "nunit.framework.dll" ], "autoReferenced": false, "defineConstraints": [], "versionDefines": [], "noEngineReferences": false } ``` - [ ] **Step 3: Write a smoke test** Create `Assets/Tests/EditMode/StatusManagerTests.cs`: ```csharp using NUnit.Framework; using Core.Domain.Status; namespace DecayGrid.Tests { public class StatusManagerTests { [Test] public void DefaultCapabilities_HaveExpectedValues() { var sm = new StatusManager(); var caps = sm.CurrentCapabilities; Assert.IsTrue(caps.CanTriggerDecay); Assert.AreEqual(1f, caps.SpeedMultiplier, 0.001f); Assert.IsFalse(caps.CanHover); } } } ``` - [ ] **Step 4: Run tests in Unity Editor** Open Unity → Window → General → Test Runner → EditMode → Run All Expected: 1 test passes. - [ ] **Step 5: Commit** ```bash cd "/mnt/drive/dev/Decay Grid" git add Assets/Tests/ git commit -m "test: add EditMode test assembly" ``` --- ## Phase 2 — Fix Status Effect Stacking (#6) **Problem:** `LightFootedEffect.ModifyCapabilities` sets `caps.SpeedMultiplier = 1.2f` and `SpeedBoostEffect.ModifyCapabilities` sets `caps.SpeedMultiplier = _multiplier`. The second applied overwrites the first — last effect wins instead of stacking. **Files:** - Modify: `Assets/Scripts/Core/Domain/Status/Effects/LightFootedEffect.cs:21` - Modify: `Assets/Scripts/Core/Domain/Status/Effects/SpeedBoostEffect.cs:15` - Modify: `Assets/Tests/EditMode/StatusManagerTests.cs` - [ ] **Step 1: Write failing stacking tests** Add to `Assets/Tests/EditMode/StatusManagerTests.cs`: ```csharp using Core.Domain.Status.Effects; // (add inside StatusManagerTests class) [Test] public void SpeedBoostAlone_AppliesMultiplier() { var sm = new StatusManager(); sm.AddEffect(new SpeedBoostEffect(10f, 1.5f)); Assert.AreEqual(1.5f, sm.CurrentCapabilities.SpeedMultiplier, 0.001f); } [Test] public void LightFootedAlone_AppliesMultiplier() { var sm = new StatusManager(); sm.AddEffect(new LightFootedEffect(10f)); Assert.AreEqual(1.2f, sm.CurrentCapabilities.SpeedMultiplier, 0.001f); } [Test] public void TwoSpeedEffectsStack_Multiplicatively() { var sm = new StatusManager(); sm.AddEffect(new LightFootedEffect(10f)); // *1.2 sm.AddEffect(new SpeedBoostEffect(10f, 1.5f)); // *1.5 Assert.AreEqual(1.8f, sm.CurrentCapabilities.SpeedMultiplier, 0.001f); // 1.0 * 1.2 * 1.5 } ``` - [ ] **Step 2: Run tests — confirm TwoSpeedEffectsStack fails** Expected: `TwoSpeedEffectsStack_Multiplicatively` FAILS (actual: 1.5f, expected: 1.8f). - [ ] **Step 3: Fix LightFootedEffect** In `Assets/Scripts/Core/Domain/Status/Effects/LightFootedEffect.cs`, line 21: ```csharp // Before: caps.SpeedMultiplier = 1.2f; // After: caps.SpeedMultiplier *= 1.2f; ``` - [ ] **Step 4: Fix SpeedBoostEffect** In `Assets/Scripts/Core/Domain/Status/Effects/SpeedBoostEffect.cs`, line 15: ```csharp // Before: caps.SpeedMultiplier = _multiplier; // After: caps.SpeedMultiplier *= _multiplier; ``` - [ ] **Step 5: Run tests — all pass** Expected: 4 tests pass. - [ ] **Step 6: Commit** ```bash git add Assets/Scripts/Core/Domain/Status/Effects/LightFootedEffect.cs \ Assets/Scripts/Core/Domain/Status/Effects/SpeedBoostEffect.cs \ Assets/Tests/EditMode/StatusManagerTests.cs git commit -m "fix: speed effects stack multiplicatively" ``` --- ## Phase 3 — Fix Backwards Coupling: Controllers Must Not Know TileViewAdapter (#3) **Problem:** `PlayerController.InteractWithGround()`, `NpcController.FixedUpdate()`, and `HunterNpcController.FixedUpdate()` all do `TryGetComponent()` and call `tile.OnPlayerStep()`. The UI adapter should not be in the call chain from physics controllers. The `Core.Ports.ITileView` exists exactly for this — it just needs `StepOn()` added to it. **Files:** - Modify: `Assets/Scripts/Core/Ports/ITileView.cs` - Modify: `Assets/Scripts/Infrastructure/Unity/TileViewAdapter.cs:109` - Modify: `Assets/Scripts/Infrastructure/Unity/PlayerController.cs:243-252` - Modify: `Assets/Scripts/Infrastructure/Unity/NpcController.cs:50-56` - Modify: `Assets/Scripts/Infrastructure/Unity/HunterNpcController.cs:38-44` - [ ] **Step 1: Add StepOn() to ITileView** Full replacement of `Assets/Scripts/Core/Ports/ITileView.cs`: ```csharp using Core.Domain; namespace Core.Ports { public interface ITileView { string TileId { get; } void Initialize(Tile tile); void SetVisualState(TileState state); void DropPhysics(); void Dispose(); void StepOn(); } } ``` - [ ] **Step 2: Confirm compile error** Unity will show: `TileViewAdapter does not implement interface member 'ITileView.StepOn()'` - [ ] **Step 3: Rename OnPlayerStep to StepOn in TileViewAdapter** In `Assets/Scripts/Infrastructure/Unity/TileViewAdapter.cs`, line 109: ```csharp // Before: public void OnPlayerStep() { _linkedTile?.StepOn(); } // After: public void StepOn() { _linkedTile?.StepOn(); } ``` - [ ] **Step 4: Update PlayerController** In `Assets/Scripts/Infrastructure/Unity/PlayerController.cs`: Add `using Core.Ports;` with the other usings at the top. Replace `InteractWithGround()` (lines 243–252): ```csharp private void InteractWithGround() { if (Physics.SphereCast(transform.position, 0.3f, Vector3.down, out var hit, groundCheckDistance, tileLayer)) { if (hit.collider.TryGetComponent(out var tileView)) { tileView.StepOn(); } } } ``` - [ ] **Step 5: Update NpcController** In `Assets/Scripts/Infrastructure/Unity/NpcController.cs`: Add `using Core.Ports;` at the top. In `FixedUpdate()`, replace the raycast block (lines 50–56): ```csharp if (Physics.Raycast(transform.position, Vector3.down, out var hit, 2.0f, tileLayer)) { if (hit.collider.TryGetComponent(out var tileView)) { tileView.StepOn(); } } ``` - [ ] **Step 6: Update HunterNpcController** In `Assets/Scripts/Infrastructure/Unity/HunterNpcController.cs`: Add `using Core.Ports;` at the top. In `FixedUpdate()`, replace the raycast block (lines 38–44): ```csharp if (Physics.Raycast(transform.position, Vector3.down, out var hit, 2f, tileLayer)) { if (hit.collider.TryGetComponent(out var tileView)) { tileView.StepOn(); } } ``` - [ ] **Step 7: Play test — tiles decay when walked on** Play in Unity Editor. Walk the player over tiles and verify they go Warning → Falling → Destroyed. NPCs should also cause decay. - [ ] **Step 8: Commit** ```bash git add Assets/Scripts/Core/Ports/ITileView.cs \ Assets/Scripts/Infrastructure/Unity/TileViewAdapter.cs \ Assets/Scripts/Infrastructure/Unity/PlayerController.cs \ Assets/Scripts/Infrastructure/Unity/NpcController.cs \ Assets/Scripts/Infrastructure/Unity/HunterNpcController.cs git commit -m "refactor: controllers use ITileView port, not TileViewAdapter directly" ``` --- ## Phase 4 — Introduce TileRegistry: Replace Parallel Collections (#5) **Problem:** `GameBootstrap` maintains `_allTiles: List` and `_tileViews: Dictionary` in lockstep. Both are passed into `LevelGenerator.GenerateAsync()` as separate parameters. `FloorVisibilityManager` also receives both. Any lookup requires consulting both manually. **Files:** - Create: `Assets/Scripts/Infrastructure/Unity/TileRegistry.cs` - Create: `Assets/Tests/EditMode/TileRegistryTests.cs` - Modify: `Assets/Scripts/Core/Domain/GameSession.cs` - Modify: `Assets/Scripts/Infrastructure/Unity/LevelGenerator.cs` - Modify: `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs` - Modify: `Assets/Scripts/Infrastructure/Unity/FloorVisibilityManager.cs` - [ ] **Step 1: Write failing TileRegistry tests** Create `Assets/Tests/EditMode/TileRegistryTests.cs`: ```csharp using NUnit.Framework; using Core.Domain; using Infrastructure.Unity; namespace DecayGrid.Tests { public class TileRegistryTests { private TileRegistry _registry; [SetUp] public void SetUp() => _registry = new TileRegistry(); [Test] public void Register_AddsToAllTiles() { var tile = new Tile("0_0_0", 0, 0.5f, 2f); _registry.Register(tile); Assert.AreEqual(1, _registry.AllTiles.Count); } [Test] public void FindTiles_ReturnsMatchingTiles() { var tile = new Tile("0_0_0", 0, 0.5f, 2f); _registry.Register(tile); var result = _registry.FindTiles(t => t.CurrentState == TileState.Stable); Assert.AreEqual(1, result.Count); } [Test] public void FindTiles_ExcludesNonMatchingTiles() { var tile = new Tile("0_0_0", 0, 0.5f, 2f); tile.StepOn(); // Now Warning _registry.Register(tile); var result = _registry.FindTiles(t => t.CurrentState == TileState.Stable); Assert.AreEqual(0, result.Count); } [Test] public void TryGetView_ReturnsFalse_WhenNoViewRegistered() { var tile = new Tile("0_0_0", 0, 0.5f, 2f); _registry.Register(tile); // no view Assert.IsFalse(_registry.TryGetView("0_0_0", out _)); } } } ``` - [ ] **Step 2: Run tests — fail with compile error (TileRegistry missing)** - [ ] **Step 3: Create TileRegistry** Create `Assets/Scripts/Infrastructure/Unity/TileRegistry.cs`: ```csharp using System; using System.Collections.Generic; using Core.Domain; namespace Infrastructure.Unity { public class TileRegistry { private readonly List _tiles = new(); private readonly Dictionary _views = new(); public IReadOnlyList AllTiles => _tiles; public void Register(Tile tile, TileViewAdapter view = null) { _tiles.Add(tile); if (view != null) _views[tile.Id] = view; } public bool TryGetView(string tileId, out TileViewAdapter view) => _views.TryGetValue(tileId, out view); public List FindTiles(Predicate predicate) => _tiles.FindAll(predicate); public List> GroupViewsByFloor(int floorCount) { var floors = new List>(floorCount); for (var i = 0; i < floorCount; i++) floors.Add(new List()); foreach (var tile in _tiles) { if (tile.Floor < floorCount && _views.TryGetValue(tile.Id, out var view)) floors[tile.Floor].Add(view); } return floors; } } } ``` - [ ] **Step 4: Run tests — all 4 pass** - [ ] **Step 5: Update GameSession to accept IReadOnlyList\** In `Assets/Scripts/Core/Domain/GameSession.cs`: Add `using System.Linq;` at the top. Change the field and constructor: ```csharp // Before: private readonly List _tiles; public GameSession(List tiles, IPersistenceService persistenceService) { _tiles = tiles; // After: private readonly IReadOnlyList _tiles; public GameSession(IReadOnlyList tiles, IPersistenceService persistenceService) { _tiles = tiles; ``` Replace both `FindAll` calls with LINQ. In `SpawnNextOrb()`: ```csharp // Before: var validTiles = _tiles.FindAll(t => t.CurrentState == TileState.Stable && t.Floor == _playerFloorIndex ); if (validTiles.Count == 0) validTiles = _tiles.FindAll(t => t.CurrentState == TileState.Stable); // After: var validTiles = _tiles.Where(t => t.CurrentState == TileState.Stable && t.Floor == _playerFloorIndex).ToList(); if (validTiles.Count == 0) validTiles = _tiles.Where(t => t.CurrentState == TileState.Stable).ToList(); ``` In `SpawnRandomPowerUp()`: ```csharp // Before: var validTiles = _tiles.FindAll(t => t.CurrentState == TileState.Stable && t.Floor == _playerFloorIndex ); if (validTiles.Count == 0) validTiles = _tiles.FindAll(t => t.CurrentState == TileState.Stable); // After: var validTiles = _tiles.Where(t => t.CurrentState == TileState.Stable && t.Floor == _playerFloorIndex).ToList(); if (validTiles.Count == 0) validTiles = _tiles.Where(t => t.CurrentState == TileState.Stable).ToList(); ``` - [ ] **Step 6: Update LevelGenerator.GenerateAsync signature** In `Assets/Scripts/Infrastructure/Unity/LevelGenerator.cs`: Change `GenerateAsync` to accept `TileRegistry` instead of the two separate collections: ```csharp // Before: public IEnumerator GenerateAsync(SoundManager soundManager, List allTiles, Dictionary tileViews, CameraController camera, RumbleManager rumble, Action onComplete) { _tilePool = new TilePool(tilePrefab, transform); var stopwatch = new Stopwatch(); stopwatch.Start(); yield return GenerateFloorAsync(0, MapPatterns.GenerateSquare(gridSizeX, gridSizeY), soundManager, allTiles, tileViews, camera, rumble, stopwatch); yield return GenerateFloorAsync(1, MapPatterns.GenerateDonut(gridSizeX, Mathf.FloorToInt(gridSizeX / 3f)), soundManager, allTiles, tileViews, camera, rumble, stopwatch); yield return GenerateFloorAsync(2, MapPatterns.GenerateCircle(gridSizeX), soundManager, allTiles, tileViews, camera, rumble, stopwatch); stopwatch?.Stop(); onComplete?.Invoke(); } // After: public IEnumerator GenerateAsync(SoundManager soundManager, TileRegistry registry, CameraController camera, RumbleManager rumble, Action onComplete) { _tilePool = new TilePool(tilePrefab, transform); var stopwatch = new Stopwatch(); stopwatch.Start(); yield return GenerateFloorAsync(0, MapPatterns.GenerateSquare(gridSizeX, gridSizeY), soundManager, registry, camera, rumble, stopwatch); yield return GenerateFloorAsync(1, MapPatterns.GenerateDonut(gridSizeX, Mathf.FloorToInt(gridSizeX / 3f)), soundManager, registry, camera, rumble, stopwatch); yield return GenerateFloorAsync(2, MapPatterns.GenerateCircle(gridSizeX), soundManager, registry, camera, rumble, stopwatch); stopwatch.Stop(); onComplete?.Invoke(); } ``` Update `GenerateFloorAsync` signature (propagate `registry` down): ```csharp // Before: private IEnumerator GenerateFloorAsync(int floorIndex, List coordinates, SoundManager soundManager, List allTiles, Dictionary tileViews, CameraController camera, RumbleManager rumble, Stopwatch stopwatch) // After: private IEnumerator GenerateFloorAsync(int floorIndex, List coordinates, SoundManager soundManager, TileRegistry registry, CameraController camera, RumbleManager rumble, Stopwatch stopwatch) ``` Update the `CreateTile` call inside `GenerateFloorAsync`: ```csharp // Before: CreateTile(pos, $"{floorIndex}_{coord.x}_{coord.y}", floorIndex, soundManager, allTiles, tileViews, camera, rumble); // After: CreateTile(pos, $"{floorIndex}_{coord.x}_{coord.y}", floorIndex, soundManager, registry, camera, rumble); ``` Update `CreateTile` signature and its final two lines: ```csharp // Before: private void CreateTile(Vector3 position, string id, int floorIndex, SoundManager soundManager, List allTiles, Dictionary tileViews, CameraController camera, RumbleManager rumble) { // ... allTiles.Add(tileLogic); tileViews.Add(id, go); } // After: private void CreateTile(Vector3 position, string id, int floorIndex, SoundManager soundManager, TileRegistry registry, CameraController camera, RumbleManager rumble) { // ... registry.Register(tileLogic, go); } ``` - [ ] **Step 7: Update GameBootstrap to use TileRegistry** In `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs`: Remove: ```csharp private readonly List _allTiles = new(); private readonly Dictionary _tileViews = new(); ``` Add: ```csharp private readonly TileRegistry _tileRegistry = new(); ``` In `Start()`, update the GameSession constructor call and GenerateAsync call: ```csharp // Before: _gameSession = new GameSession(_allTiles, _persistenceService); // ... StartCoroutine(levelGenerator.GenerateAsync(soundManager, _allTiles, _tileViews, cameraController, rumbleManager, () => { floorVisibilityManager.Initialize(_gameSession, _allTiles, _tileViews, floorsCount); // ... })); // After: _gameSession = new GameSession(_tileRegistry.AllTiles, _persistenceService); // ... StartCoroutine(levelGenerator.GenerateAsync(soundManager, _tileRegistry, cameraController, rumbleManager, () => { floorVisibilityManager.Initialize(_tileRegistry, floorsCount); // ... })); ``` Update `SpawnVisualOrb(string tileId)`: ```csharp // Before: if (!_tileViews.TryGetValue(tileId, out var tileView)) return; // After: if (!_tileRegistry.TryGetView(tileId, out var tileView)) return; ``` Update `SpawnNpc()`: ```csharp // Before: var validTiles = _allTiles.FindAll(t => t.Floor == 0 && t.CurrentState == TileState.Stable); if (validTiles.Count == 0) validTiles = _allTiles.FindAll(t => t.CurrentState == TileState.Stable); // ... if (!_tileViews.TryGetValue(randomTile.Id, out var tileView)) return; // After: var validTiles = _tileRegistry.FindTiles(t => t.Floor == 0 && t.CurrentState == TileState.Stable); if (validTiles.Count == 0) validTiles = _tileRegistry.FindTiles(t => t.CurrentState == TileState.Stable); // ... if (!_tileRegistry.TryGetView(randomTile.Id, out var tileView)) return; ``` Update `SpawnPowerUp(PowerUpType type, string tileId)`: ```csharp // Before: if (!_tileViews.TryGetValue(tileId, out var tileView)) return; // After: if (!_tileRegistry.TryGetView(tileId, out var tileView)) return; ``` Update `OnBeatMeasure()`: ```csharp // Before: var randIndex = Random.Range(0, _allTiles.Count); var tile = _allTiles[randIndex]; // ... if (_tileViews.TryGetValue(tile.Id, out var tileView)) // After: var allTiles = _tileRegistry.AllTiles; if (allTiles.Count == 0) return; var randIndex = Random.Range(0, allTiles.Count); var tile = allTiles[randIndex]; // ... if (_tileRegistry.TryGetView(tile.Id, out var tileView)) ``` Remove `using System.Collections.Generic;` from GameBootstrap if it is no longer used (check remaining usages first — `IEnumerator` is in `System.Collections`). - [ ] **Step 8: Update FloorVisibilityManager.Initialize** In `Assets/Scripts/Infrastructure/Unity/FloorVisibilityManager.cs`: Replace `Initialize`: ```csharp // Before: public void Initialize(GameSession gameSession, List allTiles, Dictionary tileViews, int totalFloors) { _gameSession = gameSession; _floors = new List>(); for (var i = 0; i < totalFloors; i++) _floors.Add(new List()); foreach (var tile in allTiles) { if (tileViews.TryGetValue(tile.Id, out var view)) if (tile.Floor < _floors.Count) _floors[tile.Floor].Add(view); } } // After: public void Initialize(TileRegistry registry, int totalFloors) { _floors = registry.GroupViewsByFloor(totalFloors); } ``` Remove the `_gameSession` field from `FloorVisibilityManager` (it's only referenced in the now-empty `Update()` method body). Remove the `Update()` method entirely if it has no logic. Remove `using Core.Domain;` from FloorVisibilityManager if it's no longer used. - [ ] **Step 9: Compile and play test** Unity should compile clean. Play a full session — level generates, tiles decay, NPCs spawn, orbs appear, beat pulses fire. - [ ] **Step 10: Commit** ```bash git add Assets/Scripts/Infrastructure/Unity/TileRegistry.cs \ Assets/Scripts/Core/Domain/GameSession.cs \ Assets/Scripts/Infrastructure/Unity/LevelGenerator.cs \ Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs \ Assets/Scripts/Infrastructure/Unity/FloorVisibilityManager.cs \ Assets/Tests/EditMode/TileRegistryTests.cs git commit -m "refactor: TileRegistry replaces parallel tile/view collections" ``` --- ## Phase 5 — Introduce LevelDefinition: End Hardcoded 3-Floor Structure (#2) **Problem:** `LevelGenerator.GenerateAsync()` hardcodes three `yield return GenerateFloorAsync(...)` calls. Floor count, height distance, and grid size are spread across `LevelGenerator`, `GameBootstrap.Update()`, `GameBootstrap.SpawnDeathPlane()`, and `FloorVisibilityManager`. Adding a 4th floor requires touching all of them. **Files:** - Create: `Assets/Scripts/Infrastructure/Unity/LevelDefinition.cs` - Modify: `Assets/Scripts/Infrastructure/Unity/LevelGenerator.cs` - Modify: `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs` - [ ] **Step 1: Create LevelDefinition ScriptableObject** Create `Assets/Scripts/Infrastructure/Unity/LevelDefinition.cs`: ```csharp using System; using System.Collections.Generic; using UnityEngine; namespace Infrastructure.Unity { [CreateAssetMenu(fileName = "LevelDefinition", menuName = "Decay Grid/Level Definition")] public class LevelDefinition : ScriptableObject { [SerializeField] private int gridSizeX = 10; [SerializeField] private int gridSizeY = 10; [SerializeField] private float floorHeightDistance = 15f; [SerializeField] private FloorConfig[] floors = { new() { pattern = FloorPatternType.Square }, new() { pattern = FloorPatternType.Donut }, new() { pattern = FloorPatternType.Circle } }; public int GridSizeX => gridSizeX; public int GridSizeY => gridSizeY; public float FloorHeightDistance => floorHeightDistance; public int FloorCount => floors.Length; public IReadOnlyList Floors => floors; } [Serializable] public class FloorConfig { public FloorPatternType pattern; } public enum FloorPatternType { Square, Donut, Circle } } ``` - [ ] **Step 2: Update LevelGenerator to read from LevelDefinition** In `Assets/Scripts/Infrastructure/Unity/LevelGenerator.cs`: Remove these serialized fields and their public properties: ```csharp // Remove: [SerializeField] private int gridSizeX = 10; [SerializeField] private int gridSizeY = 10; [SerializeField] private int floorsCount = 3; [SerializeField] private float floorHeightDistance = 15f; // Remove: public float FloorHeightDistance => floorHeightDistance; public int FloorsCount => floorsCount; public int GridSizeX => gridSizeX; public int GridSizeY => gridSizeY; ``` Add: ```csharp [Header("Level")] [SerializeField] private LevelDefinition levelDefinition; public LevelDefinition Definition => levelDefinition; ``` Replace the hardcoded three `yield return` calls in `GenerateAsync` with a loop: ```csharp // Before: yield return GenerateFloorAsync(0, MapPatterns.GenerateSquare(gridSizeX, gridSizeY), ...); yield return GenerateFloorAsync(1, MapPatterns.GenerateDonut(gridSizeX, Mathf.FloorToInt(gridSizeX / 3f)), ...); yield return GenerateFloorAsync(2, MapPatterns.GenerateCircle(gridSizeX), ...); // After: for (var i = 0; i < levelDefinition.FloorCount; i++) { var coords = GetCoordsForFloor(levelDefinition.Floors[i].pattern); yield return GenerateFloorAsync(i, coords, soundManager, registry, camera, rumble, stopwatch); } ``` Add the helper method after `GenerateAsync`: ```csharp private List GetCoordsForFloor(FloorPatternType pattern) { var sizeX = levelDefinition.GridSizeX; var sizeY = levelDefinition.GridSizeY; return pattern switch { FloorPatternType.Square => MapPatterns.GenerateSquare(sizeX, sizeY), FloorPatternType.Donut => MapPatterns.GenerateDonut(sizeX, Mathf.FloorToInt(sizeX / 3f)), FloorPatternType.Circle => MapPatterns.GenerateCircle(sizeX), _ => MapPatterns.GenerateSquare(sizeX, sizeY) }; } ``` In `GenerateFloorAsync`, replace the three removed fields with `levelDefinition.*`: ```csharp // Before: var yOffset = -(floorIndex * floorHeightDistance); var xOffset = gridSizeX / 2f; var zOffset = gridSizeY / 2f; // After: var yOffset = -(floorIndex * levelDefinition.FloorHeightDistance); var xOffset = levelDefinition.GridSizeX / 2f; var zOffset = levelDefinition.GridSizeY / 2f; ``` Do the same for jump pad and teleporter position calculations within `GenerateFloorAsync` — replace `xOffset` and `zOffset` uses that referenced the removed fields (they now use the local variables defined above, so they're already covered if calculated at the top of the method). - [ ] **Step 3: Update GameBootstrap to use levelGenerator.Definition** In `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs`: In `Start()`: ```csharp // Before: var floorsCount = levelGenerator ? levelGenerator.FloorsCount : 1; // After: var floorsCount = levelGenerator ? levelGenerator.Definition.FloorCount : 1; ``` In `Update()`: ```csharp // Before: var heightDist = levelGenerator.FloorHeightDistance; var maxFloors = levelGenerator.FloorsCount; // After: var heightDist = levelGenerator.Definition.FloorHeightDistance; var maxFloors = levelGenerator.Definition.FloorCount; ``` In `SpawnDeathPlane()`: ```csharp // Before: var lowestY = -(levelGenerator.FloorsCount * levelGenerator.FloorHeightDistance) - 5f; var pos = new Vector3(levelGenerator.GridSizeX / 2f, lowestY, levelGenerator.GridSizeY / 2f); plane.transform.localScale = new Vector3(levelGenerator.GridSizeX * 200f, 1f, levelGenerator.GridSizeY * 200f); // After: var def = levelGenerator.Definition; var lowestY = -(def.FloorCount * def.FloorHeightDistance) - 5f; var pos = new Vector3(def.GridSizeX / 2f, lowestY, def.GridSizeY / 2f); plane.transform.localScale = new Vector3(def.GridSizeX * 200f, 1f, def.GridSizeY * 200f); ``` - [ ] **Step 4: Create the LevelDefinition asset in the Unity Editor** In Unity Editor: Assets → Create → Decay Grid → Level Definition Save as `Assets/Settings/DefaultLevelDefinition.asset` In the Inspector, verify it has 3 floors (Square, Donut, Circle) matching the original hardcoded values. Assign this asset to the `LevelGenerator` component's `levelDefinition` field in the scene. - [ ] **Step 5: Play test — identical to before** The level should generate identically. Test: in the Inspector, add a 4th floor to `DefaultLevelDefinition` with `FloorPatternType.Square` — verify a 4th floor appears without changing any code. Remove the 4th floor after verifying, to restore the 3-floor default. - [ ] **Step 6: Commit** ```bash git add Assets/Scripts/Infrastructure/Unity/LevelDefinition.cs \ Assets/Scripts/Infrastructure/Unity/LevelGenerator.cs \ Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs git commit -m "refactor: LevelDefinition ScriptableObject drives floor count and geometry" ``` --- ## Phase 6 — Unify Power-Up Collection (#4) **Problem:** `PowerUpViewAdapter.ApplyEffect(PlayerController)` applies `LightFooted` and `SpeedBoost` directly to the player. `TimeSlow` is silently skipped there (`// Handled globally`) and applied via a callback in `GameBootstrap.SpawnPowerUp`. Effect logic is split between a UI adapter and the bootstrap. All effect application should live in `GameBootstrap` where both `_playerInstance` and `_gameSession` are available. **Files:** - Modify: `Assets/Scripts/Infrastructure/Unity/PowerUpViewAdapter.cs` - Modify: `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs` - [ ] **Step 1: Change OnCollected to include duration, remove ApplyEffect** In `Assets/Scripts/Infrastructure/Unity/PowerUpViewAdapter.cs`: Change the event signature: ```csharp // Before: public event Action OnCollected; // After: public event Action OnCollected; ``` Replace `OnTriggerEnter`: ```csharp // Before: private void OnTriggerEnter(Collider other) { if (other.TryGetComponent(out var player)) { ApplyEffect(player); OnCollected?.Invoke(type); // ... vfx ... Destroy(gameObject); } } // After: private void OnTriggerEnter(Collider other) { if (other.TryGetComponent(out _)) { OnCollected?.Invoke(type, duration); if (pickupVfx) { var vfx = Instantiate(pickupVfx, transform.position, Quaternion.identity); var main = vfx.main; meshRenderer.GetPropertyBlock(_propBlock); main.startColor = _propBlock.GetColor(ColorProperty); Destroy(vfx.gameObject, 2f); } Destroy(gameObject); } } ``` Delete the `ApplyEffect(PlayerController player)` method entirely. Remove `using Core.Domain.Status.Effects;` from PowerUpViewAdapter (no longer needed). - [ ] **Step 2: Update GameBootstrap.SpawnPowerUp to apply all effects** In `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs`: Add at the top: `using Core.Domain.Status.Effects;` Replace the `OnCollected` handler in `SpawnPowerUp`: ```csharp // Before: instance.OnCollected += (t) => { cameraController?.Shake(0.2f, 0.15f); rumbleManager?.PulseMedium(); if (t == PowerUpType.TimeSlow) { _gameSession.ActivateTimeSlow(10f); } }; // After: instance.OnCollected += (t, dur) => { cameraController?.Shake(0.2f, 0.15f); rumbleManager?.PulseMedium(); switch (t) { case PowerUpType.TimeSlow: _gameSession.ActivateTimeSlow(dur); break; case PowerUpType.LightFooted: _playerInstance?.Status.AddEffect(new LightFootedEffect(dur)); break; case PowerUpType.SpeedBoost: _playerInstance?.Status.AddEffect(new SpeedBoostEffect(dur)); break; } }; ``` - [ ] **Step 3: Play test all three power-up types** Collect each power-up type in the editor: - LightFooted: player color shifts, tiles should NOT decay under the player - SpeedBoost: player moves noticeably faster - TimeSlow: NPCs and decay visibly slow - [ ] **Step 4: Commit** ```bash git add Assets/Scripts/Infrastructure/Unity/PowerUpViewAdapter.cs \ Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs git commit -m "refactor: power-up effects applied centrally in GameBootstrap" ``` --- ## Phase 7 — Split GameBootstrap: Extract GameUiCoordinator (#1) **Problem:** `GameBootstrap` owns UI references (scoreText, highScoreText, gameOverUi, pauseUi, startScreenUi), LeanTween score animation, and show/hide logic for all menus. It's the last large concentration of unrelated responsibilities. Extracting UI into `GameUiCoordinator` removes ~70 lines and all TMP/UI concerns from GameBootstrap. **Files:** - Create: `Assets/Scripts/Infrastructure/Unity/GameUiCoordinator.cs` - Modify: `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs` - [ ] **Step 1: Create GameUiCoordinator** Create `Assets/Scripts/Infrastructure/Unity/GameUiCoordinator.cs`: ```csharp using Core.Domain; using TMPro; using UnityEngine; namespace Infrastructure.Unity { public class GameUiCoordinator : MonoBehaviour { [Header("References")] [SerializeField] private TMP_Text scoreText; [SerializeField] private TMP_Text highScoreText; [SerializeField] private GameObject gameOverUi; [SerializeField] private GameObject pauseUi; [SerializeField] private GameObject startScreenUi; private GameSession _session; private int _currentDisplayedScore; public void Subscribe(GameSession session) { _session = session; session.OnScoreChanged += UpdateScore; session.OnGameOver += ShowGameOverUi; } public void ShowStartScreen() { if (gameOverUi) gameOverUi.SetActive(false); if (startScreenUi) startScreenUi.SetActive(true); } public void HideStartScreen() { if (startScreenUi) startScreenUi.SetActive(false); } public void ShowGameOverUi() { if (gameOverUi) gameOverUi.SetActive(true); } public void ShowPauseUi() { if (pauseUi) pauseUi.SetActive(true); } public void HidePauseUi() { if (pauseUi) pauseUi.SetActive(false); } public void UpdateHighScore(int highScore) { if (highScoreText) highScoreText.text = $"BEST: {highScore}"; } private void UpdateScore(int newScore) { if (!scoreText) return; LeanTween.cancel(scoreText.gameObject); scoreText.rectTransform.localScale = Vector3.one; LeanTween.scale(scoreText.rectTransform, Vector3.one * 1.5f, 0.5f).setEasePunch(); LeanTween.value(scoreText.gameObject, (float val) => { var combo = _session?.ComboMultiplier ?? 1; var comboText = combo > 1 ? $" x{combo}" : ""; scoreText.text = $"{Mathf.RoundToInt(val)}{comboText}"; }, _currentDisplayedScore, newScore, 0.5f) .setEaseOutExpo(); _currentDisplayedScore = newScore; if (highScoreText && _session != null) highScoreText.text = $"BEST: {_session.HighScore}"; } } } ``` - [ ] **Step 2: Remove UI fields from GameBootstrap and add coordinator reference** In `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs`: Remove the entire `[Header("Ui")]` block: ```csharp // Remove: [Header("Ui")] [SerializeField] private TMP_Text scoreText; [SerializeField] private TMP_Text highScoreText; [SerializeField] private GameObject gameOverUi; [SerializeField] private GameObject pauseUi; [SerializeField] private GameObject startScreenUi; ``` Remove the `_currentDisplayedScore` field: ```csharp // Remove: private int _currentDisplayedScore; ``` Add the coordinator serialized field (in the `[Header("Infrastructure")]` section): ```csharp [SerializeField] private GameUiCoordinator uiCoordinator; ``` - [ ] **Step 3: Update all GameBootstrap methods that referenced UI fields** In `Start()` — replace UI setup: ```csharp // Before: if (gameOverUi) gameOverUi.SetActive(false); if (startScreenUi) startScreenUi.SetActive(true); // before level gen callback // ... if (gameOverUi) gameOverUi.SetActive(false); // inside level gen callback if (startScreenUi) startScreenUi.SetActive(true); // inside level gen callback // ... UpdateScoreUi(_gameSession.Score); // After (before GenerateAsync call): uiCoordinator?.ShowStartScreen(); // After (inside the level gen onComplete callback): uiCoordinator?.ShowStartScreen(); uiCoordinator?.UpdateHighScore(_gameSession.HighScore); ``` In `WireEvents()`: ```csharp // Before: _gameSession.OnScoreChanged += UpdateScoreUi; _gameSession.OnGameOver += HandleGameOver; // After: uiCoordinator?.Subscribe(_gameSession); _gameSession.OnGameOver += HandleGameOver; ``` In `HandleGameOver()`: ```csharp // Before: private void HandleGameOver() { _isGameRunning = false; if (beatPulseController) beatPulseController.StopTracking(); if (gameOverUi) gameOverUi.SetActive(true); // remove this line StartCoroutine(RestartRoutine()); } // After: private void HandleGameOver() { _isGameRunning = false; if (beatPulseController) beatPulseController.StopTracking(); StartCoroutine(RestartRoutine()); // GameUiCoordinator.ShowGameOverUi() fires via its own OnGameOver subscription } ``` In `TogglePause()`: ```csharp // Before: if (_isPaused) { Time.timeScale = 0f; if (soundManager) soundManager.SetPaused(true); if (pauseUi) pauseUi.SetActive(true); if (rumbleManager) rumbleManager.SetPaused(true); } else { Time.timeScale = 1f; if (soundManager) soundManager.SetPaused(false); if (pauseUi) pauseUi.SetActive(false); if (rumbleManager) rumbleManager.SetPaused(false); } // After: if (_isPaused) { Time.timeScale = 0f; if (soundManager) soundManager.SetPaused(true); if (rumbleManager) rumbleManager.SetPaused(true); uiCoordinator?.ShowPauseUi(); } else { Time.timeScale = 1f; if (soundManager) soundManager.SetPaused(false); if (rumbleManager) rumbleManager.SetPaused(false); uiCoordinator?.HidePauseUi(); } ``` In `StartGameSequence()`: ```csharp // Before: if (startScreenUi) startScreenUi.SetActive(false); _currentDisplayedScore = 0; // After: uiCoordinator?.HideStartScreen(); ``` Delete `UpdateScoreUi(int newScore)` from GameBootstrap entirely. Remove `using TMPro;` from GameBootstrap if it's no longer used. - [ ] **Step 4: Wire the GameUiCoordinator in the Unity scene** In Unity Editor: 1. Select the GameBootstrap GameObject in the Hierarchy 2. Add Component → `GameUiCoordinator` 3. Wire `scoreText`, `highScoreText`, `gameOverUi`, `pauseUi`, `startScreenUi` on the new component (same references that were on GameBootstrap) 4. Assign the `GameUiCoordinator` component reference to the `uiCoordinator` field on GameBootstrap - [ ] **Step 5: Play a full game session** Verify the entire flow: - Start screen shows on launch - Game begins on input press - Score increments and animates with combo multiplier - High score displays correctly - Pause menu toggles with pause input - Game over screen appears after death - Scene reloads after `restartTime` seconds - [ ] **Step 6: Commit** ```bash git add Assets/Scripts/Infrastructure/Unity/GameUiCoordinator.cs \ Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs git commit -m "refactor: extract GameUiCoordinator from GameBootstrap" ``` --- ## Self-Review **Spec coverage:** - ✅ #6 Effect stacking: Phase 2 — `*=` in `LightFootedEffect` and `SpeedBoostEffect` - ✅ #3 Backwards coupling: Phase 3 — `ITileView.StepOn()`, all three controllers use port - ✅ #5 Parallel collections: Phase 4 — `TileRegistry` owns both, replaces `_allTiles`/`_tileViews` - ✅ #2 Hardcoded floors: Phase 5 — `LevelDefinition` ScriptableObject drives floor structure - ✅ #4 Split power-up: Phase 6 — all effect application in GameBootstrap - ✅ #1 God object: Phase 7 — `GameUiCoordinator` extracts all UI logic **Placeholder scan:** None. All steps have exact code blocks and expected outcomes. **Type consistency:** - `TileRegistry` referenced identically across `LevelGenerator`, `GameBootstrap`, `FloorVisibilityManager`, `TileRegistryTests` - `ITileView.StepOn()` matches `TileViewAdapter.StepOn()` and all three controller call sites - `LevelDefinition.FloorCount` (not `FloorsCount`) used consistently in LevelGenerator and GameBootstrap - `OnCollected(PowerUpType, float)` matches the `(t, dur) =>` lambda in GameBootstrap - `IReadOnlyList` in `GameSession` uses `.Where().ToList()` (not `FindAll`) consistently