{"slug": "the-stale-godot-class-cache-bug-that-passed-ci-but-broke-local-startup", "title": "The Stale Godot Class Cache Bug That Passed CI but Broke Local Startup", "summary": "A developer fixed a Godot 4 bug in their game Nocturne Vania where a stale global class cache caused parse errors on local startup but passed CI. The issue occurred because CI imported the project before running tests, regenerating the cache, while local checkouts could have an outdated cache. The fix replaced global class name references with script paths and preloads across 19 files, adding a static check to prevent recurrence.", "body_md": "*This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.*\n\nNocturne Vania is a small pixel-art Metroidvania built with Godot 4. The game has interconnected rooms, enemy AI, save data, unlockable movement abilities, and a growing automated test suite.\n\nI hit this bug after adding a bell tower area. The new rooms, enemies, effects, and map markers used GDScript's `class_name`\n\nkeyword so they could be referenced as global types.\n\nThe new area worked in a freshly imported project and in CI. It did not always work in an existing local checkout.\n\nGodot stores imported project data under `.godot`\n\n. An editor session that predated the bell tower scripts could still have an old `global_script_class_cache.cfg`\n\n. In that state, starting the game caused a parse error because scripts such as `game.gd`\n\nreferred directly to global types that were missing from the stale cache.\n\nOne room script, for example, inherited from a new global class by name:\n\n```\nextends TowerRoom\n```\n\nThe test code also used the new classes for casts and enum access:\n\n``` js\nvar sentinel := await _test_spawn_enemy(\n    \"res://src/enemies/clockwork_sentinel.tscn\",\n    Vector2(320, 300)\n) as ClockworkSentinel\n\nif sentinel._state == ClockworkSentinel.State.CHARGE:\n    charged = true\n```\n\nThose references were valid after Godot refreshed its global class registry. Before that refresh, the parser could not resolve them.\n\nCI missed the problem because the test workflow imported the project before running the suite. The import regenerated the cache, so CI always tested the healthy state. Local startup followed a different order and exposed the bug.\n\nRefreshing or deleting `.godot`\n\ncould repair one checkout, but it left the startup dependency in the code. I wanted the game to parse even before the editor rebuilt the cache.\n\nI merged the complete fix as PR #95 in the project's private repository. Since the repository is not publicly accessible, the relevant before-and-after code is included below.\n\nThe first change was to use a script path when a room inherited from one of the newly added classes:\n\n```\nextends \"res://src/rooms/tower_room.gd\"\n```\n\nFor runtime checks, I loaded the script resource explicitly instead of asking the parser to resolve `TowerRoom`\n\nas a global name:\n\n``` js\nconst TOWER_ROOM_SCRIPT := preload(\"res://src/rooms/tower_room.gd\")\n\nfunc _is_tower_room(node: Node) -> bool:\n    if node == null:\n        return false\n\n    var script: Script = node.get_script() as Script\n    while script != null:\n        if script == TOWER_ROOM_SCRIPT:\n            return true\n        script = script.get_base_script()\n\n    return false\n```\n\nThe enemy tests now use their stable base type. They read the state through the object and compare it with an enum from an explicitly loaded script:\n\n``` js\nconst CLOCKWORK_SENTINEL_SCRIPT := preload(\n    \"res://src/enemies/clockwork_sentinel.gd\"\n)\n\nvar sentinel: EnemyBase = await _test_spawn_enemy(\n    \"res://src/enemies/clockwork_sentinel.tscn\",\n    Vector2(320, 300)\n)\n\nif int(sentinel.get(\"_state\")) == CLOCKWORK_SENTINEL_SCRIPT.State.CHARGE:\n    charged = true\n```\n\nTemporary objects such as wax pools and bell shockwaves did not need a global type check at all. I added them to groups and tested the behavior that mattered:\n\n```\nif child.is_in_group(&\"wax_pool\"):\n    pool_found = true\n```\n\nEffects and map marker helpers received the same explicit `preload`\n\ntreatment. The fix covered 19 files with 81 additions and 33 deletions.\n\nI added a static check to stop cache-sensitive global names from returning outside their own `class_name`\n\ndeclarations:\n\n```\nCACHE_SENSITIVE='\\b(TowerRoom|BelfrySpider|BellRingerWretch|CandleBearerSkeleton|CarrionCrow|ClockworkSentinel|GeneratedFx|MapMarkers|TowerImp|WaxSlime)\\b'\n\nCACHE_REFS=$(rg -n \"$CACHE_SENSITIVE\" src --glob '*.gd' \\\n  | grep -vE 'class_name (TowerRoom|BelfrySpider|BellRingerWretch|CandleBearerSkeleton|CarrionCrow|ClockworkSentinel|GeneratedFx|MapMarkers|TowerImp|WaxSlime)' \\\n  || true)\n\nif [ -n \"$CACHE_REFS\" ]; then\n    echo \"FAIL(stale-cache): direct reference to a newly added global class\"\n    exit 1\nfi\n```\n\nThis check is intentionally narrow. `class_name`\n\nis still useful in the project, and I did not want to ban it. The guard covers the recently added classes that caused this startup regression.\n\nI ran the full test command again on the current `main`\n\nbranch while preparing this submission:\n\n```\nPASS(stale-cache)\nPASS(rooms)\nPASS(transition)\nPASS(save)\nPASS(progression)\nPASS(gameover)\nPASS(pause)\nPASS(dash)\nPASS(boss)\nPASS(enemies)\nPASS(walljump)\nPASS(combo)\nPASS(skins)\nPASS(area3)\nPASS(area3-enemies)\nPASS(enemy-ai)\nPASS(physics)\nPASS(connections)\n== ALL PASS ==\n```\n\nThe asset check also passed for 911 media files, including 893 decoded PNGs and 110 frame sequences.\n\nClearing a bad cache would have repaired one checkout. The durable fix removes the undocumented requirement that the cache must be fresh before the project can be parsed. CI now checks the dependency directly, and the game no longer relies on editor state to reach its startup path.", "url": "https://wpnews.pro/news/the-stale-godot-class-cache-bug-that-passed-ci-but-broke-local-startup", "canonical_source": "https://dev.to/hirodeath/the-stale-godot-class-cache-bug-that-passed-ci-but-broke-local-startup-4jk5", "published_at": "2026-08-10 03:36:12+00:00", "updated_at": "2026-08-10 03:47:05.077833+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Godot", "Nocturne Vania", "GDScript", "Sentry"], "alternates": {"html": "https://wpnews.pro/news/the-stale-godot-class-cache-bug-that-passed-ci-but-broke-local-startup", "markdown": "https://wpnews.pro/news/the-stale-godot-class-cache-bug-that-passed-ci-but-broke-local-startup.md", "text": "https://wpnews.pro/news/the-stale-godot-class-cache-bug-that-passed-ci-but-broke-local-startup.txt", "jsonld": "https://wpnews.pro/news/the-stale-godot-class-cache-bug-that-passed-ci-but-broke-local-startup.jsonld"}}