# Architecture & Code Quality Review — Dungeon Lords

Reviewed: 2026-04-07
Previous review: `architecture_review.md` (2026-04-02)

This pass re-checks the 15 issues from the prior review and surfaces new findings introduced by recent work (fog of war, camera fixes, map sizing, corners, room/wall renderer).

---

## Status of Prior Review (2026-04-02)

| # | Issue | Status | Notes |
|---|-------|--------|-------|
| 1  | No allegiance check on combat | **Open** | `nodes/player_controller.gd:135` |
| 2  | No pathfinding | **Partially mitigated** | `is_tile_passable()` now blocks the next step (`player_controller.gd:122`), but units still walk around L-walls one axis at a time and have no movement budget |
| 3  | Stale combat proposal | **Open** | `apply_proposal()` still has no `is_instance_valid()` guards (`combat_system.gd:129-148`) |
| 4  | `queue_free()` dangling refs | **Open** | `nodes/unit.gd` still queue_frees immediately on death |
| 5  | Duplicated unit-tracking | **Open** | `player_controller.gd:26-35` and `scripts/battle/combat_ui.gd:40-50` |
| 6  | Inline scripts in unit.tscn | **Open** | Two `SubResource` GDScript blocks remain in `prefabs/unit.tscn` |
| 7  | Two combat code paths | **Open** | `combat_system.gd:114` `process_combat()` is dead (no callers in code, only docs/plans) |
| 8  | O(n) unit lookup | **Open** | `player_controller.gd:159-167` |
| 9  | No turn / movement system | **Open** | `spd` still unused |
| 10 | `atk_range` not enforced | **Open** | Tactic-range is checked when *building* a proposal (`combat_system.gd:25`), but `_handle_left_click` emits `combat_requested` without any pre-check |
| 11 | tile_highlight always processing | **Resolved** | `tile_highlight.gd` is gone; replaced by `scripts/battle/grid_overlay.gd` which only updates on `target_tile()` calls |
| 12 | Unused GridOverlay | **Resolved** | Now wired through `StrategyPhase._on_mouse_grid_changed → CombatMap.target_tile()` (`combat_map.gd:85`) |
| 13 | Jolt 3D physics configured | **Open** | Still in `project.godot` |
| 14 | `current_hp` default fragile | **Resolved-ish** | `UnitStats._init` sets it; works but `@export` still has no default |
| 15 | `UnitInfo.name` shadows `Object.name` | **Open** | `resources/resource_definitions/unit_info.gd:3` |

**Tally:** 11 still open, 1 partially mitigated, 3 resolved.

---

## High Priority

### A. `apply_proposal` does not validate unit references — **Resolved 2026-04-07**

`apply_proposal` now early-returns if either unit reference is invalid, and re-checks both before the counterattack branch.

**File:** `nodes/combat_system.gd:129-148`

`process_combat()` was given an `is_instance_valid()` guard at the top, but the canonical path used by the UI — `apply_proposal()` — calls `def_unit.take_damage(...)` and `atk_unit.take_damage(...)` directly without checking either reference. Today this is harmless because `Unit._die()` only happens through this same code path, but the moment a second damage source exists (poison, AoE, traps), a unit can be freed mid-proposal and `apply_proposal` will hit a freed reference.

**Fix:** Guard both unit accesses with `is_instance_valid()`. Better, snapshot the unit references into the proposal at creation time and treat them as nullable from then on.

---

### B. No allegiance check on combat targeting

**File:** `nodes/player_controller.gd:135`

```gdscript
if _selected_unit and clicked_unit != _selected_unit \
    and _selected_unit.is_alive() and clicked_unit.is_alive():
    combat_requested.emit(_selected_unit, clicked_unit)
```

A player unit can attack a friendly unit. Carried over unchanged from the prior review.

**Fix:** Compare `clicked_unit.current_allegiance.type` against the selected unit, ideally in `StrategyPhase` so `PlayerController` stays allegiance-agnostic.

---

### C. `Unit.queue_free()` on death leaves dangling references in proposals

**File:** `nodes/unit.gd` (`_die`)

When a unit dies it emits `unit_died` and immediately calls `queue_free()`. `CombatUI._on_unit_died` (`scripts/battle/combat_ui.gd:52-58`) compares the dying unit against `_current_proposal.attacker.unit` — that comparison happens *this* frame, so it's fine, but `CombatProposal` still holds the same reference and will be invalid for any code that runs after the deferred free.

**Fix:** Either delay the `queue_free()` until after combat resolution finishes (hide-then-free), or null out unit references in any active proposal as part of the death handler.

---

### D. `attack_range` is never enforced before requesting combat

**Files:** `nodes/player_controller.gd:135`, `nodes/combat_system.gd:22-27`

Tactic-range filtering happens *inside* `create_proposal`, so a click that targets an unreachable enemy will produce a proposal with no valid tactics — and from there the behavior depends on `_find_default_attack` returning `null` and `_snapshot` happily writing zeros. The user-visible bug is that you can click any enemy on the map and a proposal opens regardless of distance.

**Fix:** Check distance against the attacker's max tactic range (or `current_stats.atk_range`) inside `_handle_left_click` before emitting `combat_requested`. Reject the click otherwise.

---

## Medium Priority

### E. Pathfinding still missing; movement is greedy axis-stepping

**File:** `nodes/player_controller.gd:96-127`

`is_tile_passable` now blocks the next single step, which is an improvement, but the mover is still a greedy "pick the larger axis" loop with no global plan. Going around an L-shaped wall succeeds; going around a U-shaped wall doesn't. There's also still no movement budget — a unit can walk to the far corner of the map with a single click.

**Fix:** Compute the full path with BFS/A* over `MapLayout.is_passable` at click time, store it as a list of tiles, and step along it. Add a per-turn movement budget once a turn system exists.

---

### F. Duplicated unit-tracking boilerplate

**Files:** `nodes/player_controller.gd:26-35`, `scripts/battle/combat_ui.gd:40-50`

Both systems independently iterate `get_nodes_in_group("units")`, hook `node_added`, and guard against duplicate connections. Any third system that cares about units will copy this again.

**Fix:** Promote a small `UnitRegistry` autoload that emits `unit_registered(unit)` / `unit_deregistered(unit)`. This ties into finding G below.

---

### G. `CombatMap.remove_unit` doesn't disconnect signals

**File:** `nodes/combat_map.gd:80-82`

`remove_unit` only calls `remove_child`. PlayerController and CombatUI both connected to `unit_died` (and `unit_selected_changed` for PlayerController via the AllegianceIndicator path). If a unit is removed and re-added — or destroyed without going through `_die` — the connections leak. Combined with finding F, an event-bus / registry would solve both.

---

### H. Two combat entry points; `process_combat` is dead — **Resolved 2026-04-07**

`process_combat()` deleted from `combat_system.gd`. Only `create_proposal` → `apply_proposal` remains.

**File:** `nodes/combat_system.gd:114-127`

`process_combat()` has no callers in `nodes/` or `scripts/`; the only references are in plan/spec docs under `docs/superpowers/`. It mirrors the proposal-application path and is liable to drift.

**Fix:** Delete `process_combat()` and let any future AI/auto-combat go through `create_proposal` → `apply_proposal` directly.

---

### I. O(n) unit lookup on every click

**File:** `nodes/player_controller.gd:159-167`

`_get_unit_at` snaps every unit in the group to the grid and compares. Fine for tens of units, slow for hundreds.

**Fix:** Maintain a `Dictionary[Vector2i, Unit]` in `CombatMap` that updates on deploy/move/death. Lookups become O(1) and you get tile-occupancy collision detection for free.

---

### J. Sprite spam in `FogRenderer` and `WallRenderer` — **Resolved 2026-04-07**

Both renderers now override `_draw()` and use `draw_texture_rect_region()` directly. Zero child sprites; layout changes call `queue_redraw()` instead of thrashing the scene tree. Findings **O** (redundant `match` arms) and **P** (undocumented `FOG_RECT`) were folded into the same change.

**Files:** `nodes/fog_renderer.gd:13-43`, `nodes/wall_renderer.gd:44-176`

Both renderers create one `Sprite2D` per fog tile / per wall half-segment / per inner corner, with no pooling, and `_clear()` `queue_free`s every child on the next layout change. For a modest 30×30 map this is already in the high hundreds of nodes; for larger maps it scales linearly with `width × height` plus wall surface area, and every map reload thrashes the scene tree.

**Fix:** Override `_draw()` and use `draw_texture_rect_region()` for both renderers. You get the same visual result with one draw call per region instead of N nodes, and `queue_redraw()` on layout change replaces the whole `_clear` + recreate cycle.

---

### K. `MapLayout` requires `initialize()` but exposes no guard

**Files:** `nodes/combat_map.gd:89-101`, `resources/resource_definitions/map_layout.gd`

`apply_layout()` is the only place that calls `map_layout.initialize()`. Anything that constructs a `MapLayout` and calls `is_passable()` / `is_tile_valid()` without going through `apply_layout` will silently see empty internal state.

**Fix:** Either lazy-initialize inside `is_passable` / `is_tile_valid`, or assert that `initialize()` has been called.

---

### L. `target_tile` highlight updates while combat UI is open — **Not a bug (2026-04-07)**

On re-reading `player_controller.gd:44-46`, `_process` already early-returns when `input_disabled` is true. Original finding was based on a misread; no code change needed.

**File:** `nodes/player_controller.gd:44-52`

`input_disabled` short-circuits `_unhandled_input` but `_process` still runs and emits `mouse_grid_changed`, so the cursor highlight keeps tracking the mouse during the combat proposal panel. May be intentional, but looks like a bug — the player just told the game "I'm interacting with the modal", not "keep showing me where my next click would land on the map."

**Fix:** Bail out of `_process` (or at least suppress the emit) when `input_disabled` is true.

---

### M. Inline scripts in `prefabs/unit.tscn`

The AllegianceIndicator and selection-highlight scripts are still embedded as `SubResource` GDScript blocks in the scene file. Invisible to grep, painful to refactor.

**Fix:** Extract to `scripts/units/allegiance_indicator.gd` and `scripts/units/unit_selection_highlight.gd`.

---

## Low Priority

### N. `WallRenderer._draw_outer_corners` is a stub

**File:** `nodes/wall_renderer.gd:114-115, 179-183`

The function is called but no-ops. The TODO is fine for now, but the matching atlas constants for outer corners aren't defined either, so when you implement it you'll need to add both. Worth a tracker entry.

---

### O. `WallRenderer._draw_tile_walls` match arms are identical — **Resolved 2026-04-07**

Collapsed to a single `for edge in edges: _build_edge_segments(...)` loop as part of finding J's refactor.

**File:** `nodes/wall_renderer.gd:103-112`

```gdscript
match edge:
    &"left":  _draw_edge_segments(tile_origin, edge)
    &"right": _draw_edge_segments(tile_origin, edge)
    &"top":   _draw_edge_segments(tile_origin, edge)
    &"bottom":_draw_edge_segments(tile_origin, edge)
```

The whole `match` collapses to `for edge in edges: _draw_edge_segments(tile_origin, edge)`.

---

### P. `FogRenderer.FOG_RECT` is `(53, 53, 100, 100)` — tile-aligned magic constant

**File:** `nodes/fog_renderer.gd:8`

Hardcoded atlas region with no comment about why those coordinates. Same kind of magic numbers that the wall renderer uses, but at least the wall constants have section comments. Add a one-line comment naming the source asset.

---

### Q. `Jolt Physics` configured for a 2D project

**File:** `project.godot`

Carried over from prior review. `3d/physics_engine="Jolt Physics"` is harmless but unused — the game has no 3D nodes. Removing it removes a "huh, why?" for any new contributor.

---

### R. `UnitInfo.name` still shadows `Object.name`

**File:** `resources/resource_definitions/unit_info.gd:3`

Rename to `display_name` or `unit_name` to disambiguate from `Node.name` / `Object.name`.

---

### S. `CombatMap.is_wall` and `is_tile_valid` have legacy fallbacks — **Resolved 2026-04-07**

Fallback branches removed from `is_tile_passable` and `is_tile_valid`; both now `assert(map_layout != null)`. The now-dead `is_wall()` helper was deleted.

**File:** `nodes/combat_map.gd:97-108`

Both methods have a "no room system" fallback path. Now that every map goes through `MapLayout`, the fallback is dead — but it's also the kind of dead code that quietly hides bugs (a missing `map_layout` won't crash, it'll silently fall through to wall-tile checks). Either delete the fallbacks and assert, or document the contract.

---

## Architectural Notes

### Renderer pattern is the right shape, wrong implementation
`WallRenderer` and `FogRenderer` correctly isolate "draw the map's decoration layer" from `CombatMap`. The split is good. The execution — instantiating Sprite2D children — is the part to revisit. A `_draw()`-based implementation would let both classes keep the same public API (`draw_walls_for_layout(MapLayout)` / `draw_fog_for_layout(MapLayout)`) and their `_clear()` calls would become `queue_redraw()`.

### Combat data flow is solid
`PlayerController → StrategyPhase → CombatSystem ← CombatUI` is still clean. The remaining issues in this area (A, B, C, D) are all edge cases at the boundaries, not architectural problems.

### The missing piece is still turn/movement
Findings 2 and 9 from the prior review are the largest gameplay gap. Until that exists, "combat" is "pick two units anywhere on the map and click." Worth scoping a small turn manager (`turn_started(unit)`, `turn_ended(unit)`, `action_points_remaining`) before adding more combat features on top of the current freeform model.

### No tests
`CombatSystem` is pure logic that takes resources and returns a proposal — it's the easiest thing in the codebase to test, and the proposal/apply path is the most fragile. A few GdUnit4 tests on combat math would catch most of A/B/C/D as regressions.

---

## Summary

| # | Finding | Priority | Effort |
|---|---------|----------|--------|
| A | `apply_proposal` no `is_instance_valid` | ~~High~~ | **Done** |
| B | No allegiance check | High | Small |
| C | `queue_free` dangling refs | High | Small |
| D | `atk_range` not enforced pre-request | High | Small |
| E | Greedy axis movement, no pathfinding | Medium | Medium |
| F | Duplicated unit-tracking | Medium | Medium |
| G | `remove_unit` leaks signal connections | Medium | Small |
| H | Dead `process_combat` | ~~Medium~~ | **Done** |
| I | O(n) unit lookup | Medium | Small |
| J | Sprite-per-tile renderers | ~~Medium~~ | **Done** |
| K | `MapLayout.initialize` not guarded | Medium | Small |
| L | Highlight updates while modal open | ~~Medium~~ | **Not a bug** |
| M | Inline scripts in `unit.tscn` | Medium | Small |
| N | `_draw_outer_corners` stub | Low | Medium |
| O | Redundant `match` in wall renderer | ~~Low~~ | **Done** |
| P | Magic `FOG_RECT` constant | Low | Trivial |
| Q | Jolt 3D in 2D project | Low | Trivial |
| R | `UnitInfo.name` shadows | Low | Trivial |
| S | Dead fallback paths in `CombatMap` | ~~Low~~ | **Done** |
