Skip to content

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.

  • /sim/* files (sim/area_defs.gd, sim/sim.gd) stay pure: extends RefCounted, no Node/Engine/File/JSON APIs.
  • render/arena_background.gd and tools/bg_preview/ are render-only — they may use Node/Engine APIs freely, but must never mutate /sim state.
  • Determinism baseline must stay byte-identical: snapshot_string().hash() = 2730172591, state_checksum() = 4075578713 (seed 1234, 600 ticks — read the literal assert_eq(...) in tests/test_determinism_checksum.gd before trusting this number, per CLAUDE.md’s standing warning that this note goes stale). All background/area work here is render-side or gated behind enter_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 in render/arena_background.gd / sim/area_defs.gd — hence the internal id ember_reach (display name “Nebula”) to avoid colliding with the existing unrelated VARIANT_NEBULA (soft glow clouds).
  • GUT assertion methods are assert_lte/assert_gte, not assert_le/assert_ge.
  • After adding any file with a new class_name or in a new directory, run godot --headless --path . --import before running tests (stale class cache otherwise silently drops tests).
  • areas_enabled/V01_LOCK_AREAS stay 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"}, and AreaDefs.other(id) now cycling home → aurora → ember_reach → home instead of a binary home↔aurora toggle. Task 2 consumes the string "ember_reach" as the value read from def["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 -gexit

Expected: 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 AreaDefs
extends 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 -gexit

Expected: 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
Terminal window
git add sim/area_defs.gd tests/test_areas.gd
git 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" (from AreaDefs.get_def(id)["background"], produced by Task 1) as the trigger for the new variant in set_variant_by_name.

  • Produces: ArenaBackground.VARIANT_GALAXY (int const, value 4), ArenaBackground.set_variant_by_name("ember_reach", seed) selecting it, ArenaBackground.set_variant(VARIANT_GALAXY, seed) populating _stars with 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 — existing VARIANT_STARFIELD entries 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 -gexit

Expected: 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:

  1. Add the new variant const and generation/palette consts near the top (after the existing VARIANT_AURORA line):
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 core
const GALAXY_OUTER_COLOR: Color := Color(0.85, 0.35, 0.05) # deep orange at the arm tips / scatter field
const GALAXY_ARMS := 3
const GALAXY_CORE_STARS := 300
const GALAXY_SCATTER_STARS := 130
const GALAXY_SPIRAL_TURNS := 1.6
  1. Update set_variant_by_name to 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)
  1. Fix the wrap-exemption in set_variant (currently only VARIANT_AURORA is exempted from the % VARIANT_COUNT wrap — VARIANT_GALAXY (4) would otherwise wrap to 4 % 3 == 1, i.e. silently become VARIANT_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()
  1. Add the _build_galaxy generator (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,
})
  1. Update _process to also twinkle the galaxy (add or _variant == VARIANT_GALAXY to 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
  1. Add the galaxy draw branch in _draw(), right after the existing VARIANT_AURORA branch and before the # Nebula renders via additive child sprites comment:
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 . --import
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_arena_background.gd -gexit

Expected: 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
Terminal window
git add render/arena_background.gd tests/test_arena_background.gd
git 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, specifically VARIANT_GALAXY and the pre-existing variants) via its public set_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 new ArenaBackground variant 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 . --import
BG_VARIANT=galaxy godot --path . res://tools/bg_preview/preview.tscn --rendering-method forward_plus

Expected: 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
Terminal window
git add tools/bg_preview/preview.tscn tools/bg_preview/preview.gd
git 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/.


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 -gexit
bash scripts/check-test-count.sh

Expected: 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.


Files:

  • Create: .claude/skills/bh-area-background/SKILL.md

Interfaces:

  • Consumes: the concrete pattern established in Tasks 1-4 (file paths, gotchas, the bg_preview harness 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.md

Expected: 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-background
description: 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-only
visuals). 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 FIRST
Pick 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 can
differ from the internal id (e.g. area id `ember_reach` displays as "Nebula" — chosen specifically
to avoid colliding with the unrelated existing `VARIANT_NEBULA`, which is soft glow clouds in the
Home 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 entry
Add an id const + a `_DEFS` entry: `{"name": <display name>, "difficulty_mult": <float>,
"reward_mult": <float>, "background": <internal background name string>}`. Pick mults between
existing 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 deterministic
wormhole-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 this
Custom `_draw()` painting (and MultiMesh per-instance colour) reads back as dummy/black values
under `--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, and
density before wiring it into `main.gd`. If `BG_VARIANT` doesn't have a matching case in the
harness'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 Step
1'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 is
render-side or gated behind `enter_area()`, which the determinism test never calls, so it must be a
no-op. If it changed, something touched `/sim` state outside `enter_area()` — investigate before
committing.
## 6. Commit + tvOS sync
One area/background addition = one or a few small commits (area data, variant code, preview tool if
new, skill updates). Sync to `~/Claude/bullet-heaven-tvos/` per `bh-dev-chunk` §7 only once the
feature is meant to ship to device — areas are gated off for v0.1 (`main.V01_LOCK_AREAS`), so most
area additions can land on `main` without an immediate tvOS sync; check with Chris if unsure.
## Not covered by this skill
Enemy 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:

Terminal window
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.


  • 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_reach internal 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 in ArenaBackground.set_variant_by_name (Task 2) and the BG_VARIANT=galaxy/"galaxy" key in the preview harness (Task 3) maps to ArenaBackground.VARIANT_GALAXY (Task 2) throughout.
  • The set_variant wrap-exemption bug (a new area-selected variant silently wrapping into the Home pool if not added to the v == VARIANT_AURORA check) 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.