Nebula (Ember Reach) Galaxy Area Background Implementation Plan
Nebula (Ember Reach) Galaxy Area Background Implementation Plan
Section titled “Nebula (Ember Reach) Galaxy Area Background Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a third explorable area (“Nebula”, internal id ember_reach) with a warm gold/amber/orange galaxy backdrop (spiral core + scattered star field), and package the process as a reusable local skill (bh-area-background) so future area backgrounds are faster to add correctly.
Architecture: sim/area_defs.gd gets a new area entry + a generalized (now 3-way) wormhole-destination cycle. render/arena_background.gd gets a new area-selected-only variant (VARIANT_GALAXY) generated from a render-side seeded RNG, following the exact pattern already used for VARIANT_AURORA. A new windowed preview harness (tools/bg_preview/) lets any future variant be checked visually before wiring it in, since headless Godot can’t read back custom _draw() painting. A new local skill documents the whole checklist.
Tech Stack: Godot 4.6.3 / GDScript, GUT 9.6.0 for tests.
Global Constraints
Section titled “Global Constraints”/sim/*files (sim/area_defs.gd,sim/sim.gd) stay pure:extends RefCounted, no Node/Engine/File/JSON APIs.render/arena_background.gdandtools/bg_preview/are render-only — they may use Node/Engine APIs freely, but must never mutate/simstate.- Determinism baseline must stay byte-identical:
snapshot_string().hash() = 2730172591,state_checksum() = 4075578713(seed 1234, 600 ticks — read the literalassert_eq(...)intests/test_determinism_checksum.gdbefore trusting this number, per CLAUDE.md’s standing warning that this note goes stale). All background/area work here is render-side or gated behindenter_area(), which the determinism test never calls, so the baseline must be unaffected — but re-verify, don’t assume. - New internal names must not collide with existing
VARIANT_*constants or area ids inrender/arena_background.gd/sim/area_defs.gd— hence the internal idember_reach(display name “Nebula”) to avoid colliding with the existing unrelatedVARIANT_NEBULA(soft glow clouds). - GUT assertion methods are
assert_lte/assert_gte, notassert_le/assert_ge. - After adding any file with a new
class_nameor in a new directory, rungodot --headless --path . --importbefore running tests (stale class cache otherwise silently drops tests). areas_enabled/V01_LOCK_AREASstay untouched — this plan does not unlock area exploration for players.
Task 1: AreaDefs — add the Ember Reach (“Nebula”) area + generalize the wormhole cycle
Section titled “Task 1: AreaDefs — add the Ember Reach (“Nebula”) area + generalize the wormhole cycle”Files:
- Modify:
sim/area_defs.gd - Modify:
tests/test_areas.gd
Interfaces:
-
Consumes: nothing new (pure data).
-
Produces:
AreaDefs.EMBER_REACH == "ember_reach"(a valid area id),AreaDefs.get_def("ember_reach")returning{"name": "Nebula", "difficulty_mult": 1.3, "reward_mult": 1.25, "background": "ember_reach"}, andAreaDefs.other(id)now cyclinghome → aurora → ember_reach → homeinstead of a binary home↔aurora toggle. Task 2 consumes the string"ember_reach"as the value read fromdef["background"]. -
Step 1: Write the failing tests
Open tests/test_areas.gd. Replace the existing test_other_area_is_two_way test with a 3-way cycle test, and add a test for the new area’s mults:
func test_other_area_cycles_through_areas() -> void: assert_eq(AreaDefs.other("home"), "aurora") assert_eq(AreaDefs.other("aurora"), "ember_reach") assert_eq(AreaDefs.other("ember_reach"), "home")
func test_enter_area_ember_reach_sets_mults() -> void: var s := _sim() s.enter_area("ember_reach") assert_eq(s.current_area, "ember_reach") assert_gt(s.area_difficulty_mult, 1.0, "Ember Reach is harder than Home") assert_lt(s.area_difficulty_mult, 1.6, "Ember Reach is easier than Aurora") assert_gt(s.area_reward_mult, 1.0, "Ember Reach rewards more than Home") assert_lt(s.area_reward_mult, 1.5, "Ember Reach rewards less than Aurora")Leave every other test in the file untouched (test_defaults_to_home, test_enter_area_sets_mults, test_enter_area_clears_the_field, test_enter_area_keeps_the_player_run, test_enter_area_rearms_spawn_gates, test_wormhole_spawns_when_areas_enabled, test_wormhole_gated_off_for_v01).
- Step 2: Run the tests to verify they fail
Run:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_areas.gd -gexitExpected: test_other_area_cycles_through_areas FAILs (old other() still returns "home" for other("aurora"), not "ember_reach"); test_enter_area_ember_reach_sets_mults FAILs (get_def("ember_reach") falls back to Home’s 1.0/1.0 mults, so assert_gt fails).
- Step 3: Implement
Replace the full contents of sim/area_defs.gd:
class_name AreaDefsextends RefCounted
# Light, pure area table for explorable areas. v1 has three areas; an area is a difficulty + reward +# backdrop applied to the SAME enemy roster (no bespoke enemies yet). Adding an area = a table entry.# Lives in /sim (pure data — no Node/Engine/File APIs); the `background` is a name string the render# side maps to an ArenaBackground variant (so /sim never touches rendering).const HOME := "home"const AURORA := "aurora"const EMBER_REACH := "ember_reach" # display name "Nebula" — id kept distinct from the unrelated # ArenaBackground VARIANT_NEBULA (soft glow clouds, Home pool)
const _DEFS := { "home": {"name": "Home", "difficulty_mult": 1.0, "reward_mult": 1.0, "background": "home"}, "aurora": {"name": "Aurora", "difficulty_mult": 1.6, "reward_mult": 1.5, "background": "aurora"}, "ember_reach": {"name": "Nebula", "difficulty_mult": 1.3, "reward_mult": 1.25, "background": "ember_reach"},}
# Wormhole-destination order. Extend this list (and _DEFS above) when a 4th+ area lands.const _CYCLE := [HOME, AURORA, EMBER_REACH]
static func get_def(id: String) -> Dictionary: return _DEFS.get(id, _DEFS["home"])
# Deterministic wormhole-destination cycle: Home -> Aurora -> Ember Reach -> Home -> ...# An unrecognized id falls back to Home's successor (Aurora).static func other(id: String) -> String: var idx := _CYCLE.find(id) if idx == -1: idx = 0 return _CYCLE[(idx + 1) % _CYCLE.size()]- Step 4: Run the tests to verify they pass
Run:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_areas.gd -gexitExpected: all tests in the file PASS (7 tests: the 5 unchanged + test_other_area_cycles_through_areas + test_enter_area_ember_reach_sets_mults).
- Step 5: Commit
git add sim/area_defs.gd tests/test_areas.gdgit commit -m "feat(areas): add Ember Reach (\"Nebula\") area, generalize wormhole cycle to 3 areas
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"Task 2: ArenaBackground — galaxy variant (spiral core + scattered field, warm palette)
Section titled “Task 2: ArenaBackground — galaxy variant (spiral core + scattered field, warm palette)”Files:
- Modify:
render/arena_background.gd - Modify:
tests/test_arena_background.gd
Interfaces:
-
Consumes: the string
"ember_reach"(fromAreaDefs.get_def(id)["background"], produced by Task 1) as the trigger for the new variant inset_variant_by_name. -
Produces:
ArenaBackground.VARIANT_GALAXY(int const, value4),ArenaBackground.set_variant_by_name("ember_reach", seed)selecting it,ArenaBackground.set_variant(VARIANT_GALAXY, seed)populating_starswith a spiral-core + scattered-field star set (each entry gains a new"band"float field,0.0= core color,1.0= outer/scatter color, used only by the galaxy draw branch — existingVARIANT_STARFIELDentries are unaffected since they never read"band"). -
Step 1: Write the failing tests
Open tests/test_arena_background.gd. Add these tests at the end of the file:
func test_galaxy_generates_core_and_scatter_stars() -> void: var b := _bg() b.set_variant(ArenaBackground.VARIANT_GALAXY, 1) assert_gt(b._stars.size(), 200, "galaxy variant generates a dense spiral core + scattered field")
func test_galaxy_is_not_wrapped_into_home_pool() -> void: var b := _bg() b.set_variant(ArenaBackground.VARIANT_GALAXY, 1) assert_eq(b._variant, ArenaBackground.VARIANT_GALAXY, "galaxy is area-selected, not wrapped into the random Home pool")
func test_set_variant_by_name_ember_reach_maps_to_galaxy() -> void: var b := _bg() b.set_variant_by_name("ember_reach", 1) assert_eq(b._variant, ArenaBackground.VARIANT_GALAXY)
func test_set_variant_by_name_aurora_still_maps_to_aurora() -> void: var b := _bg() b.set_variant_by_name("aurora", 1) assert_eq(b._variant, ArenaBackground.VARIANT_AURORA)- Step 2: Run the tests to verify they fail
Run:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_arena_background.gd -gexitExpected: all 4 new tests FAIL (VARIANT_GALAXY doesn’t exist yet → parse error naming it — expect the whole file to fail to compile/run until the const exists; this is expected at this stage).
- Step 3: Implement
In render/arena_background.gd:
- Add the new variant const and generation/palette consts near the top (after the existing
VARIANT_AURORAline):
const VARIANT_GALAXY := 4 # area-selected only (NOT in the random pool) — Ember Reach's ("Nebula") backdrop
const GALAXY_CORE_COLOR: Color := Color(1.0, 0.95, 0.75) # near white-gold at the spiral coreconst GALAXY_OUTER_COLOR: Color := Color(0.85, 0.35, 0.05) # deep orange at the arm tips / scatter fieldconst GALAXY_ARMS := 3const GALAXY_CORE_STARS := 300const GALAXY_SCATTER_STARS := 130const GALAXY_SPIRAL_TURNS := 1.6- Update
set_variant_by_nameto branch on"ember_reach"before falling into the random pool:
func set_variant_by_name(area_bg: String, seed_val: int = 0) -> void: if area_bg == "aurora": set_variant(VARIANT_AURORA, seed_val) elif area_bg == "ember_reach": set_variant(VARIANT_GALAXY, seed_val) else: var rng := RandomNumberGenerator.new() rng.seed = seed_val set_variant(rng.randi() % VARIANT_COUNT, seed_val)- Fix the wrap-exemption in
set_variant(currently onlyVARIANT_AURORAis exempted from the% VARIANT_COUNTwrap —VARIANT_GALAXY(4) would otherwise wrap to4 % 3 == 1, i.e. silently becomeVARIANT_STARFIELD) and add the galaxy generation call:
func set_variant(v: int, seed_val: int = 0) -> void: # Aurora and Galaxy are area-selected and must not wrap into the Home pool; the rest wrap to 0..2. var is_area_selected := v == VARIANT_AURORA or v == VARIANT_GALAXY _variant = v if is_area_selected else ((v % VARIANT_COUNT) + VARIANT_COUNT) % VARIANT_COUNT _clear_nebula() _stars.clear() var rng := RandomNumberGenerator.new() rng.seed = seed_val var h := Sim_Const.ARENA_HALF if _variant == VARIANT_STARFIELD: for _i in range(340): _stars.append({ "p": Vector2(rng.randf_range(-h, h), rng.randf_range(-h, h)), "r": rng.randf_range(1.0, 2.8), "a": rng.randf_range(0.25, 0.9), "phase": rng.randf_range(0.0, TAU), "tw": rng.randf_range(1.5, 4.0), }) elif _variant == VARIANT_NEBULA: _build_nebula(rng, h) elif _variant == VARIANT_GALAXY: _build_galaxy(rng, h) queue_redraw()- Add the
_build_galaxygenerator (place it near_build_nebula):
# Ember Reach ("Nebula") backdrop: a dense logarithmic-spiral star core (GALAXY_ARMS arms winding# GALAXY_SPIRAL_TURNS turns outward) plus a sparser uniform scatter filling the rest of the arena.# Each star's "band" (0=core, 1=outer/scatter) drives the warm gold->orange colour lerp in _draw().func _build_galaxy(rng: RandomNumberGenerator, h: float) -> void: var per_arm := GALAXY_CORE_STARS / GALAXY_ARMS for arm in range(GALAXY_ARMS): var arm_offset := float(arm) * TAU / float(GALAXY_ARMS) for i in range(per_arm): var t := float(i) / float(per_arm) # 0..1 outward along the arm var angle := arm_offset + t * GALAXY_SPIRAL_TURNS * TAU + rng.randf_range(-0.22, 0.22) var radius := 60.0 + t * (h * 0.5) + rng.randf_range(-40.0, 40.0) * (t + 0.15) var pos := Vector2(cos(angle), sin(angle)) * radius _stars.append({ "p": pos, "r": rng.randf_range(1.0, 3.0), "a": rng.randf_range(0.35, 0.95), "phase": rng.randf_range(0.0, TAU), "tw": rng.randf_range(1.5, 4.0), "band": clampf(t, 0.0, 1.0), }) for _i in range(GALAXY_SCATTER_STARS): _stars.append({ "p": Vector2(rng.randf_range(-h, h), rng.randf_range(-h, h)), "r": rng.randf_range(1.0, 2.2), "a": rng.randf_range(0.15, 0.5), "phase": rng.randf_range(0.0, TAU), "tw": rng.randf_range(1.5, 4.0), "band": 1.0, })- Update
_processto also twinkle the galaxy (addor _variant == VARIANT_GALAXYto the existing condition):
func _process(dt: float) -> void: if _low: return _t += dt if _variant == VARIANT_STARFIELD or _variant == VARIANT_GRID or _variant == VARIANT_AURORA or _variant == VARIANT_GALAXY: queue_redraw() # twinkle the stars / breathe the grid / shimmer the aurora / galaxy elif _variant == VARIANT_NEBULA: var i := 0 for c in get_children(): if c.has_meta("nebula"): var b: float = 0.7 + 0.3 * sin(_t * 0.5 + float(i) * 1.3) c.modulate.a = float(c.get_meta("base_a", 0.16)) * b i += 1- Add the galaxy draw branch in
_draw(), right after the existingVARIANT_AURORAbranch and before the# Nebula renders via additive child spritescomment:
elif _variant == VARIANT_GALAXY and not _low: for st in _stars: var tw: float = 0.55 + 0.45 * sin(_t * float(st["tw"]) + float(st["phase"])) var band: float = float(st["band"]) var col: Color = GALAXY_CORE_COLOR.lerp(GALAXY_OUTER_COLOR, band) col.a = float(st["a"]) * tw draw_circle(st["p"], float(st["r"]), col)- Step 4: Run the tests to verify they pass
Run:
godot --headless --path . --importgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_arena_background.gd -gexitExpected: all tests in the file PASS, including the 4 new ones and the 3 pre-existing ones (test_starfield_generates_stars, test_nebula_adds_glow_children_grid_does_not, test_set_variant_wraps_out_of_range).
- Step 5: Commit
git add render/arena_background.gd tests/test_arena_background.gdgit commit -m "feat(render): add VARIANT_GALAXY spiral-core + scattered-field backdrop for Ember Reach
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"Task 3: tools/bg_preview — windowed background-preview harness + visual check
Section titled “Task 3: tools/bg_preview — windowed background-preview harness + visual check”Files:
- Create:
tools/bg_preview/preview.tscn - Create:
tools/bg_preview/preview.gd
Interfaces:
- Consumes:
ArenaBackground(from Task 2, specificallyVARIANT_GALAXYand the pre-existing variants) via its publicset_variant(v, seed_val)method. - Produces: a PNG at
tools/bg_preview/out/bg_<variant>.png, and (for future skill use) a repeatable windowed-verification command any newArenaBackgroundvariant can run through. Task 5’s skill references this harness by path + invocation.
This tool mirrors the existing tools/ship_preview/preview.gd pattern (env-var-driven, windowed-only, saves a PNG, quits) — headless Godot cannot read back custom _draw() painting, so any ArenaBackground variant must be checked this way, not with a headless test.
- Step 1: Create the scene file
Create tools/bg_preview/preview.tscn:
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tools/bg_preview/preview.gd" id="1"]
[node name="Preview" type="Node2D"]script = ExtResource("1")- Step 2: Create the harness script
Create tools/bg_preview/preview.gd:
extends Node2D
# Windowed background-preview harness — renders ArenaBackground alone (outside the game) so a new# variant's look (star placement, palette, animation) can be checked before wiring it into main.gd.# Headless Godot can't read back custom _draw() painting (only MultiMesh instance_count is readable# headless), so this MUST run windowed on a real GPU:# godot --path . res://tools/bg_preview/preview.tscn --rendering-method forward_plus# Variant via the BG_VARIANT env var: grid | starfield | nebula | aurora | galaxy (default galaxy).# Saves a PNG to tools/bg_preview/out/bg_<variant>.png and quits — open it (e.g. with the Read tool,# or in Finder) to eyeball the result.
const OUT_DIR := "res://tools/bg_preview/out"
func _ready() -> void: var variant := OS.get_environment("BG_VARIANT") if variant == "": variant = "galaxy" var variant_id: int = { "grid": ArenaBackground.VARIANT_GRID, "starfield": ArenaBackground.VARIANT_STARFIELD, "nebula": ArenaBackground.VARIANT_NEBULA, "aurora": ArenaBackground.VARIANT_AURORA, "galaxy": ArenaBackground.VARIANT_GALAXY, }.get(variant, ArenaBackground.VARIANT_GALAXY)
get_window().size = Vector2i(900, 900) var cam := Camera2D.new() # zoom < 1 shows MORE of the world (Godot 4 convention); the arena is 4000x4000 # (Sim_Const.ARENA_HALF * 2), so 0.2 comfortably fits it all in a 900px window. cam.zoom = Vector2(0.2, 0.2) add_child(cam) cam.make_current()
var bg := ArenaBackground.new() add_child(bg) bg.set_variant(variant_id, 7)
for _i in range(6): await get_tree().process_frame await RenderingServer.frame_post_draw
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUT_DIR)) var img := get_viewport().get_texture().get_image() var out_path := "%s/bg_%s.png" % [OUT_DIR, variant] img.save_png(out_path) print("SAVED %s (%dx%d)" % [ProjectSettings.globalize_path(out_path), img.get_width(), img.get_height()]) get_tree().quit()- Step 3: Import, then run the harness for the galaxy variant
Run:
godot --headless --path . --importBG_VARIANT=galaxy godot --path . res://tools/bg_preview/preview.tscn --rendering-method forward_plusExpected: a window opens briefly and closes; stdout prints SAVED .../tools/bg_preview/out/bg_galaxy.png (900x900).
- Step 4: Visually verify the output
Read the saved image file (tools/bg_preview/out/bg_galaxy.png) to confirm: a warm gold/amber/orange spiral galaxy core is visible near the center with 3 winding arms, a sparser scattered star field fills the rest of the frame, and the bright cyan arena border is visible at the edges. If the spiral doesn’t read clearly as a galaxy (e.g. arms too tight/loose, colors off), adjust GALAXY_SPIRAL_TURNS, the radius formula, or GALAXY_CORE_COLOR/GALAXY_OUTER_COLOR in render/arena_background.gd from Task 2, then re-run Step 3.
- Step 5: Commit
git add tools/bg_preview/preview.tscn tools/bg_preview/preview.gdgit commit -m "feat(tools): add bg_preview windowed harness for verifying ArenaBackground variants
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"Note: tools/bg_preview/out/*.png are generated artifacts (same treatment as tools/ship_preview/out/) — check whether tools/ship_preview/out/ is gitignored or committed (git check-ignore tools/ship_preview/out/ship_3d.png) and follow the same convention for tools/bg_preview/out/.
Task 4: Full verification gate
Section titled “Task 4: Full verification gate”Files: none (verification only).
Interfaces:
-
Consumes: everything from Tasks 1-3.
-
Produces: confirmation that the determinism baseline is unchanged and the full suite is green, gating Task 5.
-
Step 1: Boot-check for silent compile failures
Run:
godot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"Expected: empty output.
- Step 2: Full suite + test-count guard
Run:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitbash scripts/check-test-count.shExpected: exit 0, all tests pass, and the script count matches the number of tests/test_*.gd files (i.e. no file was silently dropped).
- Step 3: Confirm the determinism baseline is unchanged
Read the literal assertion in tests/test_determinism_checksum.gd (do not trust a prose number from CLAUDE.md or this plan) and confirm it still matches after Step 2’s full run — this feature is render-side (ArenaBackground) or gated behind enter_area() (never called by the determinism test), so the baseline must be byte-identical. If it changed, stop and investigate before proceeding — something touched /sim state outside enter_area.
- Step 4: No commit needed
This task only verifies prior commits; skip if all checks pass. If any check fails, fix the issue in the relevant task’s files, re-run that task’s tests, then re-run this task’s steps before continuing.
Task 5: bh-area-background skill
Section titled “Task 5: bh-area-background skill”Files:
- Create:
.claude/skills/bh-area-background/SKILL.md
Interfaces:
-
Consumes: the concrete pattern established in Tasks 1-4 (file paths, gotchas, the
bg_previewharness from Task 3). -
Produces: a local, gitignored, invocable skill (
/bh-area-background) documenting the checklist for future area+background additions. No code dependency — purely documentation. -
Step 1: Confirm the skill directory is gitignored
Run:
git check-ignore .claude/skills/bh-dev-chunk/SKILL.mdExpected: prints the path (confirms .claude/skills/ is gitignored, matching the existing local-skill convention this new skill will follow).
- Step 2: Write the skill file
Create .claude/skills/bh-area-background/SKILL.md:
---name: bh-area-backgrounddescription: Use when adding a new explorable area (and/or a new ArenaBackground visual variant) to the Bullet Heaven Godot game — the AreaDefs entry, the ArenaBackground variant + generation + draw code, windowed visual verification, and the full bh-dev-chunk gate that every area/background addition must follow.---
# Bullet Heaven — adding an area + its backdrop
Areas live in `sim/area_defs.gd` (pure `/sim` data) + `render/arena_background.gd` (render-onlyvisuals). Follow these steps in order; a new variant that isn't verified windowed can ship broken(headless can't read back custom `_draw()` painting) or silently collide with an existing name.
## 0. Naming gotcha — check FIRSTPick an internal variant id / area id that does NOT collide with any existing `VARIANT_*` const in`render/arena_background.gd` or any area id in `sim/area_defs.gd`. The in-game display name candiffer from the internal id (e.g. area id `ember_reach` displays as "Nebula" — chosen specificallyto avoid colliding with the unrelated existing `VARIANT_NEBULA`, which is soft glow clouds in theHome random pool). Grep both files for the name you're about to use before committing to it:grep -n “VARIANT_|const .* :=” render/arena_background.gd grep -n “const .* :=” sim/area_defs.gd
## 1. `sim/area_defs.gd` — the area entryAdd an id const + a `_DEFS` entry: `{"name": <display name>, "difficulty_mult": <float>,"reward_mult": <float>, "background": <internal background name string>}`. Pick mults betweenexisting areas unless told otherwise (Home is 1.0/1.0, the hardest existing area is the ceiling).If the area count changed, update `AreaDefs.other()`'s `_CYCLE` list too — it's the deterministicwormhole-destination order, pure data, no rng draw. Stays `extends RefCounted`, no engine APIs.Add tests to `tests/test_areas.gd` mirroring the existing `test_enter_area_sets_mults` /`test_other_area_cycles_through_areas` pattern.
## 2. `render/arena_background.gd` — the variant- Add a new `const VARIANT_<NAME> := <next int>`.- If it's area-selected-only (the norm — every area-specific look so far is), it must be EXCLUDED from the random Home-pool wrap in `set_variant()`. The wrap line checks `v == VARIANT_AURORA or v == VARIANT_GALAXY` (or similar) — a new area-selected variant that ISN'T added to that exemption list silently wraps into the Home pool via `% VARIANT_COUNT`. This bit us once already (see the plan for `VARIANT_GALAXY`, 2026-07-02) — always re-check this line.- Add a branch in `set_variant_by_name(area_bg, seed_val)` matching the area's `background` string.- Add generation logic in `set_variant()` (or a `_build_<name>` helper like `_build_nebula`/ `_build_galaxy`) seeded from the render-side `RandomNumberGenerator` passed in — NEVER `sim.rng` (background choice/appearance must never perturb the determinism baseline).- Add a `_draw()` branch for the new look. Reuse the existing per-star record shape (`p`, `r`, `a`, `phase`, `tw`, optionally `band` for a color gradient) and the twinkle formula (`0.55 + 0.45 * sin(_t * tw + phase)`) where the look is star-based — don't reinvent it.- If the look animates, add its variant to the `_process()` redraw condition.- Add tests to `tests/test_arena_background.gd` mirroring `test_galaxy_generates_core_and_scatter_stars` / `test_galaxy_is_not_wrapped_into_home_pool` / `test_set_variant_by_name_*`.
## 3. Verify visually — MANDATORY, headless cannot do thisCustom `_draw()` painting (and MultiMesh per-instance colour) reads back as dummy/black valuesunder `--headless`. Use the `tools/bg_preview/` harness:godot –headless –path . –import BG_VARIANT=<your_variant_name> godot –path . res://tools/bg_preview/preview.tscn –rendering-method forward_plus
Then read the saved `tools/bg_preview/out/bg_<variant>.png` to confirm the look, palette, anddensity before wiring it into `main.gd`. If `BG_VARIANT` doesn't have a matching case in theharness's variant map yet, add one — it's meant to grow with every new variant.
## 4. Wire into `main.gd` (usually a no-op)`main.gd` already calls `arena_bg.set_variant_by_name(String(AreaDefs.get_def(_warp_dest)["background"]), randi())`generically on area entry — a new area/variant pair typically needs NO `main.gd` change, since Step1's `background` string + Step 2's `set_variant_by_name` branch are all it reads.
## 5. Full verification gate (same as bh-dev-chunk)godot –headless –path . –quit-after 90 2>&1 | grep “SCRIPT ERROR” # must be empty godot –headless –path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit bash scripts/check-test-count.sh
Then confirm the determinism baseline (read the literal assertion in`tests/test_determinism_checksum.gd`, not a prose number) is unchanged — area/background work isrender-side or gated behind `enter_area()`, which the determinism test never calls, so it must be ano-op. If it changed, something touched `/sim` state outside `enter_area()` — investigate beforecommitting.
## 6. Commit + tvOS syncOne area/background addition = one or a few small commits (area data, variant code, preview tool ifnew, skill updates). Sync to `~/Claude/bullet-heaven-tvos/` per `bh-dev-chunk` §7 only once thefeature is meant to ship to device — areas are gated off for v0.1 (`main.V01_LOCK_AREAS`), so mostarea additions can land on `main` without an immediate tvOS sync; check with Chris if unsure.
## Not covered by this skillEnemy rosters, per-area difficulty/mechanic depth beyond the flat mult, or unlocking`V01_LOCK_AREAS` for players. This skill is scoped to "area identity + its backdrop" only.- Step 3: Confirm it’s local-only, no commit
Run:
git status --porcelain .claude/skills/bh-area-background/Expected: empty output — .claude/skills/ is gitignored (confirmed in Step 1), so the new file
never shows as untracked and there is nothing to commit. This matches how bh-dev-chunk/
bh-deploy already work: local-only, not part of the repo history, available to any session run
from this project directory.
Self-review notes
Section titled “Self-review notes”- Spec coverage: Part 1 (area + background) → Tasks 1-4. Part 2 (skill) → Task 5. Out-of-scope items (unlocking areas, new enemies, web demo) are untouched by every task above.
- Naming collision: resolved via
ember_reachinternal id / “Nebula” display name, called out in the Global Constraints and in the skill’s Step 0. - Type/signature consistency:
AreaDefs.EMBER_REACH/"ember_reach"(Task 1) matches the string branch added inArenaBackground.set_variant_by_name(Task 2) and theBG_VARIANT=galaxy/"galaxy"key in the preview harness (Task 3) maps toArenaBackground.VARIANT_GALAXY(Task 2) throughout. - The
set_variantwrap-exemption bug (a new area-selected variant silently wrapping into the Home pool if not added to thev == VARIANT_AURORAcheck) was caught during planning and fixed in Task 2 Step 3.3 — also documented as a named gotcha in the Task 5 skill so it isn’t rediscovered next time.