40 KiB
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 243–252):
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):
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):
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:
- Select the GameBootstrap GameObject in the Hierarchy
- Add Component →
GameUiCoordinator - Wire
scoreText,highScoreText,gameOverUi,pauseUi,startScreenUion the new component (same references that were on GameBootstrap) - Assign the
GameUiCoordinatorcomponent reference to theuiCoordinatorfield 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
restartTimeseconds -
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 —
*=inLightFootedEffectandSpeedBoostEffect - ✅ #3 Backwards coupling: Phase 3 —
ITileView.StepOn(), all three controllers use port - ✅ #5 Parallel collections: Phase 4 —
TileRegistryowns both, replaces_allTiles/_tileViews - ✅ #2 Hardcoded floors: Phase 5 —
LevelDefinitionScriptableObject drives floor structure - ✅ #4 Split power-up: Phase 6 — all effect application in GameBootstrap
- ✅ #1 God object: Phase 7 —
GameUiCoordinatorextracts all UI logic
Placeholder scan: None. All steps have exact code blocks and expected outcomes.
Type consistency:
TileRegistryreferenced identically acrossLevelGenerator,GameBootstrap,FloorVisibilityManager,TileRegistryTestsITileView.StepOn()matchesTileViewAdapter.StepOn()and all three controller call sitesLevelDefinition.FloorCount(notFloorsCount) used consistently in LevelGenerator and GameBootstrapOnCollected(PowerUpType, float)matches the(t, dur) =>lambda in GameBootstrapIReadOnlyList<Tile>inGameSessionuses.Where().ToList()(notFindAll) consistently