Files
decay-grid/docs/superpowers/plans/2026-05-14-architecture-refactor.md

40 KiB
Raw Blame History

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

mkdir -p "/mnt/drive/dev/Decay Grid/Assets/Tests/EditMode"
  • Step 2: Create assembly definition

Create Assets/Tests/EditMode/DecayGrid.Tests.EditMode.asmdef:

{
    "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:

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
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:

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:

// 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:

// Before:
caps.SpeedMultiplier = _multiplier;

// After:
caps.SpeedMultiplier *= _multiplier;
  • Step 5: Run tests — all pass

Expected: 4 tests pass.

  • Step 6: Commit
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:

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:

// 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 243252):

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 5056):

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 3844):

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
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:

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:

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:

// 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():

// 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():

// 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:

// 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):

// 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:

// 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:

// 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:

private readonly List<Tile> _allTiles = new();
private readonly Dictionary<string, TileViewAdapter> _tileViews = new();

Add:

private readonly TileRegistry _tileRegistry = new();

In Start(), update the GameSession constructor call and GenerateAsync call:

// 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):

// Before:
if (!_tileViews.TryGetValue(tileId, out var tileView)) return;

// After:
if (!_tileRegistry.TryGetView(tileId, out var tileView)) return;

Update SpawnNpc():

// 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):

// Before:
if (!_tileViews.TryGetValue(tileId, out var tileView)) return;

// After:
if (!_tileRegistry.TryGetView(tileId, out var tileView)) return;

Update OnBeatMeasure():

// 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:

// 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
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:

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:

// 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:

[Header("Level")]
[SerializeField] private LevelDefinition levelDefinition;

public LevelDefinition Definition => levelDefinition;

Replace the hardcoded three yield return calls in GenerateAsync with a loop:

// 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:

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.*:

// 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():

// Before:
var floorsCount = levelGenerator ? levelGenerator.FloorsCount : 1;

// After:
var floorsCount = levelGenerator ? levelGenerator.Definition.FloorCount : 1;

In Update():

// Before:
var heightDist = levelGenerator.FloorHeightDistance;
var maxFloors = levelGenerator.FloorsCount;

// After:
var heightDist = levelGenerator.Definition.FloorHeightDistance;
var maxFloors = levelGenerator.Definition.FloorCount;

In SpawnDeathPlane():

// 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
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:

// Before:
public event Action<PowerUpType> OnCollected;

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

Replace OnTriggerEnter:

// 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:

// 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

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:

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:

// 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:

// Remove:
private int _currentDisplayedScore;

Add the coordinator serialized field (in the [Header("Infrastructure")] section):

[SerializeField] private GameUiCoordinator uiCoordinator;
  • Step 3: Update all GameBootstrap methods that referenced UI fields

In Start() — replace UI setup:

// 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():

// Before:
_gameSession.OnScoreChanged += UpdateScoreUi;
_gameSession.OnGameOver += HandleGameOver;

// After:
uiCoordinator?.Subscribe(_gameSession);
_gameSession.OnGameOver += HandleGameOver;

In HandleGameOver():

// 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():

// 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():

// 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

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