# 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<TileViewAdapter>()` 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<ITileView>(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<ITileView>(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<ITileView>(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<Tile>` and `_tileViews: Dictionary<string, TileViewAdapter>` 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<Tile> _tiles = new();
        private readonly Dictionary<string, TileViewAdapter> _views = new();

        public IReadOnlyList<Tile> 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<Tile> FindTiles(Predicate<Tile> predicate)
            => _tiles.FindAll(predicate);

        public List<List<TileViewAdapter>> GroupViewsByFloor(int floorCount)
        {
            var floors = new List<List<TileViewAdapter>>(floorCount);
            for (var i = 0; i < floorCount; i++) floors.Add(new List<TileViewAdapter>());

            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\<Tile\>**

In `Assets/Scripts/Core/Domain/GameSession.cs`:

Add `using System.Linq;` at the top.

Change the field and constructor:

```csharp
// Before:
private readonly List<Tile> _tiles;
public GameSession(List<Tile> tiles, IPersistenceService persistenceService)
{
    _tiles = tiles;

// After:
private readonly IReadOnlyList<Tile> _tiles;
public GameSession(IReadOnlyList<Tile> 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<Tile> allTiles,
    Dictionary<string, TileViewAdapter> 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<Vector2Int> coordinates,
    SoundManager soundManager, List<Tile> allTiles, Dictionary<string, TileViewAdapter> tileViews,
    CameraController camera, RumbleManager rumble, Stopwatch stopwatch)

// After:
private IEnumerator GenerateFloorAsync(int floorIndex, List<Vector2Int> 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<Tile> allTiles, Dictionary<string, TileViewAdapter> 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<Tile> _allTiles = new();
private readonly Dictionary<string, TileViewAdapter> _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<Tile> allTiles,
    Dictionary<string, TileViewAdapter> tileViews, int totalFloors)
{
    _gameSession = gameSession;
    _floors = new List<List<TileViewAdapter>>();
    for (var i = 0; i < totalFloors; i++) _floors.Add(new List<TileViewAdapter>());
    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<FloorConfig> 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<Vector2Int> 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<PowerUpType> OnCollected;

// After:
public event Action<PowerUpType, float> OnCollected;
```

Replace `OnTriggerEnter`:

```csharp
// Before:
private void OnTriggerEnter(Collider other)
{
    if (other.TryGetComponent<PlayerController>(out var player))
    {
        ApplyEffect(player);
        OnCollected?.Invoke(type);
        // ... vfx ...
        Destroy(gameObject);
    }
}

// After:
private void OnTriggerEnter(Collider other)
{
    if (other.TryGetComponent<PlayerController>(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 ? $" <color=yellow>x{combo}</color>" : "";
                    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<Tile>` in `GameSession` uses `.Where().ToList()` (not `FindAll`) consistently
