Weekly Tech Debt Audit — misospace/windowstead
Date: 2026-07-01
Overall Risk Level: Low-Medium
Summary
Windowstead is a well-structured Godot 4.2 desktop companion game that has been carefully maintained with a recent push to extract pure-data modules from the monolithic main.gd. The extraction is mid-progress — goal_progression.gd, goal_reward.gd, rotating_goal.gd, colony_stance.gd, worker_cap_logic.gd, layout_math.gd, constants.gd, and game_state.gd have been split out successfully. However, main.gd remains at 2622 lines and still contains significant logic that should move to dedicated modules. Test coverage is good with 15+ test files, but some tests duplicate effort and several game-tick code paths lack direct verification.
Top Findings
P0 — Critical
-
Monolithic main.gd (2622 lines) — mid-extraction with high coupling
- Evidence:
main.gd still contains worker_texture() (pixel rendering), apply_anchor_layout() (full UI tree wiring), choose_task() (AI logic), _process() (edge snapping + overlay rendering), and push_event() mixed alongside game-state mutations.
- Risk: Every new feature requires touching this file. Testability suffers because half the logic is coupled to scene node references (
@onready var nodes, %BuildButtons, etc.).
- File:
scripts/main.gd
-
Worker texture rendering is pixel-pushing at runtime
- Evidence:
worker_texture() in main.gd creates a new Image object per worker per frame (12x14 RGBA8 image), fills it pixel-by-pixel, and creates an ImageTexture. Cached by worker_texture_cache keyed on name+frame+carrying, but _process calls render_worker_overlay() every frame.
- Risk: CPU cost per frame grows with worker count. Each texture is tiny, but the
Image.create() + pixel fill + ImageTexture.create_from_image() path is not GPU-friendly.
- File:
scripts/main.gd lines ~2000-2100
-
Edge-snapping rechecks apply_dock_position on every process frame (with cooldown)
- Evidence:
_process() re-runs apply_dock_position() every DOCK_RECHECK_COOLDOWN (0.5s). This calls DisplayServer.window_set_size(), DisplayServer.window_set_position(), and recalculates all tile metrics.
- Risk: Unnecessary display server calls even when nothing changed (only
current_usable != _last_usable_rect check guards the worst case, but calling it every 0.5s when idle still adds overhead).
- File:
scripts/main.gd
P1 — High
-
Stance food-bias logic duplicated between main.gd and colony_stance.gd
- Evidence:
choose_task() in main.gd has two separate food-sorting branches: one for "gather" with should_bias_to_food_gathering() and another for "gather_food" with ColonyStance.is_food_gather_task(). Both do virtually the same food-prioritization sort but via different code paths. The ColonyStance module already defines the food stance — the extra bias in choose_task() for "gather" kind duplicates this intent.
- File:
scripts/main.gd, scripts/colony_stance.gd
-
Save schema validation is thorough but not versioned against actual state shape
- Evidence:
validate_save_schema() in game_state.gd checks field types and bounds but does not validate that active_goal, completed_goal_ids, active_rewards, colony_stance have valid values or that the state dictionary is internally consistent (e.g., tile count matches grid_w * grid_h). The expected_sizes array hard-codes specific grid dimensions but doesn't derive them from anchor family logic.
- File:
scripts/game_state.gd
-
No integration test for the _on_tick full cycle
- Evidence:
test_e2e.gd simulates ticks by incrementing counters manually. _on_tick() in main.gd calls maybe_fire_event(), _clean_stale_reservations(), apply_food_upkeep(), choose_task() per worker, GoalProgression.process_tick(), GoalReward.tick_rewards(), persist(), and render_all(). None of the test files instantiate a full Control and run _on_tick() with a real timer. The e2e tests test state persistence, not real-time tick orchestration.
- Files:
tests/test_e2e.gd, scripts/main.gd
-
No tests for worker_cap_logic.gd integration with recruit_worker
- Evidence:
worker_cap_logic.gd has pure calculate_worker_cap() and can_recruit() but tests/test_worker_cap.gd tests the module in isolation. The actual recruit_worker() in main.gd uses can_recruit_worker() which calls get_worker_cap() — a slightly different implementation that iterates state.builds directly. This is a parallel implementation of the same logic.
- Files:
scripts/worker_cap_logic.gd, scripts/main.gd
P2 — Medium
-
Duplicate worker cap calculation logic
- Evidence:
worker_cap_logic.gd:calculate_worker_cap() computes cap from a builds array. main.gd:get_worker_cap() computes the same formula iterating state.builds. Two implementations of the same logic — test drift risk.
- Files:
scripts/worker_cap_logic.gd, scripts/main.gd
-
recruit_worker doesn't use worker_cap_logic module
- Evidence:
recruit_worker() calls get_worker_cap() which is a local main.gd method, not the extracted worker_cap_logic.calculate_worker_cap(). The extracted module was clearly intended to replace this.
- Files:
scripts/main.gd, scripts/worker_cap_logic.gd
-
constant WORKER_NAMES has only 2 entries — cycles early
- Evidence:
WORKER_NAMES := ["Jun", "Mara"]. With BASE_WORKER_CAP=2 and huts adding +2 each, even with one hut the cap is 4. Workers cycle through 2 names (Jun, Mara, Jun, Mara). No collision handling for same-named workers.
- File:
scripts/constants.gd
-
Event log bounded to 8 entries — events push_front and pop_back
- Evidence:
push_event() pushes front and pops back when size > 8. The event drawer shows last 6. This means 2 events are hidden between the collapsed label and the expanded log. The game loses event history rapidly — 8 events is very tight.
- File:
scripts/main.gd
-
Reservation system is not persisted
- Evidence:
reserve_resource() / release_resource() mutate state.reserved_resources but persist() does not include it in the save. On load, reservations reset to 0, which can cause two workers to double-book on the same resource tile.
- File:
scripts/main.gd
-
Builder do_build progress uses structure_build_speed without reservation check
- Evidence: Two workers can both be assigned to build the same foundation.
do_build() increments progress independently for each. If both have the build task, progress advances at double speed. No mutex or worker-cap-per-build limit exists.
- File:
scripts/main.gd
-
tile_accent uses pending_build_kind hover logic with floating-panel references
- Evidence:
tile_accent() accesses pending_build_kind and hover_tile_index — both mutable state interleaved with rendering. This is called from tile_style() which is called from render_world(). This coupling makes testing tile rendering without scene setup impossible.
- File:
scripts/main.gd
-
Tests import GameState as Node but use use_local_storage = false hack
- Evidence: Every test file creates
var gs = load_game_state() → gs_script.new() → sets gs.use_local_storage = false. This is a test-time workaround for the JavaScriptBridge reference in _ready(). If _ready() runs, it will crash without OS.has_feature("web").
- Files: All test files
-
export_presets.cfg not exported via CI
- Evidence: The
release.yml workflow does not reference export presets. There is no pck/export build step in CI. The game only tests via --script headless mode, never verifies the export configuration works.
- Files:
.github/workflows/release.yml, export_presets.cfg
-
MILESTONE_CATALOG in milestone_manager.gd is defined but never called from main.gd
- Evidence:
MilestoneManager class is fully defined at scripts/milestone_manager.gd but main.gd has zero references to it. No import, no preload, no usage in bootstrap_state() or _on_tick(). The milestone system is dead code.
- File:
scripts/milestone_manager.gd
Recommended Issue Breakdown
- P1 — Extract remaining main.gd subsystems into modules (choose_task AI, render_*, worker_texture, _process edge snap)
- P1 — Deduplicate worker cap logic: make main.gd:get_worker_cap() delegate to worker_cap_logic.gd
- P1 — Deduplicate food-bias sort logic: unify gather/gather_food sort paths in choose_task
- P2 — Persist reserved_resources in save data to prevent double-booking across reloads
- P2 — Add worker-per-build cap to prevent double-speed construction
- P2 — Expand WORKER_NAMES or add unique seed to worker names
- P2 — Increase event log capacity (8→20+)
- P2 — Add full-cycle integration test for _on_tick with real timer mock
- P2 — Wire MilestoneManager into main.gd bootstrap_state() or remove dead code
- P3 — Move tile_accent / tile_style rendering helpers into a render_module.gd
- P3 — Derive expected tile grid sizes from LayoutMath in save schema validation instead of hard-coded list
- P3 — Remove
use_local_storage = false test workaround with proper DI or sandbox
- P3 — Add export build step to CI
Not Worth Doing Yet
- Full pixel-art asset pipeline: The runtime pixel-pushed textures are charming and part of the aesthetic. An actual sprite system would be a major refactor for a desktop-companion game. Keep as-is.
- Network/web integration: Godot 4.2's web export is experimental and the game has no multiplayer use case. The
JavaScriptBridge code is minimal.
- Save encryption/obfuscation: The game saves plain JSON to
user://. For a single-player desktop companion, this is fine.
- Phaser-like state machine for workers: The current task→step→complete pattern is simple and works. A full FSM would be overengineering.
- UI toolkit abstraction (MVC): Godot's scene tree IS the view. Extracting a separate view layer adds complexity without benefit for this scale.
Weekly Tech Debt Audit — misospace/windowstead
Date: 2026-07-01
Overall Risk Level: Low-Medium
Summary
Windowstead is a well-structured Godot 4.2 desktop companion game that has been carefully maintained with a recent push to extract pure-data modules from the monolithic
main.gd. The extraction is mid-progress —goal_progression.gd,goal_reward.gd,rotating_goal.gd,colony_stance.gd,worker_cap_logic.gd,layout_math.gd,constants.gd, andgame_state.gdhave been split out successfully. However,main.gdremains at 2622 lines and still contains significant logic that should move to dedicated modules. Test coverage is good with 15+ test files, but some tests duplicate effort and several game-tick code paths lack direct verification.Top Findings
P0 — Critical
Monolithic main.gd (2622 lines) — mid-extraction with high coupling
main.gdstill containsworker_texture()(pixel rendering),apply_anchor_layout()(full UI tree wiring),choose_task()(AI logic),_process()(edge snapping + overlay rendering), andpush_event()mixed alongside game-state mutations.@onready varnodes,%BuildButtons, etc.).scripts/main.gdWorker texture rendering is pixel-pushing at runtime
worker_texture()in main.gd creates a newImageobject per worker per frame (12x14 RGBA8 image), fills it pixel-by-pixel, and creates anImageTexture. Cached byworker_texture_cachekeyed on name+frame+carrying, but_processcallsrender_worker_overlay()every frame.Image.create()+ pixel fill +ImageTexture.create_from_image()path is not GPU-friendly.scripts/main.gdlines ~2000-2100Edge-snapping rechecks apply_dock_position on every process frame (with cooldown)
_process()re-runsapply_dock_position()everyDOCK_RECHECK_COOLDOWN(0.5s). This callsDisplayServer.window_set_size(),DisplayServer.window_set_position(), and recalculates all tile metrics.current_usable != _last_usable_rectcheck guards the worst case, but calling it every 0.5s when idle still adds overhead).scripts/main.gdP1 — High
Stance food-bias logic duplicated between main.gd and colony_stance.gd
choose_task()in main.gd has two separate food-sorting branches: one for "gather" withshould_bias_to_food_gathering()and another for "gather_food" withColonyStance.is_food_gather_task(). Both do virtually the same food-prioritization sort but via different code paths. TheColonyStancemodule already defines the food stance — the extra bias inchoose_task()for "gather" kind duplicates this intent.scripts/main.gd,scripts/colony_stance.gdSave schema validation is thorough but not versioned against actual state shape
validate_save_schema()ingame_state.gdchecks field types and bounds but does not validate thatactive_goal,completed_goal_ids,active_rewards,colony_stancehave valid values or that the state dictionary is internally consistent (e.g., tile count matches grid_w * grid_h). Theexpected_sizesarray hard-codes specific grid dimensions but doesn't derive them from anchor family logic.scripts/game_state.gdNo integration test for the _on_tick full cycle
test_e2e.gdsimulates ticks by incrementing counters manually._on_tick()in main.gd callsmaybe_fire_event(),_clean_stale_reservations(),apply_food_upkeep(),choose_task()per worker,GoalProgression.process_tick(),GoalReward.tick_rewards(),persist(), andrender_all(). None of the test files instantiate a fullControland run_on_tick()with a real timer. The e2e tests test state persistence, not real-time tick orchestration.tests/test_e2e.gd,scripts/main.gdNo tests for worker_cap_logic.gd integration with recruit_worker
worker_cap_logic.gdhas purecalculate_worker_cap()andcan_recruit()buttests/test_worker_cap.gdtests the module in isolation. The actualrecruit_worker()in main.gd usescan_recruit_worker()which callsget_worker_cap()— a slightly different implementation that iterates state.builds directly. This is a parallel implementation of the same logic.scripts/worker_cap_logic.gd,scripts/main.gdP2 — Medium
Duplicate worker cap calculation logic
worker_cap_logic.gd:calculate_worker_cap()computes cap from a builds array.main.gd:get_worker_cap()computes the same formula iteratingstate.builds. Two implementations of the same logic — test drift risk.scripts/worker_cap_logic.gd,scripts/main.gdrecruit_worker doesn't use worker_cap_logic module
recruit_worker()callsget_worker_cap()which is a localmain.gdmethod, not the extractedworker_cap_logic.calculate_worker_cap(). The extracted module was clearly intended to replace this.scripts/main.gd,scripts/worker_cap_logic.gdconstant WORKER_NAMES has only 2 entries — cycles early
WORKER_NAMES := ["Jun", "Mara"]. WithBASE_WORKER_CAP=2and huts adding +2 each, even with one hut the cap is 4. Workers cycle through 2 names (Jun, Mara, Jun, Mara). No collision handling for same-named workers.scripts/constants.gdEvent log bounded to 8 entries — events push_front and pop_back
push_event()pushes front and pops back when size > 8. The event drawer shows last 6. This means 2 events are hidden between the collapsed label and the expanded log. The game loses event history rapidly — 8 events is very tight.scripts/main.gdReservation system is not persisted
reserve_resource()/release_resource()mutatestate.reserved_resourcesbutpersist()does not include it in the save. On load, reservations reset to 0, which can cause two workers to double-book on the same resource tile.scripts/main.gdBuilder
do_buildprogress uses structure_build_speed without reservation checkdo_build()increments progress independently for each. If both have the build task, progress advances at double speed. No mutex or worker-cap-per-build limit exists.scripts/main.gdtile_accent uses pending_build_kind hover logic with floating-panel references
tile_accent()accessespending_build_kindandhover_tile_index— both mutable state interleaved with rendering. This is called fromtile_style()which is called fromrender_world(). This coupling makes testing tile rendering without scene setup impossible.scripts/main.gdTests import GameState as Node but use use_local_storage = false hack
var gs = load_game_state()→gs_script.new()→ setsgs.use_local_storage = false. This is a test-time workaround for theJavaScriptBridgereference in_ready(). If_ready()runs, it will crash withoutOS.has_feature("web").export_presets.cfg not exported via CI
release.ymlworkflow does not reference export presets. There is no pck/export build step in CI. The game only tests via--scriptheadless mode, never verifies the export configuration works..github/workflows/release.yml,export_presets.cfgMILESTONE_CATALOG in milestone_manager.gd is defined but never called from main.gd
MilestoneManagerclass is fully defined atscripts/milestone_manager.gdbutmain.gdhas zero references to it. No import, nopreload, no usage inbootstrap_state()or_on_tick(). The milestone system is dead code.scripts/milestone_manager.gdRecommended Issue Breakdown
use_local_storage = falsetest workaround with proper DI or sandboxNot Worth Doing Yet
JavaScriptBridgecode is minimal.user://. For a single-player desktop companion, this is fine.