verify-live — drive the real game and prove a change works
GUT tests assert wiring; this skill proves behavior by booting the actual
world.tscn, driving gameplay through the same command path the player uses,
and capturing PNG screenshots. Use it after a feature lands to confirm it works
in the live game, and to produce media for the PR.
The harness shape
A SceneTree script (run with -s) is the verification unit. It is not part
of the GUT suite — it boots the world, advances frames, drives intents, snaps a
PNG, and quits. Mirror the proven pattern in scripts/tools/net_smoke/host_test.gd.
The binary, HOME prefix, and path are fixed:
HOME=/tmp/anthesis-home tools/godot/macos_editor.app/Contents/MacOS/Godot \
--path . -s res://scripts/tools/verify/<name>.gd
HOME=/tmp/anthesis-home— every Godot invocation in this repo overrides HOME so the editor's user data / config never touches the real~. Always prefix it. Use the same value the GUT suite uses.- Windowed (no
--headless) — terrain meshing andget_texture()need a real rendering context to produce a non-black screenshot. Headless boots but the framebuffer is empty and terrain collision never streams. -s <script>runs the script as aSceneTreeMainLoop. Put harness scripts underscripts/tools/verify/(a sibling ofnet_smoke/, also outside the GUT suite — GUT only scanstests/).--importfirst if you touched any.tres/ asset: a fresh worktree has no.godot/imported/cache. RunHOME=/tmp/anthesis-home <binary> --headless --path . --importonce (ormake import) before the windowed run.
Harness template
# scripts/tools/verify/<name>.gd — SceneTree harness, NOT a GUT test.
extends SceneTree
const WORLD_SCENE := "res://scenes/world/world.tscn"
var _world: World
var _frame := 0
var _had_error := false
func _initialize() -> void:
_world = load(WORLD_SCENE).instantiate()
root.add_child(_world)
# Returning true quits the loop. Drive actions on specific frame marks: terrain
# streams in asynchronously, so wait ~120 frames before expecting height_at() to
# be non-NAN and the player to have dropped onto the surface.
func _process(_delta: float) -> bool:
_frame += 1
if _frame == 120:
# --- drive gameplay here (see scenarios below) ---
_world.command_bus().execute(DigCommand.new(Vector3.ZERO, 2.0))
if _frame == 240:
_snap("res://artifacts/verify-<name>.png")
print("VERIFY_OK frame=%d" % _frame)
return true
return false
func _snap(path: String) -> void:
var img := root.get_texture().get_image()
# res:// is read-only at runtime; write under user:// and copy, or write an
# absolute OS path. Simplest: ProjectSettings.globalize_path on a res:// dir
# that exists, or just save to user:// and read it back from the harness log.
var abs := ProjectSettings.globalize_path(path)
var err := img.save_png(abs)
print("SNAP %s err=%d" % [abs, err])
Notes on the snapshot:
root.get_texture().get_image()returns the current frame's framebuffer as anImage. Call it from_processafter the frame you want has rendered.Image.save_png(path)wants a writable filesystem path.res://is read-only in a run; useProjectSettings.globalize_path("res://artifacts/...")(create theartifacts/dir first) oruser://. Then read the PNG back with the Read tool by its absolute OS path.- Register artifacts. Screenshots you produce for a PR or for the caller
should be registered as artifacts (via
artifact_register) so the orchestrator surfaces them — don't leave them only in/tmp. - Inspecting HUD detail. A full 1920x1080 PNG is too downscaled in the Read
tool to judge UI detail — crop the region first. System
python3has no PIL andsips's crop flags fail silently; the reliable one-liner is:uv run --with pillow python -c "from PIL import Image; Image.open(SRC).crop((x0,y0,x1,y1)).save(DST)". - PR media convention. Screenshots destined for the repo go to
docs/media/<phase-or-feature>-<n>-<subject>.png, downscaled withsips -Z 1024to land near the repo's ~500KB norm (raw 1920x1080 grabs are ~2MB). Embed in PRs with SHA-pinnedraw.githubusercontent.comURLs (use the pushed commit SHA, not a branch name) so images survive squash-merges. Note the repo tracks.png.importsidecars —make importgenerates them; commit both.
Catching script errors
A windowed run prints GDScript errors to stderr but exits 0 anyway. To fail loud, capture stderr and grep:
HOME=/tmp/anthesis-home tools/godot/macos_editor.app/Contents/MacOS/Godot \
--path . -s res://scripts/tools/verify/<name>.gd 2>&1 | tee /tmp/verify.log
grep -E "SCRIPT ERROR|Parse Error|Cannot call|Invalid (get|call)" /tmp/verify.log \
&& echo "FAILED" || echo "CLEAN"
Print a unique VERIFY_OK / VERIFY_FAIL <reason> line from the harness and
assert on it — same convention as HOST_OK in the net smoke test.
Driving gameplay — World introspection surface
Everything you need to drive and inspect a live world is exposed on the World
node (see scripts/systems/world/world.gd). The command path is the contract:
drive mutations through world.command_bus().execute(...) (or
world.router().submit(...) for the authority-aware path), never by poking
nodes. The full getter surface:
| Getter | Returns | Use for |
|---|---|---|
command_bus() |
CommandBus |
execute any WorldCommand directly (solo path) |
router() |
CommandRouter |
authority-aware submit (use in session scenarios) |
voxel_world() |
VoxelWorld |
height_at(Vector2), voxel_tool() |
player() |
Player |
read global_position, velocity |
flora() |
FloraScatter |
inspect scattered props (children) |
inventory() |
Inventory |
assert on item counts after harvest/craft |
registry() |
ItemRegistry |
resolve item(id) / recipe(id) |
hud() |
Hud |
presentation; show_hint, bars |
lumen_well() |
LumenWell |
current(), capacity(), add(amount) |
magic() |
MagicSystem |
can_cast, cooldown_remaining |
combat() |
CombatService |
health_of(id), node_of(id), apply_damage |
player_health() |
Health |
player HP pool (owned by World, not Player) |
creatures() |
CreatureRegistry |
resolve creature defs for spawns |
music() |
MusicSystem |
players() (stem AudioStreamPlayers) |
intensity() |
IntensityModel |
read the soundtrack heat signal |
blocks_container() |
Node3D |
placed sequencer blocks (children) |
block_place() |
BlockPlacementService |
inventory-gated block spawn/remove |
session() |
NetworkSession |
host(), join(), has_authority() |
command_log() |
CommandLog |
size(), entries() for replay assertions |
There is no public getter for the SimulationClock; it is a named child —
world.get_node("SimulationClock") — gating it under the World public-method
cap (see Gotchas in CLAUDE.md). Ticks fire from its _process, so they only
advance in a windowed/real run, not when you instantiate World bare.
Common scenarios (drop into _process at a frame mark)
Dig a crater (Phase 1). Wait for terrain to stream first.
if _frame == 120 and not is_nan(_world.voxel_world().height_at(Vector2.ZERO)):
_world.command_bus().execute(DigCommand.new(_world.player().global_position, 3.0))
Cast an ability (Phase 3). Casts route through CastCommand; cost/cooldown
are enforced inside the rule gate. Pre-load the well so the cast succeeds.
var ability := AbilityRegistry.new().ability(&"shape_burst")
_world.lumen_well().add(50.0) # ensure affordable
_world.command_bus().execute(CastCommand.new(ability, _world.player().global_position))
# verify: well.current() dropped by ability.lumen_cost
Spawn an Umbral (Phase 4). Spawning is host-authority and tick-driven, so in a windowed run Umbrals condense on their own once the player is placed. To force one for a screenshot, damage path is the cleanest assertion:
# after a tick has spawned one, find it and strike it through the command path:
for child in _world.get_node("Umbrals").get_children():
if child is Umbral:
_world.command_bus().execute(DamageCommand.new(child.get_instance_id(), 12.0, Vector3.UP * 2.0))
break
Place sequencer blocks (Phase 6). Block placement is inventory-gated — grant the items first, then place a Core, then a Note Block near it.
_world.inventory().add(&"sequencer_core", 1)
_world.inventory().add(&"note_block", 4)
var p := _world.player().global_position
_world.command_bus().execute(PlaceBlockCommand.new(&"sequencer_core", p + Vector3(2, 0, 0)))
_world.command_bus().execute(PlaceBlockCommand.new(&"note_block", p + Vector3(3, 0, 0)))
# verify: _world.blocks_container().get_child_count() == 2
Host a session (Phase 7). Solo already reports has_authority() == true, so
the command path is identical. To verify the session lifecycle live:
if _frame == 60:
_world.session().host() # opens server on DEFAULT_PORT (24565)
if _frame == 120:
# committing through the router now logs to command_log for late-join replay
_world.router().submit(DigCommand.new(Vector3.ZERO, 2.0))
if _frame == 180:
print("LOG_SIZE %d" % _world.command_log().size()) # expect >= 1
For a true two-instance check, run the existing scripts/tools/net_smoke/
host/client pair (host first, then client on loopback) — see the header of
host_test.gd for the exact two-process invocation.
Gotchas
- Terrain is async.
height_at()returnsNANuntil the chunk under the player streams in (~1-2s windowed). Gate every position-dependent action onnot is_nan(height_at(Vector2.ZERO))or a generous frame count. World itself parks the player atPLAYER_SAFE_ALTITUDE(220m) until terrain is ready. - Headless = black screenshot.
get_texture().get_image()under--headlessis empty/black and terrain collision never streams. Always run windowed for visual verification. res://is read-only at runtime. Save PNGs viaProjectSettings.globalize_path(...)to a real dir, oruser://.- Forgot
--import. A fresh worktree errors on missing.importfiles / black props. Runmake importonce. Same fix when a rebase/pull brings in new scripts and the harness dies withIdentifier "X" not declaredparse errors — that's the staleclass_namecache, not your harness. - Always prefix
HOME=/tmp/anthesis-homeor the run pollutes the real home.
Related
scripts/tools/net_smoke/host_test.gd— the canonical SceneTree harness.tests/integration/test_world_boot.gd— the wiring assertions GUT covers.docs/COMMANDS.md— the command layer you drive through..claude/skills/new-phase/SKILL.md— where live verification fits in a phase.