FunZo VFX Sandbox Implementation Plan
FunZo VFX Sandbox Implementation Plan
Section titled “FunZo VFX Sandbox 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: Build a two-tier sandbox (fast Mac preview + live on-device iPad showcase) that lets Chris try 8 distinct graphical treatments on the FunZo boss, to judge how impressive the game can look on iOS/tvOS and to build working knowledge of Godot 4.6’s 2D VFX toolset.
Architecture: Tier 1 (tools/funzo_lab/) boots a real, minimal Sim (no full game) and renders FunZo with the actual ArchetypeRenderer + FunZoRenderer pair, driven by a shared FunZoTreatments registry that attaches/detaches one visual treatment at a time. Tier 2 reuses the already-shipped Remote Playtest Console (net/control_client.gd + the CF Worker relay) so every treatment is switchable live, over the network, against a real FunZo fighting on Chris’s iPad — one install covers the whole gallery.
Tech Stack: Godot 4.6.3 / GDScript, GUT 9.6.0 for tests, the existing bullet-heaven-tvos repo’s iOS export preset for on-device verification.
Global Constraints
Section titled “Global Constraints”/simpurity: every file undersim/staysRefCounted, no Node/Engine/File/JSON APIs — the ONE exception in this plan isSim.dev_set_funzo_hp_frac(Task 1), which follows the exact allow-listed, default-inert shape of the existingdev_set_player_stat.- Determinism baseline must stay byte-identical:
snapshot_string().hash()=2730172591,state_checksum()=4075578713(read the literal assertions intests/test_determinism_checksum.gd— this exact number has gone stale in prose before, so verify against that file, not this plan). Every change in this plan is render-side or a dev-only inert seam, so the baseline is safe by construction, but re-run the determinism tests after Task 1 (the only task touchingsim/) to confirm. - Headless Godot cannot read back
_draw(),MultiMeshper-instance state,GPUParticles2D,Light2D, or shader output — every capture/visual-judgment step in this plan runs windowed, never--headless. GUT tests in this plan only assert node structure (children exist, positions match, types match), never pixels. - Full suite command:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit(exit 0 = pass). Always check the printed script count, not just “all passed” (the stale-class-cache trap silently drops newly-addedclass_namefiles from the run). - Test-count guard:
scripts/check-test-count.sh(fails loud if GUT ran fewer scripts than there aretest_*.gdfiles). - Boot smoke check:
godot --headless --path . --quit-after 60then grep stderr forSCRIPT ERROR. - New
class_namescripts need a class-cache refresh before their first headless test run:godot --headless --path . --import. - No new
.gdshaderresource files — every shader in this project is an inlineShader.codestring; this plan keeps that convention. - Godot 2D has no native particle attractor node (
GPUParticlesAttractorBox3D/Sphere3Dare 3D-only, confirmed against the 4.6 class docs) — Task 6 (Reality-tear debris) uses a hand-scripted pooled-sprite system instead of a native attractor, correcting the original spec’s wording.
Task 1: Sim.dev_set_funzo_hp_frac seam + Tier-1 scaffold (baseline treatment)
Section titled “Task 1: Sim.dev_set_funzo_hp_frac seam + Tier-1 scaffold (baseline treatment)”Files:
- Modify:
sim/sim.gd(adddev_set_funzo_hp_frac, near the existingdev_set_player_statat line ~1135) - Modify:
tests/test_dev_seams.gd(add two tests) - Create:
tools/funzo_lab/preview.tscn - Create:
tools/funzo_lab/preview.gd
Interfaces:
-
Produces:
Sim.dev_set_funzo_hp_frac(frac: float) -> void— every later task’sHP_FRACenv var and the Tier-2funzo_hp_fracdev command (Task 11) call this. -
Produces:
tools/funzo_lab/preview.gd’s env varsHP_FRAC,FF_TICKS,RUN_SECONDS— later tasks (2, 10) extend this same file with aTREATMENTenv var. -
Step 1: Write the failing tests for the new Sim seam
Add to tests/test_dev_seams.gd:
func test_dev_set_funzo_hp_frac_writes_enemy_hp() -> void: var sim := Sim.new(1234, SimContentFixture.db()) sim.funzo_director.spawn(sim, Vector2.ZERO) sim.dev_set_funzo_hp_frac(0.25) var fi := sim.boss_rotation.funzo_index(sim) assert_almost_eq(sim.enemies.data[fi], sim.funzo.max_hp * 0.25, 0.01)
func test_dev_set_funzo_hp_frac_noop_without_a_live_funzo() -> void: var sim := Sim.new(1234, SimContentFixture.db()) sim.dev_set_funzo_hp_frac(0.5) # no FunZo alive -- must not crash assert_true(true)- Step 2: Run the tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_dev_seams.gd -gexit
Expected: FAIL — dev_set_funzo_hp_frac does not exist on Sim.
- Step 3: Implement the seam
In sim/sim.gd, immediately after the existing dev_set_player_stat function:
# Dev-tool: force FunZo's HP fraction directly (0..1) so the FunZo VFX sandbox can scrub# its HP-driven escalation arc (body growth, dash-chain count, zone cadence, enrage) on# demand instead of waiting through a real fight. Silent no-op if FunZo isn't alive.func dev_set_funzo_hp_frac(frac: float) -> void: var fi := boss_rotation.funzo_index(self) if fi == -1: return enemies.data[fi] = clampf(frac, 0.0, 1.0) * funzo.max_hp- Step 4: Run the tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_dev_seams.gd -gexit
Expected: PASS (all tests in the file, including the two new ones).
- Step 5: Create the Tier-1 preview scene
Create tools/funzo_lab/preview.tscn:
[gd_scene load_steps=2 format=3 uid="uid://bfunzolab0001"]
[ext_resource type="Script" path="res://tools/funzo_lab/preview.gd" id="1_preview"]
[node name="Preview" type="Node2D"]script = ExtResource("1_preview")- Step 6: Write the Tier-1 preview harness
Create tools/funzo_lab/preview.gd:
extends Node2D
# Windowed FunZo VFX sandbox harness (Tier 1 — see# docs/superpowers/specs/2026-07-03-funzo-vfx-sandbox-design.md). Boots a REAL, minimal# Sim (no full game/main.gd) so FunZo's actual timing/escalation logic drives the preview,# spawns FunZo, and renders it with ArchetypeRenderer (base pooled body) + FunZoRenderer# (attack-pattern overlay) exactly as the real game does. Run WINDOWED (real GPU);# --headless can't read back shader/particle/_draw() output:# godot --path . res://tools/funzo_lab/preview.tscn --rendering-method mobile# --rendering-method mobile matches the actual iOS/tvOS Metal path (both are non-# gl_compatibility renderers and take the same HDR branch in main.gd's glow setup); the# Mac-default forward_plus works too for a quick look. Env vars:# HP_FRAC 0..1, forces FunZo's HP fraction once at spawn (default: unset = full HP)# FF_TICKS ticks (1/60s each) to fast-forward silently before rendering, so the# capture lands on a dramatic moment instead of idle drift (default 420,# ~7s -- just past the first telegraph/dash)# RUN_SECONDS real-time seconds to keep rendering after the fast-forward, before# capturing + quitting (default 3.0)# TREATMENT (added in Task 2) selects a visual treatment; until then only the baseline# (FunZo's shipped look) renders.
const OUT_DIR := "res://tools/funzo_lab/out"
var _sim: Simvar _archetype: ArchetypeRenderervar _funzo_renderer: FunZoRenderervar _run_seconds := 3.0var _elapsed := 0.0var _variant := "baseline"var _saving := false
func _ready() -> void: _setup_canvas_glow() _variant = OS.get_environment("TREATMENT") if _variant == "": _variant = "baseline" var rs_env := OS.get_environment("RUN_SECONDS") _run_seconds = float(rs_env) if rs_env != "" else 3.0
get_window().size = Vector2i(900, 900) var cam := Camera2D.new() cam.zoom = Vector2(1.3, 1.3) add_child(cam) cam.make_current()
var content := ContentLoader.load_from_path("res://data/bible.json") _sim = Sim.new(1234, content) _sim.player.dev_invuln = true # keep the preview running even if FunZo dashes into the player _sim.funzo_director.spawn(_sim, Vector2.ZERO)
var hp_env := OS.get_environment("HP_FRAC") if hp_env != "": _sim.dev_set_funzo_hp_frac(float(hp_env))
_archetype = ArchetypeRenderer.new() add_child(_archetype) _funzo_renderer = FunZoRenderer.new() _funzo_renderer.setup(_sim) add_child(_funzo_renderer)
var ff_env := OS.get_environment("FF_TICKS") var ff_ticks := int(ff_env) if ff_env != "" else 420 for _i in range(ff_ticks): _sim.funzo_director.update(_sim, 1.0 / 60.0) _sim.funzo_director.update_zones(_sim, 1.0 / 60.0)
func _process(delta: float) -> void: if _sim == null or _saving: return _sim.funzo_director.update(_sim, delta) _sim.funzo_director.update_zones(_sim, delta) var colors := PackedColorArray() for i in range(_sim.enemies.count): colors.append(ElementPalette.color_for(_sim.content, _sim.enemies.base_element[i])) _archetype.sync(_sim.enemies, colors, PackedInt32Array())
_elapsed += delta if _elapsed >= _run_seconds: _saving = true await _save()
func _setup_canvas_glow() -> void: RenderingServer.set_default_clear_color(Color(0.015, 0.02, 0.05)) var env := Environment.new() env.background_mode = Environment.BG_CANVAS env.glow_enabled = true env.glow_intensity = 1.15 env.glow_bloom = 0.15 env.glow_hdr_threshold = 1.0 env.glow_hdr_scale = 1.5 var we := WorldEnvironment.new() we.environment = env add_child(we) get_viewport().use_hdr_2d = true
func _save() -> void: for _i in range(10): await get_tree().process_frame await RenderingServer.frame_post_draw var img := get_viewport().get_texture().get_image() var abs_dir := ProjectSettings.globalize_path(OUT_DIR) DirAccess.make_dir_recursive_absolute(abs_dir) var path := "%s/funzo_%s.png" % [OUT_DIR, _variant] var err := img.save_png(path) print("FUNZO_LAB_SAVED err=%d %s" % [err, ProjectSettings.globalize_path(path)]) get_tree().quit()- Step 7: Refresh the class cache and boot-check
Run: godot --headless --path . --import
Run: godot --headless --path . --quit-after 60
Expected: no SCRIPT ERROR in stderr.
- Step 8: Run the baseline capture
Run: godot --path . res://tools/funzo_lab/preview.tscn --rendering-method forward_plus
Expected: a window opens showing FunZo drifting/telegraphing/dashing, then tools/funzo_lab/out/funzo_baseline.png is written and the process quits. Open the PNG (e.g. via the Read tool) to confirm it shows FunZo’s real look — this is the reference every treatment gets compared against.
- Step 9: Run the full suite + count guard + determinism check
Run: scripts/check-test-count.sh
Expected: exits 0, script count matches the number of test_*.gd files.
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
Expected: PASS (the dev_set_funzo_hp_frac addition is a new, unused-by-default method — the baseline never calls it).
- Step 10: Commit
git add sim/sim.gd tests/test_dev_seams.gd tools/funzo_lab/preview.tscn tools/funzo_lab/preview.gdgit commit -m "feat(funzo-lab): dev_set_funzo_hp_frac seam + Tier-1 baseline preview harness"Task 2: Treatment registry skeleton + wire TREATMENT into the preview
Section titled “Task 2: Treatment registry skeleton + wire TREATMENT into the preview”Files:
- Create:
render/funzo_treatments.gd - Create:
tests/test_funzo_treatments.gd - Modify:
tools/funzo_lab/preview.gd
Interfaces:
-
Consumes:
sim.funzo_director.render_info(sim)(fromsim/funzo.gd, already shipped). -
Produces:
FunZoTreatments.TREATMENT_BASELINE..TREATMENT_CARNIVAL_WARP(ints 0-7),FunZoTreatments.id_for_name(name: String) -> int,FunZoTreatments.apply(id: int, root: Node2D, sim) -> Node. Tasks 3-9 each add exactly onematcharm toapply()’s body — no other function signature changes. -
Treatment class contract (every class Tasks 3-9 create follows this):
func setup(sim) -> void(store the sim reference, called once byapply()right afteradd_child) andfunc refresh(delta: float) -> void(called every frame by the owner —preview.gd’s_processhere,main.gd’s_processin Task 11 — readssim.funzo_director.render_info(sim)and updates the node’s own visuals). -
Step 1: Write the failing test
Create tests/test_funzo_treatments.gd:
extends GutTest
func test_id_for_name_maps_known_names() -> void: assert_eq(FunZoTreatments.id_for_name("baseline"), FunZoTreatments.TREATMENT_BASELINE) assert_eq(FunZoTreatments.id_for_name("living_shadow"), FunZoTreatments.TREATMENT_LIVING_SHADOW) assert_eq(FunZoTreatments.id_for_name("carnival_warp"), FunZoTreatments.TREATMENT_CARNIVAL_WARP)
func test_id_for_name_falls_back_to_baseline_for_unknown_names() -> void: assert_eq(FunZoTreatments.id_for_name("not_a_real_treatment"), FunZoTreatments.TREATMENT_BASELINE)
func test_apply_baseline_attaches_no_extra_node() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) var root := Node2D.new() add_child_autofree(root) var node := FunZoTreatments.apply(FunZoTreatments.TREATMENT_BASELINE, root, sim) assert_null(node, "baseline is FunZo's shipped look -- no extra node")
func test_apply_unimplemented_id_falls_back_to_null() -> void: # Ids for treatments not yet built (Tasks 3-9 land these one at a time) must never # crash -- apply() falls back to null exactly like baseline until their arm lands. var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) var root := Node2D.new() add_child_autofree(root) var node := FunZoTreatments.apply(99, root, sim) assert_null(node, "unknown id is a safe no-op")- Step 2: Run the test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_funzo_treatments.gd -gexit
Expected: FAIL — FunZoTreatments does not exist.
- Step 3: Write the registry
Create render/funzo_treatments.gd:
class_name FunZoTreatmentsextends RefCounted
# Shared registry for the FunZo VFX sandbox (Tier 1 tools/funzo_lab/ AND Tier 2's Remote# Playtest Console, see docs/superpowers/specs/2026-07-03-funzo-vfx-sandbox-design.md) --# ONE dispatch table, so both tiers stay in sync by construction.## Treatment class contract (every class below implements both):# func setup(sim) -> void -- store the sim reference; called once by apply()# func refresh(delta: float) -> void -- called every frame by the owner; reads# sim.funzo_director.render_info(sim) and updates this node's own visuals. Must no-op# gracefully (hide itself) when FunZo isn't alive -- never assume a live boss.## Ids 1-7 correspond to gallery items 1-7 in the design spec. Gallery item 8 (the# HP-scrub) is Sim.dev_set_funzo_hp_frac, not a node here. Gallery item 9 (the# stylization wildcard) is Tier-1-only (tools/funzo_lab/preview.gd), not part of this# registry -- a global SubViewport CRT/posterize pass over the whole live HUD is a much# bigger, differently-scoped feature than "dress up FunZo specifically."
const TREATMENT_BASELINE := 0const TREATMENT_LIVING_SHADOW := 1const TREATMENT_LIT_SHELL := 2const TREATMENT_CONFETTI_MIST := 3const TREATMENT_REALITY_TEAR := 4const TREATMENT_DASH_AFTERIMAGE := 5const TREATMENT_VOID_SKIN := 6const TREATMENT_CARNIVAL_WARP := 7const TREATMENT_COUNT := 8
const NAMES: Array[String] = [ "baseline", "living_shadow", "lit_shell", "confetti_mist", "reality_tear", "dash_afterimage", "void_skin", "carnival_warp",]
static func id_for_name(n: String) -> int: var idx := NAMES.find(n) return idx if idx >= 0 else TREATMENT_BASELINE
static func name_for(id: int) -> String: return NAMES[id] if id >= 0 and id < NAMES.size() else "baseline"
# Builds and returns the treatment node for `id`, added as a child of `root` and wired to# `sim`. Returns null for TREATMENT_BASELINE or any id not yet implemented (Tasks 3-9 each# add one arm here). Caller owns freeing any previously active treatment before calling# this again.static func apply(id: int, root: Node2D, sim) -> Node: var node: Node = null match id: _: return null root.add_child(node) node.setup(sim) return node- Step 4: Run the test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_funzo_treatments.gd -gexit
Expected: PASS.
- Step 5: Wire
TREATMENTinto the Tier-1 preview
In tools/funzo_lab/preview.gd, add a field and dispatch call. Add near the other var declarations:
var _treatment_node: Node = nullIn _ready(), immediately after the _funzo_renderer block (after add_child(_funzo_renderer)) and before the FF_TICKS fast-forward loop:
var id := FunZoTreatments.id_for_name(_variant) _treatment_node = FunZoTreatments.apply(id, self, _sim)In _process(delta), right after the _archetype.sync(...) call, add:
if _treatment_node != null and _treatment_node.has_method("refresh"): _treatment_node.refresh(delta)Update the file header comment’s env var list to note TREATMENT is now live (replace the “TREATMENT (added in Task 2)” line with: # TREATMENT baseline|living_shadow|lit_shell|confetti_mist|reality_tear| dash_afterimage|void_skin|carnival_warp|wildcard (default baseline; wildcard added in Task 10)).
- Step 6: Boot-check + full suite + count guard
Run: godot --headless --path . --import
Run: godot --headless --path . --quit-after 60 — expect no SCRIPT ERROR.
Run: scripts/check-test-count.sh — expect exit 0.
- Step 7: Commit
git add render/funzo_treatments.gd tests/test_funzo_treatments.gd tools/funzo_lab/preview.gdgit commit -m "feat(funzo-lab): treatment registry skeleton + TREATMENT env var dispatch"Task 3: Treatment 1 — Living shadow (Light2D + LightOccluder2D)
Section titled “Task 3: Treatment 1 — Living shadow (Light2D + LightOccluder2D)”Files:
- Create:
render/funzo_living_shadow.gd - Modify:
render/funzo_treatments.gd(add theTREATMENT_LIVING_SHADOWarm) - Create:
tests/test_funzo_living_shadow.gd
Interfaces:
-
Consumes: the treatment class contract from Task 2 (
setup/refresh). -
Produces:
FunZoLivingShadowwith public fields_light: PointLight2D,_occluder: LightOccluder2D(read by the test). -
Step 1: Write the failing test
Create tests/test_funzo_living_shadow.gd:
extends GutTest
func test_builds_light_and_occluder_and_tracks_funzo() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.funzo_director.spawn(sim, Vector2(100, 50)) var root := Node2D.new() add_child_autofree(root) var node := FunZoTreatments.apply(FunZoTreatments.TREATMENT_LIVING_SHADOW, root, sim) assert_true(node is FunZoLivingShadow) node.refresh(0.016) assert_almost_eq(node._light.position, Vector2(100, 50), Vector2(0.5, 0.5)) assert_almost_eq(node._occluder.position, Vector2(100, 50), Vector2(0.5, 0.5)) assert_gt(node._occ_poly.polygon.size(), 0, "occluder has a real shadow-casting polygon")
func test_hides_when_funzo_is_not_alive() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) var root := Node2D.new() add_child_autofree(root) var node := FunZoTreatments.apply(FunZoTreatments.TREATMENT_LIVING_SHADOW, root, sim) node.refresh(0.016) assert_false(node.visible, "no live FunZo -- treatment hides itself")- Step 2: Run the test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_funzo_living_shadow.gd -gexit
Expected: FAIL — FunZoLivingShadow does not exist / apply() returns null.
- Step 3: Write the treatment class
Create render/funzo_living_shadow.gd:
class_name FunZoLivingShadowextends Node2D
# Treatment 1 (FunZo VFX sandbox): a real Light2D casts fuchsia/void light from FunZo's# body, with a matching LightOccluder2D so the body throws a shadow onto the arena/zones# behind it -- the first LightOccluder2D use anywhere in this project (render/hero_lights.gd# flags occluder shadows as an unbuilt extension point). Render-side only.
var sim = nullvar _light: PointLight2Dvar _occluder: LightOccluder2Dvar _occ_poly: OccluderPolygon2Dvar _last_radius := -1.0
const LIGHT_COLOR := Color(1.0, 0.15, 0.75)const LIGHT_ENERGY := 1.6const LIGHT_TEXTURE_SCALE := 7.0
func setup(s) -> void: sim = s
func _ready() -> void: _light = PointLight2D.new() _light.texture = GlowTexture.shared() _light.color = LIGHT_COLOR _light.energy = LIGHT_ENERGY _light.texture_scale = LIGHT_TEXTURE_SCALE _light.blend_mode = Light2D.BLEND_MODE_ADD add_child(_light)
_occ_poly = OccluderPolygon2D.new() _occluder = LightOccluder2D.new() _occluder.occluder = _occ_poly add_child(_occluder)
func refresh(_delta: float) -> void: if sim == null: visible = false return var info: Dictionary = sim.funzo_director.render_info(sim) if not info.get("alive", false): visible = false return visible = true var pos: Vector2 = info["pos"] var radius: float = float(info.get("radius", 85.0)) _light.position = pos _occluder.position = pos if absf(radius - _last_radius) > 1.0: _last_radius = radius _occ_poly.polygon = _circle_pts(radius * 0.85, 16)
static func _circle_pts(r: float, n: int) -> PackedVector2Array: var pts := PackedVector2Array() for k in range(n): var a := TAU * float(k) / float(n) pts.append(Vector2(cos(a), sin(a)) * r) return pts- Step 4: Wire the registry arm
In render/funzo_treatments.gd, replace the match id: body’s _: fallback so TREATMENT_LIVING_SHADOW is handled:
match id: TREATMENT_LIVING_SHADOW: node = FunZoLivingShadow.new() _: return null- Step 5: Run the test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_funzo_living_shadow.gd -gexit
Expected: PASS.
- Step 6: Boot-check + full suite + count guard
Run: godot --headless --path . --import
Run: godot --headless --path . --quit-after 60 — expect no SCRIPT ERROR.
Run: scripts/check-test-count.sh — expect exit 0.
- Step 7: Capture and eyeball it
Run: TREATMENT=living_shadow godot --path . res://tools/funzo_lab/preview.tscn --rendering-method forward_plus
Open tools/funzo_lab/out/funzo_living_shadow.png and compare against funzo_baseline.png.
- Step 8: Commit
git add render/funzo_living_shadow.gd render/funzo_treatments.gd tests/test_funzo_living_shadow.gdgit commit -m "feat(funzo-lab): treatment 1 -- living shadow (Light2D + LightOccluder2D)"Task 4: Treatment 2 — Lit clown-shell (normal-mapped shader body)
Section titled “Task 4: Treatment 2 — Lit clown-shell (normal-mapped shader body)”Files:
- Create:
render/funzo_lit_shell.gd - Modify:
render/funzo_treatments.gd(add theTREATMENT_LIT_SHELLarm) - Create:
tests/test_funzo_lit_shell.gd
Interfaces:
-
Consumes: the treatment class contract from Task 2.
-
Produces:
FunZoLitShellwith public field_sprite: Sprite2D(read by the test). -
Step 1: Write the failing test
Create tests/test_funzo_lit_shell.gd:
extends GutTest
func test_builds_lit_sprite_and_tracks_funzo_size() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.funzo_director.spawn(sim, Vector2(20, -30)) var root := Node2D.new() add_child_autofree(root) var node := FunZoTreatments.apply(FunZoTreatments.TREATMENT_LIT_SHELL, root, sim) assert_true(node is FunZoLitShell) node.refresh(0.016) assert_almost_eq(node._sprite.position, Vector2(20, -30), Vector2(0.5, 0.5)) assert_true(node._sprite.material is ShaderMaterial) assert_gt(node._sprite.scale.x, 0.0, "scaled to FunZo's current radius")
func test_hides_when_funzo_is_not_alive() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) var root := Node2D.new() add_child_autofree(root) var node := FunZoTreatments.apply(FunZoTreatments.TREATMENT_LIT_SHELL, root, sim) node.refresh(0.016) assert_false(node.visible)- Step 2: Run the test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_funzo_lit_shell.gd -gexit
Expected: FAIL.
- Step 3: Write the treatment class
Create render/funzo_lit_shell.gd:
class_name FunZoLitShellextends Node2D
# Treatment 2: extends render/player_renderer.gd's normal-mapped hull-lighting technique# to FunZo's body -- the first non-player entity with real per-pixel lighting instead of# a flat procedural fill. A hemisphere bump map is baked ONCE (FunZo is always circular,# unlike the player's per-tier polygon), then a Sprite2D samples it with a fixed key light.
var sim = nullvar _sprite: Sprite2Dvar _mat: ShaderMaterial
const TEX_SIZE := 96
const _SHELL_SHADER := "shader_type canvas_item;render_mode blend_add;uniform sampler2D normal_tex : filter_linear;uniform vec3 light_dir = vec3(0.35, -0.8, 0.5);uniform vec3 tint : source_color = vec3(1.0, 0.2, 0.85);uniform float ambient = 0.30;uniform float spec = 0.8;void fragment() { vec4 base = texture(TEXTURE, UV); if (base.a < 0.02) { discard; } vec3 n = texture(normal_tex, UV).rgb * 2.0 - 1.0; vec3 L = normalize(light_dir); float ndl = clamp(dot(n, L), 0.0, 1.0); float hi = pow(ndl, 20.0) * spec; vec3 lit = tint * (ambient + ndl) + vec3(1.0) * hi; COLOR = vec4(lit, base.a);}"
func setup(s) -> void: sim = s
func _ready() -> void: _mat = ShaderMaterial.new() var sh := Shader.new() sh.code = _SHELL_SHADER _mat.shader = sh _sprite = Sprite2D.new() _sprite.material = _mat _sprite.texture = _make_disc_texture() _mat.set_shader_parameter("normal_tex", _make_hemisphere_normal()) add_child(_sprite)
func refresh(_delta: float) -> void: if sim == null: visible = false return var info: Dictionary = sim.funzo_director.render_info(sim) if not info.get("alive", false): visible = false return visible = true var pos: Vector2 = info["pos"] var radius: float = float(info.get("radius", 85.0)) _sprite.position = pos var s := (radius * 0.9 * 2.0) / float(TEX_SIZE) _sprite.scale = Vector2(s, s)
func _make_disc_texture() -> ImageTexture: var img := Image.create(TEX_SIZE, TEX_SIZE, false, Image.FORMAT_RGBA8) var ctr := Vector2(TEX_SIZE, TEX_SIZE) * 0.5 var r := float(TEX_SIZE) * 0.5 for y in range(TEX_SIZE): for x in range(TEX_SIZE): var d := Vector2(x, y).distance_to(ctr) img.set_pixel(x, y, Color(1, 1, 1, 1.0 if d <= r else 0.0)) return ImageTexture.create_from_image(img)
func _make_hemisphere_normal() -> ImageTexture: var img := Image.create(TEX_SIZE, TEX_SIZE, false, Image.FORMAT_RGBA8) var ctr := Vector2(TEX_SIZE, TEX_SIZE) * 0.5 var r := float(TEX_SIZE) * 0.5 for y in range(TEX_SIZE): for x in range(TEX_SIZE): var p := (Vector2(x, y) - ctr) / r var d := p.length() var n: Vector3 if d >= 1.0: n = Vector3(0, 0, 1) else: n = Vector3(p.x, p.y, sqrt(1.0 - d * d)).normalized() img.set_pixel(x, y, Color(n.x * 0.5 + 0.5, n.y * 0.5 + 0.5, n.z * 0.5 + 0.5, 1.0)) return ImageTexture.create_from_image(img)- Step 4: Wire the registry arm
In render/funzo_treatments.gd, add the arm alongside the existing one:
TREATMENT_LIT_SHELL: node = FunZoLitShell.new()- Step 5: Run the test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_funzo_lit_shell.gd -gexit
Expected: PASS.
- Step 6: Boot-check + full suite + count guard
Run: godot --headless --path . --import
Run: godot --headless --path . --quit-after 60 — expect no SCRIPT ERROR.
Run: scripts/check-test-count.sh — expect exit 0.
- Step 7: Capture and eyeball it
Run: TREATMENT=lit_shell godot --path . res://tools/funzo_lab/preview.tscn --rendering-method forward_plus
Open tools/funzo_lab/out/funzo_lit_shell.png.
- Step 8: Commit
git add render/funzo_lit_shell.gd render/funzo_treatments.gd tests/test_funzo_lit_shell.gdgit commit -m "feat(funzo-lab): treatment 2 -- lit clown-shell (normal-mapped shader)"Task 5: Treatment 3 — Confetti mist aura (continuous GPUParticles2D)
Section titled “Task 5: Treatment 3 — Confetti mist aura (continuous GPUParticles2D)”Files:
- Create:
render/funzo_confetti_mist.gd - Modify:
render/funzo_treatments.gd(add theTREATMENT_CONFETTI_MISTarm) - Create:
tests/test_funzo_confetti_mist.gd
Interfaces:
-
Consumes: the treatment class contract from Task 2.
-
Produces:
FunZoConfettiMist(aGPUParticles2Dsubclass) withemittingreadable by the test. -
Step 1: Write the failing test
Create tests/test_funzo_confetti_mist.gd:
extends GutTest
func test_emits_continuously_and_tracks_funzo() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.funzo_director.spawn(sim, Vector2(200, 10)) var root := Node2D.new() add_child_autofree(root) var node := FunZoTreatments.apply(FunZoTreatments.TREATMENT_CONFETTI_MIST, root, sim) assert_true(node is FunZoConfettiMist) assert_true(node.emitting, "continuous emission, unlike the one-shot fx/gpu_burst.gd") node.refresh(0.016) assert_almost_eq(node.position, Vector2(200, 10), Vector2(0.5, 0.5))
func test_hides_when_funzo_is_not_alive() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) var root := Node2D.new() add_child_autofree(root) var node := FunZoTreatments.apply(FunZoTreatments.TREATMENT_CONFETTI_MIST, root, sim) node.refresh(0.016) assert_false(node.visible)- Step 2: Run the test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_funzo_confetti_mist.gd -gexit
Expected: FAIL.
- Step 3: Write the treatment class
Create render/funzo_confetti_mist.gd:
class_name FunZoConfettiMistextends GPUParticles2D
# Treatment 3: continuous-emission GPUParticles2D swirling around FunZo's body -- the# first continuous emitter in the project (fx/gpu_burst.gd only does one-shot# emit_particle() injection). Local-space, anchored to this node's own position, which# refresh() repositions onto FunZo each frame.
var sim = null
const AMOUNT := 40const LIFETIME := 1.6const MIST_COLOR := Color(1.0, 0.25, 0.85)
func setup(s) -> void: sim = s
func _ready() -> void: amount = AMOUNT lifetime = LIFETIME emitting = true local_coords = true texture = GlowTexture.shared() var cmat := CanvasItemMaterial.new() cmat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD material = cmat process_material = _make_process()
func _make_process() -> ParticleProcessMaterial: var p := ParticleProcessMaterial.new() p.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE p.emission_sphere_radius = 70.0 p.gravity = Vector3.ZERO p.initial_velocity_min = 6.0 p.initial_velocity_max = 18.0 p.tangential_accel_min = 60.0 # swirl around the body p.tangential_accel_max = 110.0 p.radial_accel_min = -40.0 # slight inward pull, keeps the mist hugging FunZo p.radial_accel_max = -15.0 p.scale_min = 0.12 p.scale_max = 0.30 p.color = MIST_COLOR var grad := Gradient.new() grad.set_color(0, Color(MIST_COLOR.r, MIST_COLOR.g, MIST_COLOR.b, 0.0)) grad.set_color(1, Color(MIST_COLOR.r, MIST_COLOR.g, MIST_COLOR.b, 0.0)) grad.add_point(0.15, Color(MIST_COLOR.r, MIST_COLOR.g, MIST_COLOR.b, 0.55)) var gt := GradientTexture1D.new() gt.gradient = grad p.color_ramp = gt return p
func refresh(_delta: float) -> void: if sim == null: visible = false return var info: Dictionary = sim.funzo_director.render_info(sim) if not info.get("alive", false): visible = false return visible = true position = info["pos"]- Step 4: Wire the registry arm
In render/funzo_treatments.gd:
TREATMENT_CONFETTI_MIST: node = FunZoConfettiMist.new()- Step 5: Run the test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_funzo_confetti_mist.gd -gexit
Expected: PASS.
- Step 6: Boot-check + full suite + count guard
Run: godot --headless --path . --import
Run: godot --headless --path . --quit-after 60 — expect no SCRIPT ERROR.
Run: scripts/check-test-count.sh — expect exit 0.
- Step 7: Capture and eyeball it
Run: TREATMENT=confetti_mist godot --path . res://tools/funzo_lab/preview.tscn --rendering-method forward_plus
Open tools/funzo_lab/out/funzo_confetti_mist.png.
- Step 8: Commit
git add render/funzo_confetti_mist.gd render/funzo_treatments.gd tests/test_funzo_confetti_mist.gdgit commit -m "feat(funzo-lab): treatment 3 -- confetti mist aura (continuous GPUParticles2D)"Task 6: Treatment 4 — Reality-tear debris (scripted pooled sprites, not a native attractor)
Section titled “Task 6: Treatment 4 — Reality-tear debris (scripted pooled sprites, not a native attractor)”Files:
- Create:
render/funzo_reality_tear.gd - Modify:
render/funzo_treatments.gd(add theTREATMENT_REALITY_TEARarm) - Create:
tests/test_funzo_reality_tear.gd
Interfaces:
-
Consumes: the treatment class contract from Task 2, plus
sim.funzones(Arrayof{pos, radius, remaining, duration, fast_decay}dicts, appended to byFunZo.spawn_zone). -
Produces:
FunZoRealityTearwith public fields_sprites: Array[Sprite2D],_age: PackedFloat32Array(read by the test). -
Step 1: Write the failing test
Create tests/test_funzo_reality_tear.gd:
extends GutTest
func test_spawns_a_burst_when_a_fresh_zone_appears() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) var root := Node2D.new() add_child_autofree(root) var node := FunZoTreatments.apply(FunZoTreatments.TREATMENT_REALITY_TEAR, root, sim) assert_true(node is FunZoRealityTear) node.refresh(0.016) var live := 0 for a in node._age: if a >= 0.0: live += 1 assert_eq(live, 0, "no zones yet -- nothing spawned")
sim.funzo_director.spawn_zone(sim, Vector2(30, 40)) node.refresh(0.016) live = 0 for a in node._age: if a >= 0.0: live += 1 assert_eq(live, FunZoRealityTear.SPAWN_PER_ZONE, "one fresh zone spawns exactly one burst")
func test_shards_ease_toward_the_zone_center_then_expire() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) var root := Node2D.new() add_child_autofree(root) var node := FunZoTreatments.apply(FunZoTreatments.TREATMENT_REALITY_TEAR, root, sim) sim.funzo_director.spawn_zone(sim, Vector2(0, 0)) node.refresh(0.016) var start_dist := node._sprites[0].position.length() for _i in range(30): node.refresh(0.05) # ~1.5s, past FunZoRealityTear.LIFE assert_lt(node._sprites[0].position.length(), start_dist, "eased toward the zone center") assert_eq(node._age[0], -1.0, "expired after LIFE seconds")- Step 2: Run the test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_funzo_reality_tear.gd -gexit
Expected: FAIL.
- Step 3: Write the treatment class
Create render/funzo_reality_tear.gd:
class_name FunZoRealityTearextends Node2D
# Treatment 4: when FunZo drops a fresh DoT zone (sim.funzones grows), a ring of small# additive shards spawns around it and eases toward its centre with a shrinking spiral --# "reality being pulled apart" at the zone. Godot 2D has NO native particle attractor node# (that's 3D-only -- GPUParticlesAttractorBox3D/Sphere3D, confirmed against the 4.6 docs),# so this is a small hand-scripted pooled-sprite system, matching this project's existing# pattern for scripted per-instance state (fx/death_dissolve.gd, fx/gpu_burst.gd).# Assumes this node's own transform stays at world origin (added directly under the game# root / preview root with no offset), same assumption render/zone_renderer.gd makes.
var sim = nullconst POOL := 60const LIFE := 1.1const SPAWN_PER_ZONE := 10const SHARD_COLOR := Color(1.0, 0.2, 0.85)
var _sprites: Array[Sprite2D] = []var _age: PackedFloat32Array = PackedFloat32Array()var _start_pos: PackedVector2Array = PackedVector2Array()var _target_pos: PackedVector2Array = PackedVector2Array()var _spiral: PackedFloat32Array = PackedFloat32Array()var _seen_zone_count := 0
func setup(s) -> void: sim = s
func _ready() -> void: var tex := GlowTexture.shared() for i in range(POOL): var s := Sprite2D.new() s.texture = tex s.scale = Vector2(0.16, 0.16) s.modulate = Color(SHARD_COLOR.r, SHARD_COLOR.g, SHARD_COLOR.b, 0.0) var cmat := CanvasItemMaterial.new() cmat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD s.material = cmat add_child(s) _sprites.append(s) _age.append(-1.0) _start_pos.append(Vector2.ZERO) _target_pos.append(Vector2.ZERO) _spiral.append(0.0)
func refresh(delta: float) -> void: if sim == null: return var zones: Array = sim.funzones if zones.size() > _seen_zone_count: for z in range(_seen_zone_count, zones.size()): _spawn_burst(zones[z]["pos"]) _seen_zone_count = zones.size()
for i in range(_sprites.size()): if _age[i] < 0.0: continue _age[i] += delta if _age[i] >= LIFE: _age[i] = -1.0 _sprites[i].modulate.a = 0.0 continue var t: float = _age[i] / LIFE var eased := 1.0 - pow(1.0 - t, 3.0) # ease-out cubic -- fast pull-in, gentle arrival var base_pos: Vector2 = _start_pos[i].lerp(_target_pos[i], eased) var delta_vec: Vector2 = base_pos - _target_pos[i] var swirl_amt: float = _spiral[i] * (1.0 - eased) # spiral fades out as it nears center _sprites[i].position = _target_pos[i] + delta_vec.rotated(swirl_amt) _sprites[i].modulate.a = (1.0 - t) * 0.85
func _spawn_burst(center: Vector2) -> void: for k in range(SPAWN_PER_ZONE): var slot := _free_slot() if slot == -1: return var a := TAU * float(k) / float(SPAWN_PER_ZONE) _start_pos[slot] = center + Vector2(cos(a), sin(a)) * 90.0 _target_pos[slot] = center _spiral[slot] = (TAU * 0.6) * (1.0 if k % 2 == 0 else -1.0) _age[slot] = 0.0 _sprites[slot].position = _start_pos[slot] _sprites[slot].modulate.a = 0.85
func _free_slot() -> int: for i in range(_age.size()): if _age[i] < 0.0: return i return -1- Step 4: Wire the registry arm
In render/funzo_treatments.gd:
TREATMENT_REALITY_TEAR: node = FunZoRealityTear.new()- Step 5: Run the test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_funzo_reality_tear.gd -gexit
Expected: PASS.
- Step 6: Boot-check + full suite + count guard
Run: godot --headless --path . --import
Run: godot --headless --path . --quit-after 60 — expect no SCRIPT ERROR.
Run: scripts/check-test-count.sh — expect exit 0.
- Step 7: Capture and eyeball it
Run: HP_FRAC=0.6 TREATMENT=reality_tear godot --path . res://tools/funzo_lab/preview.tscn --rendering-method forward_plus
Open tools/funzo_lab/out/funzo_reality_tear.png — the FF_TICKS default (~7s) should already have dropped at least one zone.
- Step 8: Commit
git add render/funzo_reality_tear.gd render/funzo_treatments.gd tests/test_funzo_reality_tear.gdgit commit -m "feat(funzo-lab): treatment 4 -- reality-tear debris (scripted, no native 2D attractor)"Task 7: Treatment 5 — Dash afterimage (pooled ghost trail)
Section titled “Task 7: Treatment 5 — Dash afterimage (pooled ghost trail)”Files:
- Create:
render/funzo_dash_afterimage.gd - Modify:
render/funzo_treatments.gd(add theTREATMENT_DASH_AFTERIMAGEarm) - Create:
tests/test_funzo_dash_afterimage.gd
Interfaces:
-
Consumes: the treatment class contract from Task 2,
FunZoState.PHASE_DASH. -
Produces:
FunZoDashAfterimagewith public fields_sprites: Array[Sprite2D],_age: PackedFloat32Array. -
Step 1: Write the failing test
Create tests/test_funzo_dash_afterimage.gd:
extends GutTest
func test_drops_a_ghost_while_dashing_and_fades_it() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.funzo_director.spawn(sim, Vector2.ZERO) sim.funzo.phase = FunZoState.PHASE_DASH # force the dash phase directly (sandbox-only) var root := Node2D.new() add_child_autofree(root) var node := FunZoTreatments.apply(FunZoTreatments.TREATMENT_DASH_AFTERIMAGE, root, sim) assert_true(node is FunZoDashAfterimage) node.refresh(0.05) # past FunZoDashAfterimage.SPAWN_INTERVAL var live := 0 for a in node._age: if a >= 0.0: live += 1 assert_eq(live, 1, "one ghost dropped while dashing")
node.refresh(FunZoDashAfterimage.LIFE + 0.01) live = 0 for a in node._age: if a >= 0.0: live += 1 assert_eq(live, 0, "the ghost fades out after LIFE seconds")
func test_no_ghosts_while_not_dashing() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.funzo_director.spawn(sim, Vector2.ZERO) # starts in PHASE_DRIFT var root := Node2D.new() add_child_autofree(root) var node := FunZoTreatments.apply(FunZoTreatments.TREATMENT_DASH_AFTERIMAGE, root, sim) node.refresh(0.05) var live := 0 for a in node._age: if a >= 0.0: live += 1 assert_eq(live, 0, "no ghosts drop outside PHASE_DASH")- Step 2: Run the test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_funzo_dash_afterimage.gd -gexit
Expected: FAIL.
- Step 3: Write the treatment class
Create render/funzo_dash_afterimage.gd:
class_name FunZoDashAfterimageextends Node2D
# Treatment 5: a fading ghost-trail spawned while FunZo is in PHASE_DASH -- generalizes# the existing warp-only vertex-shader smear (render/archetype_renderer.gd _SMEAR_SHADER)# into a dedicated, reusable boss motion-trail. Simple pooled Sprite2D ghosts (position +# fading alpha), no shader needed.
var sim = nullconst POOL := 10const LIFE := 0.28const SPAWN_INTERVAL := 0.035 # seconds between ghost drops while dashingconst GHOST_COLOR := Color(1.0, 0.25, 0.8)
var _sprites: Array[Sprite2D] = []var _age: PackedFloat32Array = PackedFloat32Array()var _spawn_timer := 0.0
func setup(s) -> void: sim = s
func _ready() -> void: for i in range(POOL): var s := Sprite2D.new() s.texture = GlowTexture.shared() s.scale = Vector2(1.4, 1.4) s.modulate = Color(GHOST_COLOR.r, GHOST_COLOR.g, GHOST_COLOR.b, 0.0) var cmat := CanvasItemMaterial.new() cmat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD s.material = cmat add_child(s) _sprites.append(s) _age.append(-1.0)
func refresh(delta: float) -> void: if sim == null: return var info: Dictionary = sim.funzo_director.render_info(sim) var dashing: bool = info.get("alive", false) and int(info.get("phase", -1)) == FunZoState.PHASE_DASH if dashing: _spawn_timer += delta if _spawn_timer >= SPAWN_INTERVAL: _spawn_timer = 0.0 _spawn_ghost(info["pos"], float(info.get("radius", 85.0))) else: _spawn_timer = 0.0
for i in range(_sprites.size()): if _age[i] < 0.0: continue _age[i] += delta if _age[i] >= LIFE: _age[i] = -1.0 _sprites[i].modulate.a = 0.0 continue var t: float = _age[i] / LIFE _sprites[i].modulate.a = (1.0 - t) * 0.5
func _spawn_ghost(pos: Vector2, radius: float) -> void: for i in range(_sprites.size()): if _age[i] < 0.0: _age[i] = 0.0 _sprites[i].position = pos _sprites[i].scale = Vector2(radius, radius) / 42.0 _sprites[i].modulate.a = 0.5 return- Step 4: Wire the registry arm
In render/funzo_treatments.gd:
TREATMENT_DASH_AFTERIMAGE: node = FunZoDashAfterimage.new()- Step 5: Run the test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_funzo_dash_afterimage.gd -gexit
Expected: PASS.
- Step 6: Boot-check + full suite + count guard
Run: godot --headless --path . --import
Run: godot --headless --path . --quit-after 60 — expect no SCRIPT ERROR.
Run: scripts/check-test-count.sh — expect exit 0.
- Step 7: Capture and eyeball it
Run: TREATMENT=dash_afterimage godot --path . res://tools/funzo_lab/preview.tscn --rendering-method forward_plus
Open tools/funzo_lab/out/funzo_dash_afterimage.png (the default FF_TICKS lands mid-telegraph/dash).
- Step 8: Commit
git add render/funzo_dash_afterimage.gd render/funzo_treatments.gd tests/test_funzo_dash_afterimage.gdgit commit -m "feat(funzo-lab): treatment 5 -- dash afterimage (pooled ghost trail)"Task 8: Treatment 6 — Unstable void skin (continuous dissolve shader)
Section titled “Task 8: Treatment 6 — Unstable void skin (continuous dissolve shader)”Files:
- Create:
render/funzo_void_skin.gd - Modify:
render/funzo_treatments.gd(add theTREATMENT_VOID_SKINarm) - Create:
tests/test_funzo_void_skin.gd
Interfaces:
-
Consumes: the treatment class contract from Task 2.
-
Produces:
FunZoVoidSkin(aSprite2Dsubclass) with aShaderMaterialwhoseprogressparameter the test reads back viaget_shader_parameter. -
Step 1: Write the failing test
Create tests/test_funzo_void_skin.gd:
extends GutTest
func test_pulses_progress_and_intensifies_with_damage() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.funzo_director.spawn(sim, Vector2(5, 5)) var root := Node2D.new() add_child_autofree(root) var node := FunZoTreatments.apply(FunZoTreatments.TREATMENT_VOID_SKIN, root, sim) assert_true(node is FunZoVoidSkin) assert_true(node.material is ShaderMaterial) node.refresh(0.016) var full_hp_progress: float = node.material.get_shader_parameter("progress")
sim.dev_set_funzo_hp_frac(0.15) # heavily hurt -- the pulse should read stronger node.refresh(0.016) var hurt_progress: float = node.material.get_shader_parameter("progress") assert_ne(full_hp_progress, hurt_progress, "hurt_intensity changes the pulse")
func test_hides_when_funzo_is_not_alive() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) var root := Node2D.new() add_child_autofree(root) var node := FunZoTreatments.apply(FunZoTreatments.TREATMENT_VOID_SKIN, root, sim) node.refresh(0.016) assert_false(node.visible)- Step 2: Run the test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_funzo_void_skin.gd -gexit
Expected: FAIL.
- Step 3: Write the treatment class
Create render/funzo_void_skin.gd:
class_name FunZoVoidSkinextends Sprite2D
# Treatment 6: fx/death_dissolve.gd's FBM noise-erosion technique, repurposed as a# CONTINUOUS pulsing "unstable void-matter" skin instead of a one-shot death effect --# progress oscillates rather than sweeping 0->1 once, and intensifies with hurt_intensity# and enrage. Same shader code as death_dissolve.gd, different drive function.
var sim = nullvar _mat: ShaderMaterialvar _t := 0.0
const _SHADER := "shader_type canvas_item;render_mode blend_add;uniform float progress = 0.0;uniform vec4 tint = vec4(1.0);float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }float noise(vec2 p){ vec2 i = floor(p); vec2 f = fract(p); f = f * f * (3.0 - 2.0 * f); return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y);}void fragment() { vec4 tex = texture(TEXTURE, UV); float n = noise(UV * 7.0); float a = tex.a * step(progress, n); float edge = smoothstep(progress, progress + 0.10, n) - smoothstep(progress + 0.10, progress + 0.22, n); vec3 col = tint.rgb + vec3(edge) * 1.6; COLOR = vec4(col, a * tint.a * (1.0 - progress * 0.25));}"
func setup(s) -> void: sim = s
func _ready() -> void: texture = GlowTexture.shared() _mat = ShaderMaterial.new() var sh := Shader.new() sh.code = _SHADER _mat.shader = sh material = _mat
func refresh(delta: float) -> void: if sim == null: visible = false return var info: Dictionary = sim.funzo_director.render_info(sim) if not info.get("alive", false): visible = false return visible = true _t += delta var pos: Vector2 = info["pos"] var radius: float = float(info.get("radius", 85.0)) var hp: float = float(info.get("hp", 0.0)) var max_hp: float = float(info.get("max_hp", 1.0)) var hurt: float = 1.0 - clampf(hp / maxf(max_hp, 1.0), 0.0, 1.0) var enraged: bool = bool(info.get("enraged", false)) position = pos scale = Vector2(radius, radius) * 2.0 / 64.0 var speed: float = 2.0 + hurt * 3.0 + (2.0 if enraged else 0.0) var amp: float = 0.12 + hurt * 0.18 var base: float = 0.20 + hurt * 0.15 var prog: float = clampf(base + amp * (0.5 + 0.5 * sin(_t * speed)), 0.0, 0.85) _mat.set_shader_parameter("progress", prog) _mat.set_shader_parameter("tint", Vector4(1.0, 0.2, 0.85, 1.0))- Step 4: Wire the registry arm
In render/funzo_treatments.gd:
TREATMENT_VOID_SKIN: node = FunZoVoidSkin.new()- Step 5: Run the test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_funzo_void_skin.gd -gexit
Expected: PASS.
- Step 6: Boot-check + full suite + count guard
Run: godot --headless --path . --import
Run: godot --headless --path . --quit-after 60 — expect no SCRIPT ERROR.
Run: scripts/check-test-count.sh — expect exit 0.
- Step 7: Capture and eyeball it
Run: HP_FRAC=0.3 TREATMENT=void_skin godot --path . res://tools/funzo_lab/preview.tscn --rendering-method forward_plus
Open tools/funzo_lab/out/funzo_void_skin.png.
- Step 8: Commit
git add render/funzo_void_skin.gd render/funzo_treatments.gd tests/test_funzo_void_skin.gdgit commit -m "feat(funzo-lab): treatment 6 -- unstable void skin (continuous dissolve pulse)"Task 9: Treatment 7 — Carnival warp ultimate (screen-space shader on enrage-latch)
Section titled “Task 9: Treatment 7 — Carnival warp ultimate (screen-space shader on enrage-latch)”Files:
- Create:
render/funzo_carnival_warp.gd - Modify:
render/funzo_treatments.gd(add theTREATMENT_CARNIVAL_WARParm) - Create:
tests/test_funzo_carnival_warp.gd
Interfaces:
-
Consumes: the treatment class contract from Task 2.
-
Produces:
FunZoCarnivalWarp(aCanvasLayersubclass), public field_strength: float(read by the test). -
Step 1: Write the failing test
Create tests/test_funzo_carnival_warp.gd:
extends GutTest
func test_triggers_on_the_enrage_latch_edge_and_decays() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.funzo_director.spawn(sim, Vector2.ZERO) var root := Node2D.new() add_child_autofree(root) var node := FunZoTreatments.apply(FunZoTreatments.TREATMENT_CARNIVAL_WARP, root, sim) assert_true(node is FunZoCarnivalWarp) node.refresh(0.016) assert_eq(node._strength, 0.0, "not enraged yet -- idle") assert_false(node.visible)
sim.dev_set_funzo_hp_frac(0.1) # below FunZo.FUNZO_ENRAGE_FRAC -- latches on the next update sim.funzo_director.update(sim, 0.016) node.refresh(0.016) assert_gt(node._strength, 0.0, "enrage-latch edge triggers the ramp") assert_true(node.visible)
for _i in range(60): # ~1s -- past RAMP_TIME, into the decay node.refresh(0.05) assert_almost_eq(node._strength, 0.0, 0.05, "decays back to idle")
func test_does_not_retrigger_while_already_enraged() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.funzo_director.spawn(sim, Vector2.ZERO) sim.funzo.enraged = true # already enraged before the treatment ever saw it var root := Node2D.new() add_child_autofree(root) var node := FunZoTreatments.apply(FunZoTreatments.TREATMENT_CARNIVAL_WARP, root, sim) node.refresh(0.016) assert_eq(node._strength, 0.0, "no rising edge observed -- no retrigger")- Step 2: Run the test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_funzo_carnival_warp.gd -gexit
Expected: FAIL.
- Step 3: Write the treatment class
Create render/funzo_carnival_warp.gd:
class_name FunZoCarnivalWarpextends CanvasLayer
# Treatment 7: FunZo's own signature screen-space effect, triggered on the enrage-latch# EDGE -- sibling to render/screen_punch.gd and render/gravity_lens.gd (same# BackBufferCopy + hint_screen_texture technique, proven safe on gl_compatibility/# Mobile/Forward+ alike), but a distinct "carnival mirror" ripple + fuchsia tint instead# of reusing either of their looks, so FunZo reads as its own boss on-screen.
var sim = nullvar _mat: ShaderMaterialvar _strength := 0.0var _was_enraged := false
const RAMP_TIME := 0.15const DECAY_TIME := 1.0
const _SHADER := "shader_type canvas_item;uniform sampler2D screen_tex : hint_screen_texture, filter_linear;uniform vec2 center = vec2(0.5, 0.5);uniform float strength = 0.0;uniform float aspect = 1.78;void fragment() { vec2 d = SCREEN_UV - center; d.x *= aspect; float dist = length(d); float pinch = sin(dist * 18.0 - strength * 6.0) * 0.02 * strength; vec2 dir = dist > 0.0001 ? d / dist : vec2(0.0); vec2 offset = dir * pinch; offset.x /= aspect; vec3 col = texture(screen_tex, SCREEN_UV - offset).rgb; vec3 carnival = vec3(1.0, 0.3, 0.85) * strength * 0.12; COLOR = vec4(col + carnival, 1.0);}"
func setup(s) -> void: sim = s
func _ready() -> void: layer = 2 var bbc := BackBufferCopy.new() bbc.copy_mode = BackBufferCopy.COPY_MODE_VIEWPORT add_child(bbc) _mat = ShaderMaterial.new() var sh := Shader.new() sh.code = _SHADER _mat.shader = sh var rect := ColorRect.new() rect.set_anchors_preset(Control.PRESET_FULL_RECT) rect.mouse_filter = Control.MOUSE_FILTER_IGNORE rect.material = _mat add_child(rect) visible = false
func refresh(delta: float) -> void: if sim == null: return var info: Dictionary = sim.funzo_director.render_info(sim) var enraged: bool = info.get("alive", false) and bool(info.get("enraged", false)) if enraged and not _was_enraged: _strength = 0.001 # trigger the ramp on the enrage-latch EDGE, not every frame _was_enraged = enraged
if _strength <= 0.0: return if _strength < 1.0: _strength = minf(1.0, _strength + delta / RAMP_TIME) else: _strength = maxf(0.0, _strength - delta / DECAY_TIME) visible = _strength > 0.0 _mat.set_shader_parameter("strength", _strength) if info.get("alive", false): var vp := get_viewport() var screen_pos: Vector2 = vp.get_canvas_transform() * (info["pos"] as Vector2) var vsize: Vector2 = vp.get_visible_rect().size _mat.set_shader_parameter("center", screen_pos / vsize) _mat.set_shader_parameter("aspect", vsize.x / maxf(vsize.y, 1.0))- Step 4: Wire the registry arm
In render/funzo_treatments.gd:
TREATMENT_CARNIVAL_WARP: node = FunZoCarnivalWarp.new()- Step 5: Run the test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_funzo_carnival_warp.gd -gexit
Expected: PASS.
- Step 6: Boot-check + full suite + count guard
Run: godot --headless --path . --import
Run: godot --headless --path . --quit-after 60 — expect no SCRIPT ERROR.
Run: scripts/check-test-count.sh — expect exit 0.
- Step 7: Capture and eyeball it
Run: HP_FRAC=0.35 TREATMENT=carnival_warp godot --path . res://tools/funzo_lab/preview.tscn --rendering-method forward_plus
Open tools/funzo_lab/out/funzo_carnival_warp.png (HP_FRAC below FUNZO_ENRAGE_FRAC=0.40 means it’s already enraged at spawn — the edge fires on the very first update() call inside the FF_TICKS fast-forward, so the pulse will have already decayed by the time the window renders; lower RUN_SECONDS/FF_TICKS if you want to catch the ramp itself, e.g. FF_TICKS=5).
- Step 8: Commit
git add render/funzo_carnival_warp.gd render/funzo_treatments.gd tests/test_funzo_carnival_warp.gdgit commit -m "feat(funzo-lab): treatment 7 -- carnival warp ultimate (screen-space, enrage-triggered)"Task 10: Gallery item 9 — stylization wildcard (SubViewport + posterize/CRT, Tier-1 only)
Section titled “Task 10: Gallery item 9 — stylization wildcard (SubViewport + posterize/CRT, Tier-1 only)”Files:
- Modify:
tools/funzo_lab/preview.gd
Interfaces:
-
Consumes: nothing new — restructures where
_archetype/_funzo_renderer/the treatment node get parented. -
Produces:
TREATMENT=wildcardsupport. Not part ofFunZoTreatments(see the registry file header rationale) and not wired into Tier 2 in Task 11/12. -
Step 1: Restructure
_ready()around a_content_root
In tools/funzo_lab/preview.gd, add new fields near the existing ones:
var _content_root: Node2Dvar _wildcard_viewport: SubViewport = nullReplace the camera/archetype/funzo_renderer/treatment block in _ready() (everything from get_window().size = Vector2i(900, 900) through the _treatment_node = FunZoTreatments.apply(...) line) with:
get_window().size = Vector2i(900, 900) if _variant == "wildcard": _wildcard_viewport = SubViewport.new() _wildcard_viewport.size = Vector2i(900, 900) _wildcard_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS add_child(_wildcard_viewport) var inner := Node2D.new() _wildcard_viewport.add_child(inner) _content_root = inner _setup_wildcard_display() else: _content_root = self
var cam := Camera2D.new() cam.zoom = Vector2(1.3, 1.3) _content_root.add_child(cam) cam.make_current()
var content := ContentLoader.load_from_path("res://data/bible.json") _sim = Sim.new(1234, content) _sim.player.dev_invuln = true _sim.funzo_director.spawn(_sim, Vector2.ZERO)
var hp_env := OS.get_environment("HP_FRAC") if hp_env != "": _sim.dev_set_funzo_hp_frac(float(hp_env))
_archetype = ArchetypeRenderer.new() _content_root.add_child(_archetype) _funzo_renderer = FunZoRenderer.new() _funzo_renderer.setup(_sim) _content_root.add_child(_funzo_renderer)
if _variant != "wildcard": var id := FunZoTreatments.id_for_name(_variant) _treatment_node = FunZoTreatments.apply(id, self, _sim)- Step 2: Add the CRT/posterize display setup
Add near the bottom of tools/funzo_lab/preview.gd:
const _CRT_SHADER := "shader_type canvas_item;uniform float levels = 6.0;void fragment() { vec3 col = texture(TEXTURE, UV).rgb; col = floor(col * levels) / levels; float scan = 0.94 + 0.06 * sin(UV.y * 480.0); col *= scan; COLOR = vec4(col, 1.0);}"
func _setup_wildcard_display() -> void: # Experimental (gallery item 9): pipes the whole preview through a posterize + # scanline post pass -- an alternate STYLIZATION direction, not a FunZo-specific # effect, hence Tier-1-only (never registered in FunZoTreatments or the Remote # Playtest Console -- a global CRT filter over the live HUD is a much bigger, # differently-scoped feature than "dress up FunZo"). var mat := ShaderMaterial.new() var sh := Shader.new() sh.code = _CRT_SHADER mat.shader = sh var trect := TextureRect.new() trect.texture = _wildcard_viewport.get_texture() trect.size = Vector2(900, 900) trect.material = mat add_child(trect)- Step 3: Boot-check + full suite + count guard
Run: godot --headless --path . --import
Run: godot --headless --path . --quit-after 60 — expect no SCRIPT ERROR.
Run: scripts/check-test-count.sh — expect exit 0 (no GDScript tests target preview.gd itself — it’s a dev tool, not /sim or a shared render class — so the count is unaffected by this task).
- Step 4: Capture and eyeball it
Run: TREATMENT=wildcard godot --path . res://tools/funzo_lab/preview.tscn --rendering-method forward_plus
Open tools/funzo_lab/out/funzo_wildcard.png and judge whether the alternate stylization is worth pursuing further (it likely isn’t — that’s fine, this is explicitly the wildcard).
- Step 5: Re-run one earlier treatment to confirm the restructure didn’t break the normal path
Run: TREATMENT=living_shadow godot --path . res://tools/funzo_lab/preview.tscn --rendering-method forward_plus
Expected: identical to the Task 3 capture — the _content_root = self branch is unchanged behavior for every non-wildcard variant.
- Step 6: Commit
git add tools/funzo_lab/preview.gdgit commit -m "feat(funzo-lab): gallery item 9 -- stylization wildcard (SubViewport CRT/posterize)"Task 11: Tier-2 wiring — dev commands + live treatment layer in main.gd
Section titled “Task 11: Tier-2 wiring — dev commands + live treatment layer in main.gd”Files:
- Modify:
main.gd
Interfaces:
-
Consumes:
FunZoTreatments.apply(id, root, sim)(Task 2),Sim.dev_set_funzo_hp_frac(frac)(Task 1). -
Produces:
apply_dev_commandcases"funzo_treatment"({kind, id}) and"funzo_hp_frac"({kind, value});build_caps()gains a"funzo_treatments"list, consumed by the panel in Task 12. -
Step 1: Declare the tracking variable
In main.gd, near the existing var funzo_renderer: FunZoRenderer declaration (line ~25):
var _funzo_treatment_node: Node = null- Step 2: Reset it on a fresh run
In main.gd’s _new_run(), right after the existing player2_renderer = null line:
_funzo_treatment_node = null # freed by the queue_free() sweep above; drop the stale reference too- Step 3: Add the dev command cases
In main.gd’s apply_dev_command, add two new match arms after the existing "player_stat" case:
"funzo_treatment": _set_funzo_treatment(int(cmd.get("id", 0))) "funzo_hp_frac": sim.dev_set_funzo_hp_frac(float(cmd.get("value", 1.0)))Then add the helper method right after apply_dev_command:
# Swap the FunZo VFX sandbox's active treatment (Remote Playtest Console only -- see# docs/superpowers/specs/2026-07-03-funzo-vfx-sandbox-design.md). Frees whatever was# active before attaching the new one; id 0 (baseline) just frees, matching# FunZoTreatments.apply()'s null return for that id.func _set_funzo_treatment(id: int) -> void: if _funzo_treatment_node != null: _funzo_treatment_node.queue_free() _funzo_treatment_node = null _funzo_treatment_node = FunZoTreatments.apply(id, self, sim)- Step 4: Drive
refresh()each frame
In main.gd’s _process(delta), right after the existing hero_lights.update(sim.player.pos, delta, sim.fx_events) line:
if _funzo_treatment_node != null and _funzo_treatment_node.has_method("refresh"): _funzo_treatment_node.refresh(delta)- Step 5: Publish capabilities
In main.gd’s build_caps(), add a new entry to the returned dictionary, alongside the existing "player_stats" key:
"funzo_treatments": [ {"id": 0, "name": "baseline"}, {"id": 1, "name": "living_shadow"}, {"id": 2, "name": "lit_shell"}, {"id": 3, "name": "confetti_mist"}, {"id": 4, "name": "reality_tear"}, {"id": 5, "name": "dash_afterimage"}, {"id": 6, "name": "void_skin"}, {"id": 7, "name": "carnival_warp"}, ],- Step 6: Boot-check + full suite + count guard + determinism
Run: godot --headless --path . --import
Run: godot --headless --path . --quit-after 60 — expect no SCRIPT ERROR.
Run: scripts/check-test-count.sh — expect exit 0.
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit — expect PASS (render-only changes; main.gd isn’t part of /sim and the determinism test never touches it).
- Step 7: Manual smoke test in the editor/windowed build
Run: godot --path . and start a Survival run. Press the pause-menu button to open the pause overlay, note the pairing code, arm Remote Control. From a separate terminal, confirm the caps payload includes the new list:
curl -s "https://bullet-heaven-control.chris-allen-06f.workers.dev/caps?code=<CODE_FROM_SCREEN>" | grep -o '"funzo_treatments"'Expected: prints "funzo_treatments".
- Step 8: Commit
git add main.gdgit commit -m "feat(funzo-lab): wire funzo_treatment + funzo_hp_frac dev commands into main.gd"Task 12: Remote Playtest Console panel — Funzo Lab section
Section titled “Task 12: Remote Playtest Console panel — Funzo Lab section”Files:
- Modify:
control/src/panel.html.js
Interfaces:
-
Consumes:
caps.funzo_treatments(Task 11). -
Produces: a new
#funzo-labpanel section; no change to the worker (control/’squeue.js/routes are unchanged — this task is presentation-only, using the existing/cmdPOST plumbing). -
Step 1: Add the section markup
In control/src/panel.html.js, add #funzo-lab to the stylesheet’s hidden-until-connected selector list (the existing line #spawn,#builder,#player,#survivability{display:none} becomes):
#spawn,#builder,#player,#survivability,#funzo-lab{display:none}Add the new section markup right after the closing </div> of the #player section and before the <!-- ── READOUT ── --> comment:
<!-- ── FUNZO VFX SANDBOX ───────────────────────────────────────── --><hr class="sep"><div id="funzo-lab"> <h2>Funzo Lab</h2> <h3>Treatment</h3> <div class="grid" id="funzo-treatment-grid"></div> <div class="stat-row" style="margin-top:8px"> <label>HP fraction</label> <input type="range" id="funzo-hp" min="0" max="1" step="0.01" value="1" oninput="document.getElementById('funzo-hp-v').textContent=this.value"> <span id="funzo-hp-v">1</span> </div></div>- Step 2: Render the treatment buttons + wire the HP slider
In the loadCaps() function, add a call alongside the existing renderPlayerEditor(...) line:
renderFunzoLab(caps.funzo_treatments||[]);And reveal the section next to the other style.display='block' lines:
document.getElementById('funzo-lab').style.display='block';Add the new render function + slider wiring near renderPlayerEditor:
function renderFunzoLab(treatments){ const grid = document.getElementById('funzo-treatment-grid'); grid.innerHTML=''; for(const t of treatments){ const btn = document.createElement('button'); btn.textContent = t.name; // server-controlled but textContent-only, matching every other button here btn.onclick = ()=>send({kind:'funzo_treatment',id:t.id}); grid.appendChild(btn); } let _debTimer=null; document.getElementById('funzo-hp').oninput=function(){ document.getElementById('funzo-hp-v').textContent=this.value; clearTimeout(_debTimer); _debTimer=setTimeout(()=>send({kind:'funzo_hp_frac',value:+this.value}),120); };}- Step 3: Deploy the panel
Run: cd control && CLOUDFLARE_API_TOKEN="$CF_LUMARA_DEPLOY_TOKEN" CLOUDFLARE_ACCOUNT_ID="$CF_ACCOUNT_ID" npx wrangler deploy
Expected: deploy succeeds, prints the worker URL.
- Step 4: Manual verification
With a run active and Remote Control armed (per Task 11 Step 7), open https://bullet-heaven-control.chris-allen-06f.workers.dev/?key=<KEY> in a browser, enter the pairing code, click Connect. Confirm the “Funzo Lab” section appears with 8 treatment buttons and an HP-fraction slider. Click a few buttons and confirm the editor/windowed game visibly changes FunZo’s look (spawn FunZo first via the existing Bosses grid → funzo, if none is alive).
- Step 5: Commit
git add control/src/panel.html.jsgit commit -m "feat(funzo-lab): control-panel section for treatment switching + HP-fraction scrub"Task 13: On-device verification — install to Chris’s iPad and judge the gallery
Section titled “Task 13: On-device verification — install to Chris’s iPad and judge the gallery”Files: none (deploy + manual playtest task; no code changes).
Interfaces: none — this task consumes everything built in Tasks 1-12.
- Step 1: Sync gameplay + this session’s new files to the tvOS/iOS repo
Follow the project’s bh-deploy skill for the main→tvOS gameplay sync (it handles the “which files changed since the last sync” decision and the Sim_Const.BUILD bump) — this plan added render/funzo_*.gd, the sim/sim.gd seam, and main.gd’s dev-command wiring, all of which need to reach the bullet-heaven-tvos repo. tools/funzo_lab/ itself does NOT need to be copied (it’s a Mac-only dev tool, like tools/ship_preview/ and tools/bg_preview/, neither of which are synced today).
- Step 2: Export and install the iOS build to Chris’s iPad
Follow the bh-deploy skill’s iOS section (stock Godot + stock Xcode, preset.1 in export_presets.cfg, builds to build-ios/). Target device UUID: 4A2B411C-53F7-5BB3-AA08-8B1467E9142B (“Chris ipad pro”, iPad13,8 — confirmed paired via xcrun devicectl list devices during this plan’s brainstorming session). Verify the install with xcrun devicectl device install app --device 4A2B411C-53F7-5BB3-AA08-8B1467E9142B <path-to-.app> and launch it.
- Step 3: Arm the Remote Playtest Console on the iPad build
On the iPad, start a Survival run, open the pause menu, note the pairing code, tap Arm Remote.
- Step 4: Drive the gallery from a laptop/phone browser
Open the control panel URL (https://bullet-heaven-control.chris-allen-06f.workers.dev/?key=<KEY>, key from ~/.secrets BULLET_HEAVEN_CONTROL_KEY) from a separate device, enter the pairing code, Connect. Use the Bosses grid to spawn funzo. For each of the 8 treatments in the new Funzo Lab section, click it and use the HP-fraction slider to sweep from 1.0 down to ~0.1, watching FunZo’s real body/dash/zone/enrage escalation arc on the iPad screen under each treatment.
- Step 5: Chris judges and records the verdict
For each of the 8 treatments (plus the Tier-1-only wildcard, judged separately via its Mac screenshot from Task 10), note: keep as-is / needs tuning / discard. This verdict is the input to the promotion follow-up plan described in the design spec (folding winners into the real FunZoRenderer + QualityManager) — that follow-up is intentionally out of scope for this plan.
No commit for this task (no code changes) — the verdict itself is the deliverable, to be captured wherever Chris tracks next steps (a new brainstorming session for the promotion plan, when he’s ready).