Skip to content

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.

  • /sim purity: every file under sim/ stays RefCounted, no Node/Engine/File/JSON APIs — the ONE exception in this plan is Sim.dev_set_funzo_hp_frac (Task 1), which follows the exact allow-listed, default-inert shape of the existing dev_set_player_stat.
  • Determinism baseline must stay byte-identical: snapshot_string().hash()=2730172591, state_checksum()=4075578713 (read the literal assertions in tests/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 touching sim/) to confirm.
  • Headless Godot cannot read back _draw(), MultiMesh per-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-added class_name files from the run).
  • Test-count guard: scripts/check-test-count.sh (fails loud if GUT ran fewer scripts than there are test_*.gd files).
  • Boot smoke check: godot --headless --path . --quit-after 60 then grep stderr for SCRIPT ERROR.
  • New class_name scripts need a class-cache refresh before their first headless test run: godot --headless --path . --import.
  • No new .gdshader resource files — every shader in this project is an inline Shader.code string; this plan keeps that convention.
  • Godot 2D has no native particle attractor node (GPUParticlesAttractorBox3D/Sphere3D are 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 (add dev_set_funzo_hp_frac, near the existing dev_set_player_stat at 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’s HP_FRAC env var and the Tier-2 funzo_hp_frac dev command (Task 11) call this.

  • Produces: tools/funzo_lab/preview.gd’s env vars HP_FRAC, FF_TICKS, RUN_SECONDS — later tasks (2, 10) extend this same file with a TREATMENT env 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: Sim
var _archetype: ArchetypeRenderer
var _funzo_renderer: FunZoRenderer
var _run_seconds := 3.0
var _elapsed := 0.0
var _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
Terminal window
git add sim/sim.gd tests/test_dev_seams.gd tools/funzo_lab/preview.tscn tools/funzo_lab/preview.gd
git 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) (from sim/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 one match arm to apply()’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 by apply() right after add_child) and func refresh(delta: float) -> void (called every frame by the owner — preview.gd’s _process here, main.gd’s _process in Task 11 — reads sim.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 FunZoTreatments
extends 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 := 0
const TREATMENT_LIVING_SHADOW := 1
const TREATMENT_LIT_SHELL := 2
const TREATMENT_CONFETTI_MIST := 3
const TREATMENT_REALITY_TEAR := 4
const TREATMENT_DASH_AFTERIMAGE := 5
const TREATMENT_VOID_SKIN := 6
const TREATMENT_CARNIVAL_WARP := 7
const 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 TREATMENT into 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 = null

In _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
Terminal window
git add render/funzo_treatments.gd tests/test_funzo_treatments.gd tools/funzo_lab/preview.gd
git 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 the TREATMENT_LIVING_SHADOW arm)
  • Create: tests/test_funzo_living_shadow.gd

Interfaces:

  • Consumes: the treatment class contract from Task 2 (setup/refresh).

  • Produces: FunZoLivingShadow with 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 FunZoLivingShadow
extends 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 = null
var _light: PointLight2D
var _occluder: LightOccluder2D
var _occ_poly: OccluderPolygon2D
var _last_radius := -1.0
const LIGHT_COLOR := Color(1.0, 0.15, 0.75)
const LIGHT_ENERGY := 1.6
const 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
Terminal window
git add render/funzo_living_shadow.gd render/funzo_treatments.gd tests/test_funzo_living_shadow.gd
git 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 the TREATMENT_LIT_SHELL arm)
  • Create: tests/test_funzo_lit_shell.gd

Interfaces:

  • Consumes: the treatment class contract from Task 2.

  • Produces: FunZoLitShell with 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 FunZoLitShell
extends 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 = null
var _sprite: Sprite2D
var _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
Terminal window
git add render/funzo_lit_shell.gd render/funzo_treatments.gd tests/test_funzo_lit_shell.gd
git 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 the TREATMENT_CONFETTI_MIST arm)
  • Create: tests/test_funzo_confetti_mist.gd

Interfaces:

  • Consumes: the treatment class contract from Task 2.

  • Produces: FunZoConfettiMist (a GPUParticles2D subclass) with emitting readable 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 FunZoConfettiMist
extends 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 := 40
const LIFETIME := 1.6
const 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
Terminal window
git add render/funzo_confetti_mist.gd render/funzo_treatments.gd tests/test_funzo_confetti_mist.gd
git 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 the TREATMENT_REALITY_TEAR arm)
  • Create: tests/test_funzo_reality_tear.gd

Interfaces:

  • Consumes: the treatment class contract from Task 2, plus sim.funzones (Array of {pos, radius, remaining, duration, fast_decay} dicts, appended to by FunZo.spawn_zone).

  • Produces: FunZoRealityTear with 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 FunZoRealityTear
extends 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 = null
const POOL := 60
const LIFE := 1.1
const SPAWN_PER_ZONE := 10
const 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
Terminal window
git add render/funzo_reality_tear.gd render/funzo_treatments.gd tests/test_funzo_reality_tear.gd
git 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 the TREATMENT_DASH_AFTERIMAGE arm)
  • Create: tests/test_funzo_dash_afterimage.gd

Interfaces:

  • Consumes: the treatment class contract from Task 2, FunZoState.PHASE_DASH.

  • Produces: FunZoDashAfterimage with 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 FunZoDashAfterimage
extends 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 = null
const POOL := 10
const LIFE := 0.28
const SPAWN_INTERVAL := 0.035 # seconds between ghost drops while dashing
const 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
Terminal window
git add render/funzo_dash_afterimage.gd render/funzo_treatments.gd tests/test_funzo_dash_afterimage.gd
git 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 the TREATMENT_VOID_SKIN arm)
  • Create: tests/test_funzo_void_skin.gd

Interfaces:

  • Consumes: the treatment class contract from Task 2.

  • Produces: FunZoVoidSkin (a Sprite2D subclass) with a ShaderMaterial whose progress parameter the test reads back via get_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 FunZoVoidSkin
extends 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 = null
var _mat: ShaderMaterial
var _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
Terminal window
git add render/funzo_void_skin.gd render/funzo_treatments.gd tests/test_funzo_void_skin.gd
git 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 the TREATMENT_CARNIVAL_WARP arm)
  • Create: tests/test_funzo_carnival_warp.gd

Interfaces:

  • Consumes: the treatment class contract from Task 2.

  • Produces: FunZoCarnivalWarp (a CanvasLayer subclass), 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 FunZoCarnivalWarp
extends 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 = null
var _mat: ShaderMaterial
var _strength := 0.0
var _was_enraged := false
const RAMP_TIME := 0.15
const 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
Terminal window
git add render/funzo_carnival_warp.gd render/funzo_treatments.gd tests/test_funzo_carnival_warp.gd
git commit -m "feat(funzo-lab): treatment 7 -- carnival warp ultimate (screen-space, enrage-triggered)"

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=wildcard support. Not part of FunZoTreatments (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: Node2D
var _wildcard_viewport: SubViewport = null

Replace 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
Terminal window
git add tools/funzo_lab/preview.gd
git 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_command cases "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:

Terminal window
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
Terminal window
git add main.gd
git 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-lab panel section; no change to the worker (control/’s queue.js/routes are unchanged — this task is presentation-only, using the existing /cmd POST 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
Terminal window
git add control/src/panel.html.js
git commit -m "feat(funzo-lab): control-panel section for treatment switching + HP-fraction scrub"

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).