Bullet Heaven — Godot 4.6 gotchas
Bullet Heaven — Godot 4.6 gotchas
Section titled “Bullet Heaven — Godot 4.6 gotchas”Extracted from
CLAUDE.mdon 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. SeeCLAUDE.md§ “Subsystem architecture — read on demand”.
Godot 4.6 gotchas learned the hard way
Section titled “Godot 4.6 gotchas learned the hard way”- Headless cannot read back
MultiMeshper-instance transforms/colors. Under--headlessthe dummy RenderingServer returns(0,0)/black forget_instance_transform_2d/get_instance_coloreven thoughinstance_countis correct. So MultiMesh render tests can only assertinstance_count(+ resync behavior) headlessly; pixel placement is verified by playtest. (Probe to confirm: aSceneTreescript that sets then gets an instance transform.) run/main_scenepointing at a not-yet-existent scene makesgodot --importHARD-FAIL (Cannot open file 'res://main.tscn'), which leaves the global class cache stale so newly-addedclass_namescripts aren’t registered — and GUT then silently drops their tests from a-gdirscan. A green suite can be running fewer tests than you think. Always verify the test COUNT, not just “all passed.” Keepmain.tscnpresent (or don’t reference a missing main_scene)..godot/is gitignored, so CI rebuilds the cache from scratch — a CI--importstep that fails on error + an expected-test-count assert would guard this.SwarmRenderer.configure(color)setsmodulateANDsync()sets per-instance color → passing the same color to both renders it squared (darker). Configure withColor.WHITE, let per-instancesynccolor 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 bytests/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 inproject.godot. Godot serializes key events asObject(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_actiontrue butaction_get_events().size()==0), soInput.get_vectorsilently returns zero and nothing responds — symptom: “can’t move,” no error. Register input actions in CODE instead:InputRouter.ensure_actions()bindsphysical_keycodeWASD viaInputMap.add_action/action_add_event(layout-independent, idempotent, unit-testable). Probe to diagnose any input issue: aSceneTreescript printingInputMap.action_get_events(a).size(). - GUT 9.6 FAILS any test in which an un-asserted
push_errorfires (addons/gut/error_tracker.gd, defaulttreat_push_error_as = FAILURE;gut.gdshould_test_fail_from_errors). So a function thatpush_errors on bad input (e.g.ContentLoader.load_from_dicton 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 againstContentLoader.validate(raw)’s returned problem array, which does NOT push_error; (b) when you must exercise the erroring path, consume the expected error withassert_push_error("substr")/assert_push_error_count(n)(case-insensitive; marks ithandledso it no longer fails the test). Keep the productionpush_error(it’s the boot-time fail-loud); just consume it in the one test that triggers it. Corollary: pure/simhelpers that “should never” hit a branch (e.g.StatEffects.applyon an unknown effect) take a silent no-op rather thanpush_error, since the bad path is upstream-guarded by validation and apush_errorthere only trips tests. (OS.is_debug_build()is NOT an escape hatch —OSis 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). CanvasLayerhas nothemeproperty — it’s aNode, not aControl. Setting.themeon a CanvasLayer root silently does nothing. ApplyNeonTheme.get_theme()to the first childControlnode (e.g.center.theme = …on aCenterContainer), 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 whenais a Dictionary (values are Variants). Writevar 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 acrossorwith a narrowed-event property → a SILENT-HANG class-compile failure.var ok := a or (event is InputEventX and event.pressed)— GDScript does NOT narroweventtoInputEventXinside anor, soevent.pressed/.button_indexread 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 anif event is InputEventX:block or annotatevar ok: bool =. Always boot-check forSCRIPT ERRORbefore trusting a build (note:timeoutis not on macOS PATH — it’sgtimeout— so atimeout-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 isassert_lte) is a Parse Error that silently DROPS the whole test file from a-gdirrun — the suite still reports “All tests passed” with one FEWER script. Thescripts/check-test-count.shguard catches it (ran fewer scripts thantest_*.gdfiles); always trust the COUNT, not just “passed”. GUT methods:assert_lte/assert_gte, notassert_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) needsMAX_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 nobible.jsonentry; skirmisher=7), so_enemy_type_colors.resize(names.size())thenarr[tid]=…indexes out of bounds for tid 7. Size toTYPE_SKIRMISHER + 1and 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-inreadysignal →Parse Error: Member "ready" redefined (original in native class 'Control'), which fails the WHOLE script compile →.new()returns null → a silent freeze (the boot-checkgrep "SCRIPT ERROR"catches it). Use another name (e.g.crackling). Same class as the:=-across-orsilent-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_statsis called every spawn and DOES move the checksum (it draws rng values per spawn, shifting entity positions). The task-10 re-pin movedstate_checksumfrom1267954985→2325839371;snapshot_string().hash()remained4152236597(aggregates unaffected by per-entity stat variation). - A render-side effect that DECAYS / restores state each frame MUST be advanced ABOVE the
_processearly-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.advancesat 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 afterif sim == null: return) and force-finish them ongame_over. The_physics_processsim-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 SLOTi— NOT a per-type LUT indexed bytype_id. (It readcolors[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_colorsall run throughElementPalette.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 id —
data/bestiary.json(player-facing name/desc/counter, separate file fromdata/bible.json) andEnemyPool.TYPE_NAMES(telemetry attribution) can diverge from the content id:EnemyPool.TYPE_ELITE(bible id"elite", behavior=dash) displays as “Charger”;EnemyPool.TYPE_BOSS2displays 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_NAMEStelemetry label are unchanged, onlybible.json/bestiary.jsonnamefields moved). When a request names an enemy by its in-game/display name, grepbestiary.json+EnemyPool.TYPE_NAMESfirst. (2026-07-01.) - A baked player-ship hull sprite (
render/ship_sprites/ship3d_*.png, shown byPlayerRendererwhenUSE_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.PlayerRendererjust rotates the wholeSprite2DviaNode2D.rotationto 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_previewSHIP_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 throughtools/ship_previewSHIP_VARIANT=current(windowed, the REALPlayerRenderer, 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 biblexp_value) is VESTIGIAL — never read. Actual kill XP is 100% biomass-driven viaSim._xp_worth()/enemies.biomass. A feature that reads/writesxp_valexpecting it to affect reward is a no-op. (2026-07-01.)- GUT’s
-gtest=res://tests/<file>.gddid 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-gdirrun is only ~4s anyway. (2026-07-01.) - A windowed preview harness that fits a whole large
/simspace (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 aCamera2D.zoomchosen 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 (reusingGlowTexture, the patternrender/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 aset_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.QualityManagercallsset_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 checkset_lowalready 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 inset_low’s loop, not by a failing test — add a regression test assertingset_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 thattests/test_wormhole.gdalso 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
@rpcannotation) any method it invokes viarpc_id(), even though that method never actually runs there —rpc_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 viagodotengine/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 (seenet/mp_host.gd/net/mp_client.gd’sreceive_snapshot/receive_inputpairs 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 on127.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
NSLocalNetworkUsageDescriptionin 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.plistfor tvOS,build-ios/BulletHeaven/BulletHeaven-Info.plistfor iOS — NOT the DerivedData build output, which gets regenerated) — this persists across--export-packre-exports exactly like the existing signing-identity pbxproj fix, since--export-packonly 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_Aconfirm,_buttons/_sel/_focus_sel/_move(), seeui/pause_menu.gd) — plain default-focus GodotButtons do NOT reliably respond to the Siri Remote. Confirmed as a real, live bug: a new lobby screen built with ordinaryButtons (relying on default Godot Control focus/click) silently did nothing when pressed on a real Apple TV, even thoughgrab_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 focusedLineEditbefore the LineEdit’s owntext_submittedmechanism ever sees it. Confirmed live: a screen with both a custom debounced-nav_input()(Enter confirms_buttons[_sel]) and aLineEditfor 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_selwas, 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(), checkline_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 (localmainroutinely 40-90+ commits ahead oforigin/main), butEnterWorktree’s defaultfreshmode branches fromorigin/<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 onmain(e.g. a plan doc written minutes earlier) are simply missing in the new worktree. Fix: immediately afterEnterWorktree, rungit log <new-branch> --not main --oneline— if empty (no unique commits), it’s safe togit reset --hard mainto bring it current. Always re-verify withgodot --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
maininstead of its worktree branch; a controller-level plan-doc edit did the same). It’s usually invisible until you explicitly diffgit log <worktree-branch> --onelineagainstmain’s own log after every dispatch. Cheap mitigation that measurably helped: tell every subagent to runpwd && git rev-parse --show-toplevelas its literal first action AND again immediately before its final commit, and independently re-verify (git log --oneline -3in 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 memorybullet-heaven-worktree-tooling-gotchasfor the full writeup + the broadened mitigation (2026-07-04). Control.set_anchors_preset(FULL_RECT)called onselffrom 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 defaultresize_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: callset_anchors_presetfrom_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 callingadd_childon it — seedialogue_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 printingget_rect()(not visible from a screenshot alone, since a collapsed Control just renders nothing rather than erroring). New regression tests assert the resolvedoffset_left/top/right/bottomare all0.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
.visiblewill silently override an explicit one-shot toggle set by other code — this has now recurred 3 times. Pattern: somemain._process()-driven toggle (e.g._toggle_tactical_hud()) setssome_node.visible = falseonce, but a DIFFERENT function ALSO runs every frame and doessome_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 nextupdate()call”). Confirmed instances:marketing/capture/shoot.gd’s own comment aboutauto_label.visible=_autofighting a hide attempt;DroneDock.update_dock(sim)doingvisible = total > 0every frame (totalis essentially always > 0) and stompingmain._toggle_tactical_hud()’s hide (2026-07-04) — the siblingWeaponPanelnever had this bug because its ownupdate_panel()never touches.visibleat all, leaving toggle ownership exclusively withmain.gd. Rule for any new per-frameupdate()-style method: if some OTHER code owns turning a node visible/invisible via an explicit toggle, theupdate()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 assertingupdate()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 tomove_*/aim_*) only ran inside_new_run()’sInputRouter.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-inui_*actions don’t need registering).MenuNav.is_down/up/left/right’sevent.is_action_pressed("move_down")on a nonexistent action just logsERROR: The InputMap action "move_down" doesn't existand returns false — no crash, no obvious symptom besides silently-dead input. Fix: callInputRouter.new().ensure_actions()unconditionally as the very first line ofmain.gd’s top-level_ready(), before the headless/start-menu branch — it’s pureInputMapregistration with no tree/scene dependency, safe before any node is even added. Any new custom action bound outsideproject.godot’s[input]section should register at boot, not lazily on first use. (2026-07-04.) - A growing button list inside a bare
CenterContainer(noScrollContainer) silently runs off the edges of a fixed 1280×720 TV viewport once it’s tall enough —CenterContainerhas 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 rowVBoxContainerin aScrollContainerwithfollow_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 bareCenterContainer. (2026-07-04.)