Skip to content

Bullet Heaven — Godot 4.6 gotchas

Extracted from CLAUDE.md on 2026-07-04 to keep the always-loaded file lean. This is the current architecture reference for these systems, not a changelog — the “M2 cycle N, DONE” headings document present code. Keep it current when you change the code. See CLAUDE.md § “Subsystem architecture — read on demand”.

  • Headless cannot read back MultiMesh per-instance transforms/colors. Under --headless the dummy RenderingServer returns (0,0)/black for get_instance_transform_2d/get_instance_color even though instance_count is correct. So MultiMesh render tests can only assert instance_count (+ resync behavior) headlessly; pixel placement is verified by playtest. (Probe to confirm: a SceneTree script that sets then gets an instance transform.)
  • run/main_scene pointing at a not-yet-existent scene makes godot --import HARD-FAIL (Cannot open file 'res://main.tscn'), which leaves the global class cache stale so newly-added class_name scripts aren’t registered — and GUT then silently drops their tests from a -gdir scan. A green suite can be running fewer tests than you think. Always verify the test COUNT, not just “all passed.” Keep main.tscn present (or don’t reference a missing main_scene). .godot/ is gitignored, so CI rebuilds the cache from scratch — a CI --import step that fails on error + an expected-test-count assert would guard this.
  • SwarmRenderer.configure(color) sets modulate AND sync() sets per-instance color → passing the same color to both renders it squared (darker). Configure with Color.WHITE, let per-instance sync color drive (preserves neon brightness).
  • Determinism trace caveat: snapshot_string() captures aggregate counts + player state, not per-entity positions — so two runs could diverge in entity positions while the string stays equal. Sim.state_checksum() (used by tests/test_determinism_checksum.gd) closes that gap: it hashes per-entity positions + data + aura columns (enemies/projectiles/gems) plus player state, for Phase-3 desync hunting. Use the checksum, not the string, when chasing a position-level desync.
  • Don’t hand-author [input] actions in project.godot. Godot serializes key events as Object(InputEventKey,"physical_keycode":87,...), NOT a plain dict {"type":"InputEventKey","keycode":87}. The plain-dict form parses into an action with zero events (InputMap.has_action true but action_get_events().size()==0), so Input.get_vector silently returns zero and nothing responds — symptom: “can’t move,” no error. Register input actions in CODE instead: InputRouter.ensure_actions() binds physical_keycode WASD via InputMap.add_action/action_add_event (layout-independent, idempotent, unit-testable). Probe to diagnose any input issue: a SceneTree script printing InputMap.action_get_events(a).size().
  • GUT 9.6 FAILS any test in which an un-asserted push_error fires (addons/gut/error_tracker.gd, default treat_push_error_as = FAILURE; gut.gd should_test_fail_from_errors). So a function that push_errors on bad input (e.g. ContentLoader.load_from_dict on invalid data, the fail-loud problem list) will trip GUT even when its return value is correct. Two ways to handle: (a) test the non-erroring seam — e.g. assert against ContentLoader.validate(raw)’s returned problem array, which does NOT push_error; (b) when you must exercise the erroring path, consume the expected error with assert_push_error("substr") / assert_push_error_count(n) (case-insensitive; marks it handled so it no longer fails the test). Keep the production push_error (it’s the boot-time fail-loud); just consume it in the one test that triggers it. Corollary: pure /sim helpers that “should never” hit a branch (e.g. StatEffects.apply on an unknown effect) take a silent no-op rather than push_error, since the bad path is upstream-guarded by validation and a push_error there only trips tests. (OS.is_debug_build() is NOT an escape hatch — OS is an Engine API, forbidden in /sim.)
  • GUT 9.6.0 is the Godot 4.6 release. Vendored under addons/gut/ (committed dependency, not authored — don’t review/edit it).
  • CanvasLayer has no theme property — it’s a Node, not a Control. Setting .theme on a CanvasLayer root silently does nothing. Apply NeonTheme.get_theme() to the first child Control node (e.g. center.theme = … on a CenterContainer), from which it propagates to all descendant Controls. Confirmed in NeonTheme implementation (cycle 6).
  • GDScript Variant arithmetic requires explicit float() casts. var t := 1.0 - a["life"] / a["max_life"] is rejected by Godot 4.6 type inference when a is a Dictionary (values are Variants). Write var t: float = 1.0 - float(a["life"]) / float(a["max_life"]). Affects any pooled-FX advance loop or similar per-frame Dict math.
  • := type inference fails across or with a narrowed-event property → a SILENT-HANG class-compile failure. var ok := a or (event is InputEventX and event.pressed) — GDScript does NOT narrow event to InputEventX inside an or, so event.pressed/.button_index read as Variant and the := “cannot infer type” Parse Errors. That fails the WHOLE script’s compile → MyClass.new() returns null (“Nonexistent function ‘new’ in base ‘GDScript’”) → a node that calls into it silently no-ops and the game freezes with no visible error. Fix: narrow inside an if event is InputEventX: block or annotate var ok: bool =. Always boot-check for SCRIPT ERROR before trusting a build (note: timeout is not on macOS PATH — it’s gtimeout — so a timeout-wrapped smoke check silently no-ops and hides this). (2026-06-24, tvOS port.)
  • A GUT assertion typo (e.g. assert_le — the real method is assert_lte) is a Parse Error that silently DROPS the whole test file from a -gdir run — the suite still reports “All tests passed” with one FEWER script. The scripts/check-test-count.sh guard catches it (ran fewer scripts than test_*.gd files); always trust the COUNT, not just “passed”. GUT methods: assert_lte/assert_gte, not assert_le/assert_ge. (2026-06-24.)
  • MAX_ENEMY_RADIUS (the projectile broad-phase bound in _resolve_collisions) MUST cover the largest enemy or projectiles tunnel straight through it. A big enemy (the 70px boss) needs MAX_ENEMY_RADIUS >= its radius; with the old 26 (elite), projectiles missed the boss entirely. Widening it is determinism-safe ONLY because the baseline run has no player projectiles (melee-blade start) — verify before changing if that ever changes.
  • Render-side type→colour LUTs must be sized by MAX type id, not entry count. Enemy TYPE_* ids are non-contiguous (boss=6 has no bible.json entry; skirmisher=7), so _enemy_type_colors.resize(names.size()) then arr[tid]=… indexes out of bounds for tid 7. Size to TYPE_SKIRMISHER + 1 and special-case ids without a bible colour (boss/skirmisher) in _enemy_colors. (2026-06-24.)
  • A GDScript inner-class member CANNOT be named ready — it collides with Node’s built-in ready signal → Parse Error: Member "ready" redefined (original in native class 'Control'), which fails the WHOLE script compile → .new() returns null → a silent freeze (the boot-check grep "SCRIPT ERROR" catches it). Use another name (e.g. crackling). Same class as the :=-across-or silent-compile trap. (2026-06-25, hud.gd DecoyCrackle.)
  • Reactions do NOT fire in the blade-only determinism baseline (seed 1234, 600 ticks), so tuning the reaction constants (REACTION_DAMAGE_SCALE, burst magnitude, ZONE_DPS) is determinism-SAFE. Likewise the decoy/boss/synergy/telemetry counters are all inactive-or-excluded in the baseline. Always re-run the determinism test after a sim const change, but don’t assume a balance tweak moved the baseline — most don’t. (2026-06-25.) Note: _vary_stats is called every spawn and DOES move the checksum (it draws rng values per spawn, shifting entity positions). The task-10 re-pin moved state_checksum from 12679549852325839371; snapshot_string().hash() remained 4152236597 (aggregates unaffected by per-entity stat variation).
  • A render-side effect that DECAYS / restores state each frame MUST be advanced ABOVE the _process early-returns (if sim.game_over: return, _paused_for_levelup, _warping, _paused_for_menu), or it FREEZES on the death / level-up / pause / area-warp screens — and an overlay frozen on a high CanvasLayer (warp streaks=19, control hint=18) then draws OVER the results panel (layer 10). Caught 2026-06-30: the warp world-dim (main._drive_warp) + ControlHint.advance sat below those returns, so a warp fired in the i-frame-gap-before-death (DASH i-frames 0.18s < WARP_S 0.30s) left the world stuck at ~45% brightness + the streak overlay frozen above the death screen until the next run. Fix: hoist all such decay/restore drivers to the top of _process (right after if sim == null: return) and force-finish them on game_over. The _physics_process sim-tick freeze gates are correct where they are; this rule is ONLY for render-side restore-to-default effects. (Anything that modulate-darkens shared render nodes especially — it must un-dim on every exit path.)
  • ArchetypeRenderer.sync(enemies, colors, ...) takes a PER-ENEMY colour array (main._enemy_colors(), length == enemies.count) indexed by enemy SLOT i — NOT a per-type LUT indexed by type_id. (It read colors[tid] for a while, silently ignoring every per-enemy aura/boss tint; fixed 2026-06-30.) When adding an enemy colour source, vivify it at the SOURCE (ElementPalette.color_for + _build_enemy_type_colors + the boss overrides in _enemy_colors all run through ElementPalette.vivid()); vivid() ramps saturation but no-ops on hueless colours (so the white-cyan GEM is left alone).
  • A per-enemy-TYPE display name is NOT the same as its bible/internal iddata/bestiary.json (player-facing name/desc/counter, separate file from data/bible.json) and EnemyPool.TYPE_NAMES (telemetry attribution) can diverge from the content id: EnemyPool.TYPE_ELITE (bible id "elite", behavior=dash) displays as “Charger”; EnemyPool.TYPE_BOSS2 displays as “Sentinel”; EnemyPool.TYPE_SPIDER (bible id "spider") displays as “Weaver” (renamed 2026-07-02 — “spider” doesn’t fit the space theme; the internal id/class/TYPE_NAMES telemetry label are unchanged, only bible.json/bestiary.json name fields moved). When a request names an enemy by its in-game/display name, grep bestiary.json + EnemyPool.TYPE_NAMES first. (2026-07-01.)
  • A baked player-ship hull sprite (render/ship_sprites/ship3d_*.png, shown by PlayerRenderer when USE_BAKED_HULL) MUST be a flat top-down orthographic view — nose pointing straight up, bilaterally symmetric, soft/even centerline lighting (NOT a one-sided key light), alpha-isolated on transparent. PlayerRenderer just rotates the whole Sprite2D via Node2D.rotation to match facing; there’s no per-frame relighting shader on the baked path, so a dramatic 3/4-angle “hero shot” with directional shadows looks wrong the instant the ship turns. Confirmed a NEW sprite source works alongside the existing 3D-model bake (tools/ship_preview SHIP_VARIANT=bake): an AI image generator prompted explicitly for “flat top-down orthographic, nose-up, symmetric, no perspective, isolated on plain background” (matching the exact spec above) produces a usable hull after processing — isolate alpha via flood-fill FROM THE IMAGE CORNERS (not a global white-colour threshold, which would also wipe out enclosed bright details like an engine-glow stripe), corner-blank any generator watermark, resize to 512×512. Always verify a new candidate through tools/ship_preview SHIP_VARIANT=current (windowed, the REAL PlayerRenderer, all 5 level tiers) before committing — only that shows it with the halo/thruster/accent-orb overlay at true in-game scale. (2026-07-01, aurum/cobalt/prism.)
  • enemies.xp_val (per-enemy column, set at spawn from bible xp_value) is VESTIGIAL — never read. Actual kill XP is 100% biomass-driven via Sim._xp_worth()/enemies.biomass. A feature that reads/writes xp_val expecting it to affect reward is a no-op. (2026-07-01.)
  • GUT’s -gtest=res://tests/<file>.gd did NOT isolate a single file in this project’s run (still executed the full ~172-script suite). Don’t rely on it to speed up iteration — the full -gdir run is only ~4s anyway. (2026-07-01.)
  • A windowed preview harness that fits a whole large /sim space (e.g. the 4000×4000 arena) into a small window can render sparse/small elements as functionally invisible. tools/bg_preview/ (galaxy backdrop verification) first used a Camera2D.zoom chosen to fit the whole arena in a 900px window — individual 1-3 world-unit star radii shrank below a screen pixel, so the “proper looking galaxy” the generation code drew was there but unreadable. Fix: pick a preview zoom that favors legible individual-element size over fitting the whole space in frame (a tighter crop is fine — the goal is judging the LOOK, not framing everything at once), and for any sparse-particle/star-based look, consider adding a soft additive glow anchor (reusing GlowTexture, the pattern render/arena_background.gd’s nebula variant already uses) so it reads as “there’s something bright here” even before individual points are legible. (2026-07-02.)
  • A new additive/glow child node on ArenaBackground (or any render node with a set_low(v) adaptive-quality toggle) MUST be wired into that toggle, the same way existing glow children are, or it silently ignores the adaptive-quality system. QualityManager calls set_low(true) at low quality tiers specifically to shed additive full-quad overdraw on weaker hardware (e.g. the Apple TV) — a new glow sprite not covered by the SAME meta-tag check set_low already loops over (e.g. c.has_meta("nebula")) stays visible and costs overdraw at every quality tier regardless. This was only caught by a reviewer manually checking whether the new tag appeared in set_low’s loop, not by a failing test — add a regression test asserting set_low(true) hides the new node. (2026-07-02, VARIANT_GALAXY’s core glow sprite.)
  • Generalizing a shared pure function (e.g. AreaDefs.other(), a 2-way toggle → N-way cycle) can silently break a test in a DIFFERENT file than the one you’re editing. Only running the targeted test file for the function’s own tests (tests/test_areas.gd) missed that tests/test_wormhole.gd also encoded the old 2-way assumption — caught later by a full-suite run. Before considering a shared-function change done, grep -rl "<function_name>(" tests/ for other callers, and always run the FULL suite at least once, not just the file you wrote tests in. (2026-07-02.)
  • Godot’s RPC system requires the CALLING node’s own script to locally declare (with a matching @rpc annotation) any method it invokes via rpc_id(), even though that method never actually runs thererpc_id() looks up the RPC config on the LOCAL script to encode the outbound call, and fails with “Unable to get the RPC configuration for the function” if the method doesn’t exist locally, even when it’s correctly declared on the RECEIVING peer’s script. Confirmed via godotengine/godot#57869. Any two-sided RPC pair (host calls a method only the client implements, and vice versa) needs a “mirror stub” — the real implementation on the receiver, plus a same-name/same-annotation no-op stub on the caller (see net/mp_host.gd/net/mp_client.gd’s receive_snapshot/receive_input pairs for the pattern). This is invisible to any test that never opens a real ENet connection. (2026-07-02, M-B multiplayer.)
  • A real ENet unreliable packet that exceeds the MTU (1392 bytes default) risks losing its ENTIRE payload on a real network, not just “some degradation” — IP fragmentation reassembly requires every fragment to arrive; losing even one silently discards the whole datagram, and "unreliable_ordered" never retries. A LOOPBACK/localhost test does NOT surface this (no real fragmentation loss on 127.0.0.1), so a headless dual-instance smoke test on one Mac can pass cleanly while the same code fails completely on real WiFi between two physical devices. Confirmed live: a client that joined successfully still saw zero enemies (an oversized snapshot never survived), while the player’s own locally-predicted ship moved fine (no network needed). Fix for an oversized payload that must arrive intact: switch that RPC to "reliable" (ENet handles fragmentation/retransmission properly) rather than "unreliable_ordered". Any new large periodic broadcast should be checked against the MTU before assuming loopback testing proves it works over real WiFi. (2026-07-02, M-B multiplayer.)
  • iOS/tvOS 14+ require NSLocalNetworkUsageDescription in Info.plist for ANY local-network socket traffic — including raw UDP/ENet, not just Bonjour/mDNS discovery. Without it, local-network access is silently blocked (no error surfaced to the app) rather than prompting the user, which looks exactly like “the code is broken” rather than “the OS blocked it.” Godot’s export templates do NOT add this key automatically. Fix: PlistBuddy -c "Add :NSLocalNetworkUsageDescription string '...'" on the Xcode project’s SOURCE Info.plist template (build/BulletHeaven/BulletHeaven-Info.plist for tvOS, build-ios/BulletHeaven/BulletHeaven-Info.plist for iOS — NOT the DerivedData build output, which gets regenerated) — this persists across --export-pack re-exports exactly like the existing signing-identity pbxproj fix, since --export-pack only touches the .pck, never the Xcode project files. (2026-07-02, M-B multiplayer.)
  • ANY new custom UI overlay on tvOS MUST use this project’s explicit debounced-nav pattern (dpad via MenuNav.is_down/is_up + JOY_BUTTON_A confirm, _buttons/_sel/_focus_sel/_move(), see ui/pause_menu.gd) — plain default-focus Godot Buttons do NOT reliably respond to the Siri Remote. Confirmed as a real, live bug: a new lobby screen built with ordinary Buttons (relying on default Godot Control focus/click) silently did nothing when pressed on a real Apple TV, even though grab_focus() was called on the initial button. Every other tvOS-safe menu in this project (PauseMenu, LevelUpPanel, StartMenu) already reimplements this explicit pattern — that consistency is load-bearing, not a style preference. When adding a new menu/overlay, copy the pattern from the start; don’t assume default Button behavior works on tvOS just because it works in the editor/on Mac/on touch. (2026-07-02, ui/mp_lobby.gd.)
  • A custom _input() override doing global keypress handling (e.g. Enter-key confirm for a menu) runs BEFORE Godot’s own Control gui-input dispatch, so it can silently intercept a keypress meant for a focused LineEdit before the LineEdit’s own text_submitted mechanism ever sees it. Confirmed live: a screen with both a custom debounced-nav _input() (Enter confirms _buttons[_sel]) and a LineEdit for text entry — pressing Return on the on-screen keyboard while typing into the LineEdit always confirmed the currently-selected MENU button (which defaults to whatever _sel was, since tapping into a LineEdit via touch never moves it), not the LineEdit’s own submit action; the keyboard also had no way to dismiss. Fix: in the custom _input(), check line_edit.has_focus() for the Enter-key case specifically and handle it as “submit this field” (call the intended action directly + line_edit.release_focus() to dismiss the keyboard) BEFORE falling through to the generic menu-confirm logic. (2026-07-02, ui/mp_lobby.gd.)
  • EnterWorktree’s default base can be badly stale in this repo — always verify before building on it. Chris works almost entirely locally and rarely pushes (local main routinely 40-90+ commits ahead of origin/main), but EnterWorktree’s default fresh mode branches from origin/<default-branch> — so a fresh worktree can silently start from a base that’s missing dozens of recent local commits (hit 3 times in one session: the shop-carousel-hologram, debug-settings-panel, and hud-elegance-pass worktrees all branched stale). Symptom: files/commits you just made on main (e.g. a plan doc written minutes earlier) are simply missing in the new worktree. Fix: immediately after EnterWorktree, run git log <new-branch> --not main --oneline — if empty (no unique commits), it’s safe to git reset --hard main to bring it current. Always re-verify with godot --headless --path . --import + the test-count guard after any reset, since a fresh worktree’s .godot/ cache needs rebuilding regardless. (2026-07-03.)
  • A subagent’s Edit/Write/Bash call can silently target the WRONG absolute path (the main repo checkout instead of the worktree it was explicitly told to use, or vice versa) even when given the exact correct path in its prompt — happened at least twice in one session (an implementer’s commit landed on main instead of its worktree branch; a controller-level plan-doc edit did the same). It’s usually invisible until you explicitly diff git log <worktree-branch> --oneline against main’s own log after every dispatch. Cheap mitigation that measurably helped: tell every subagent to run pwd && git rev-parse --show-toplevel as its literal first action AND again immediately before its final commit, and independently re-verify (git log --oneline -3 in both the worktree and main) after every dispatch before generating a review package — don’t just trust a reported commit SHA. (2026-07-03.) This isn’t subagent-exclusive — the main session hit the identical failure directly, TWICE in one later session, editing files on main’s checkout instead of the active worktree’s; see memory bullet-heaven-worktree-tooling-gotchas for the full writeup + the broadened mitigation (2026-07-04).
  • Control.set_anchors_preset(FULL_RECT) called on self from INSIDE _ready() (i.e. after the node is already parented, still at its default (0,0) size) silently collapses the node to a permanent zero-size rect. The preset’s default resize_mode (PRESET_MODE_MINSIZE) “preserves” whatever rect the Control CURRENTLY has by computing offsets relative to the parent’s size at that exact moment — with a fresh (0,0) size and a real parent, that bakes in NEGATIVE offsets (offset_right/offset_bottom = -parent_size), permanently pinning the final rect to zero forever (offsets never get recomputed later). Everything nested inside inherits the collapse. The fix: call set_anchors_preset from _init() instead — before the node has ANY parent — with no parent to “preserve a rect” against, Godot lands on zero offsets (a true, dynamically-filling full rect), matching how every OTHER full-rect dim/backdrop in this codebase already does it (creating the Control, setting its anchors, THEN calling add_child on it — see dialogue_box.gd/level_up_panel.gd/ship_config_panel.gd). Confirmed as a real, live bug: NeonBackdrop (the shop panel’s dimming backdrop) rendered at a genuinely zero-size rect in production — Chris saw the live battlefield at full brightness behind the shop UI, because the “opaque” backdrop meant to hide it was silently 0×0. Caught via a temp debug harness printing get_rect() (not visible from a screenshot alone, since a collapsed Control just renders nothing rather than erroring). New regression tests assert the resolved offset_left/top/right/bottom are all 0.0, not just structural things like child count. (2026-07-04, ui/neon_backdrop.gd.)
  • A per-frame “authority” that unconditionally reasserts a Control’s .visible will silently override an explicit one-shot toggle set by other code — this has now recurred 3 times. Pattern: some main._process()-driven toggle (e.g. _toggle_tactical_hud()) sets some_node.visible = false once, but a DIFFERENT function ALSO runs every frame and does some_node.visible = <some condition that's basically always true> — stomping the toggle back to visible on the very next frame, with no error, no test failure (since most tests check the toggle function’s IMMEDIATE effect, not “does it survive the next update() call”). Confirmed instances: marketing/capture/shoot.gd’s own comment about auto_label.visible=_auto fighting a hide attempt; DroneDock.update_dock(sim) doing visible = total > 0 every frame (total is essentially always > 0) and stomping main._toggle_tactical_hud()’s hide (2026-07-04) — the sibling WeaponPanel never had this bug because its own update_panel() never touches .visible at all, leaving toggle ownership exclusively with main.gd. Rule for any new per-frame update()-style method: if some OTHER code owns turning a node visible/invisible via an explicit toggle, the update() method must only ever force it OFF (a genuine “there’s nothing to show” case), never force it back ON — turning it on stays the toggle’s job alone. Add a regression test asserting update() doesn’t override an already-hidden state.
  • A custom InputMap action registered lazily (only when some later system first spins up) creates a dead window where only Godot’s BUILT-IN actions work — and the failure looks like “nothing happens,” not an obvious bug. InputRouter.ensure_actions() (binds WASD to move_*/aim_*) only ran inside _new_run()’s InputRouter.new() constructor, so at the COLD-BOOT start menu — before any run has ever started this session — WASD did nothing; arrow keys/D-pad worked fine (Godot’s built-in ui_* actions don’t need registering). MenuNav.is_down/up/left/right’s event.is_action_pressed("move_down") on a nonexistent action just logs ERROR: The InputMap action "move_down" doesn't exist and returns false — no crash, no obvious symptom besides silently-dead input. Fix: call InputRouter.new().ensure_actions() unconditionally as the very first line of main.gd’s top-level _ready(), before the headless/start-menu branch — it’s pure InputMap registration with no tree/scene dependency, safe before any node is even added. Any new custom action bound outside project.godot’s [input] section should register at boot, not lazily on first use. (2026-07-04.)
  • A growing button list inside a bare CenterContainer (no ScrollContainer) silently runs off the edges of a fixed 1280×720 TV viewport once it’s tall enough — CenterContainer has no clipping/scrolling, it just centers the overflow, hiding whatever doesn’t fit. ui/debug_settings_panel.gd’s row list grew to 13 buttons + title/status (~832px of content) against the 720px viewport, silently making the bottom rows unreachable — no error, no warning. Fix: wrap the row VBoxContainer in a ScrollContainer with follow_focus = true (auto-scrolls to keep whichever row is currently focused on screen — essential since there’s no mouse on a 10-foot/d-pad UI to drag a scrollbar) plus TV-safe top/bottom margins (offset_top/offset_bottom, since overscan eats further into the raw edges). Any menu/panel whose row count can grow over time — dev panels especially, they tend to accrete rows — should use this pattern from the start instead of a bare CenterContainer. (2026-07-04.)