Godot expert (technical director and agent protocol)
Target: Godot 4.7.2.stable (standard build, no C#), macOS Apple Silicon, Metal by default.
Expert Godot work is a loop run against evidence: set the budget, build one stage, measure it, look at it, fix it, advance. An agent without a mouse gets there by picking the right channel, passing only on a machine-readable result, and never trusting one signal alone. This skill is that protocol, the shared toolkit every scenario-godot-* skill calls (scripts/), and the map of the team. If a sibling skill named here is missing from your available skills, ask the user to install it (npx skills add scenario-labs/skills --skill <name>); unattended, proceed from tool schemas and flag the gap.
Status (2026-10-02): every toolkit function below ran in Godot 4.7.2 here (23 live tests, 17 offline, all pass; references/procedures.md).
Stance (the expert delta)
- Headless output is the truth; the editor's Output panel is not. MCP servers that scrape it "report errors for lines no longer on disk", and it does not list every project error (Fennara, 2vSYP7GyA5U [00:02:51, 00:06:20]). PixelLab's agent runs the Godot binary itself and reads the console (THwZYWuOdZI [00:13:45]). Pass on the
AGENT_RESULT envelope plus a log scan, never on "it exited": a script with a parse error exits 0, with or without --check-only, and so does a script path Godot cannot find (version deltas, verified; L6).
- One writer per project. A GUI editor holding a project keeps its own copy of the open scenes, so its next save can overwrite a headless edit [added]; merged
.tscn files can fail to parse (Lilith Duncan, CAJ_iIedx_I [00:01:36]). The toolkit refuses a project a GUI Godot has open; work through that editor (MCP) or ask the user to close it. Never close a Godot or Blender process you did not start [added].
- Milliseconds against a budget, on the target. Measure before optimizing (Dan Does Dev, s2C2RO_WMh0 [00:07:12]); profile on the real device class, thermals included (Ian Bolton, WrjaUNAXYqk [00:09:35]). Headless frame time is loop time, not game cost; windowed frame time on this Mac is pinned to the display refresh; judge
gpu_ms and cpu_ms (observed).
- Look AND check. Every frame goes through
gd_review.image_checks and a contact sheet you open. Headless draws nothing, so captures need a windowed run (observed). After AI asset import, check facing, animation direction, tile seams and camera jitter by eye (PixelLab, THwZYWuOdZI).
- Tests are the contract. Assert on signals and public results, not internals (Godotneers, CreugthdgJ0 [01:08:36]; Butch Wesley, ImqhHLlPfZg [00:16:59]; Mike Schulze, x6GdTgIAFiU [00:24:19]); bound every await (2 s Godotneers, 5 s Schulze); prove the test bites by emptying the function and watching it go red (Wesley [00:13:40]).
- Design for a mouseless driver. Split body and brain so a test brain can steer a character (Godotneers [00:18:14]). Headless has no mouse device (Wesley [00:48:33]), yet GUI input still tests headless:
get_viewport().push_input(event, true) reaches Controls, while Input.parse_input_event never reaches a MOUSE_FILTER_STOP Control (scenario-godot-gameplay, scenario-godot-ui, observed); pass true so positions are logical, not window pixels. Levels hold a spawn point, not the player (Godotneers [00:56:56]).
- Edits persist only with
owner and a save. add_child alone is invisible in the Scene dock and lost on save (Godot docs, Running code in the editor); script edits bypass Undo unless routed through EditorUndoRedoManager (Queble, nW7YtSSJzbQ [00:04:41]).
- Be honest about visual editors. VisualShader, AnimationTree, the TileSet and theme editors, and the particle inspector are visual; the agent writes
.gdshader, .tres/.tscn text, or builds the resource in code, and names the GUI path for a human. Pick an MCP by feedback fidelity, not tool count (Fennara [00:09:22]).
Execution channels
| Channel | Use for | Cost (measured here) | Rules |
|---|
Headless job gd_run.run_script | logic, data, audits, scene building, imports, tests, exports | 0.2 s for a small job; --import about 2 s | default channel; watchdog ends a hung job (exit 124) |
Windowed job capture_scene, capture_sequence, profile_scene | anything that must render: screenshots, sequences, GPU time | about 1 s for 4 views warm; first capture in a fresh project 10.8 s, a 5 s profile 29.6 s | 160x90 window, SubViewport at the requested size, focus handed back (2 restores per run observed); GD_MAX_WINDOWED 2 |
Headless editor run_script(editor=True) | EditorInterface: open, edit and save scenes; EditorFileSystem | 1.1 s | wait 2 frames before editor calls; without -e EditorInterface calls fail (observed) |
GUI editor + MCP (hybridindie godot-editor-mcp) | the user's open editor: live tree, selection, undoable edits | stdio probe 0.9 s, 23 tools exposed by default | only when the user has the editor open; project-local .mcp.json only (gd_live.write_mcp_config) |
| Running game + MCP (tugcantopaloglu) | drive a running game (input, eval) | not run here | clone, npm run build, a game autoload on port 9090 |
gd_live.channels(P) reports what is possible now. Prefer the headless channels; add an MCP for live scene inspection.
Establish first
Ask once, then run: target platforms and lowest device; frame budget (default 60 fps desktop, 30 fps mobile); 2D or 3D; renderer (Forward+ desktop 3D, Mobile for demanding phones, Compatibility for web and widest reach, after Ray Hayes, KWhVVMpihsc [00:06:03]); art style and pixel art or not; whether the user's editor has the project open; the deliverable. Then gd_live.channels(P) and gd_run.audit(P, "project"), and write the renderer, physics engine (Jolt for 3D), stretch mode, main scene and autoloads into the plan.
Workflow (the expert loop)
- Brief in numbers. Budgets per platform, resolution, scale, style. GATE: every later check has a number to compare with.
- Project.
gd_env.base_project("3d"|"2d", dest) (APFS clone, 0.01 s, its own user:// folder) or new_project(...), which writes the project-manager defaults a hand-written project.godot lacks. GATE: audit(P, "project") has no flags.
- Baseline.
check_all(P), audit(P, "scene", scene=...), captures from fixed views, a profile if performance matters. GATE: zero parse and script errors; flags fixed or listed; sheet opened.
- Build one stage with small idempotent jobs (get-or-create,
owner set, explicit save). Route domain work to its owner (below).
- Measure. Audit, tests (
run_tests), profile_scene plus gd_stat.budget_check, or the export result. GATE: numbers against the brief.
- Look AND check. Same views,
image_checks, contact sheet opened, judged with the domain skill's critique. GATE: no flag that sets ok false, the change visible (compare changed_fraction above 0).
- Fix before advancing, one change at a time;
compare proves parity or gain.
- Deliver with evidence. Each step with its toolkit call, then Verified (what ran, result file, number or frame) and Assumed (not run, defaults). Never call a scene, shader, level or build done without numbers and a frame you looked at.
Shared toolkit (scripts/, the contract for every scenario-godot-* skill)
import sys; sys.path.insert(0, "<scenario-godot-expert>/scripts"). System python3, standard library (Pillow optional).
Every gd_env, gd_run, gd_review, gd_stat and gd_live call, with defaults and return values: references/procedures.md section 1. Most used: gd_env.new_project, gd_run.run_script, gd_run.capture_scene, gd_run.profile_scene, gd_run.export, gd_review.image_checks.
Every gd_run run returns ok, error, result (the job's dictionary), exit_code, timed_out, parse_errors, script_errors, shader_errors, engine_errors, warnings, leaks, log_path, result_path. Output lands in <project>/.agent_out/ (carries .gdignore). Concurrency: GD_MAX (6) Godot processes, GD_MAX_WINDOWED (2), one per project, all through gd_run.godot_slot; GodotBusy when a GUI editor holds the project.
A domain job is a GDScript file in the project (AgentKit, scripts/agentkit/, is copied to res://addons/agentkit/):
extends "res://addons/agentkit/agent_job.gd"
func run() -> Dictionary:
var scene := await load_scene("res://main.tscn", 2) # in the tree, 2 frames waited
note("children", scene.get_child_count())
return {"ok": true, "speed": arg("speed", 4.0)} # arg() typed by its default
Or a method on any RefCounted script, run_script(P, "res://tools/x.gd:build", args) (it receives the job). Errors logged during the job flip ok to false unless expect_errors is set. A script error aborts a typed run() -> Dictionary, which then returns {}: an empty result always fails (aborted: true), even with expect_errors, so return at least {"ok": true}. export reports bytes for every file it wrote (main_bytes for the named one) and treats an iOS export_project_only Xcode project as the artifact. Captures emulate the project stretch (keep, keep_width, keep_height, expand, ignore, integer scale, scale factor, viewport mode) with black bars, matching the engine on 15 cases (L20); in your own job read a make_viewport SubViewport with agent_capture.grab(vp). Helpers: arg, out_path, load_scene, wait_frames, wait_physics_frames, wait_seconds, note, is_headless, captured_errors; libraries agent_build.gd (save_scene, presets), agent_profile.gd (record). Domain code goes in scripts/gd_<domain>.py and scripts/agentkit/<domain>/.
Numbers
| Value | Relative to | Source |
|---|
| 16.67 / 33.33 ms | frame budget at 60 / 30 fps | arithmetic |
| 8.33 ms | windowed frame_ms floor on this 120 Hz display, even with --disable-vsync | observed |
| 6.9 ms | headless frame pacing: the low-processor sleep (low_processor_usage_mode_sleep_usec 6900), whatever the work; benchmark with --fixed-fps 60 and time the work | observed; scenario-godot-gameplay, scenario-godot-ui |
| 1.4 to 1.8 ms / 0 | GPU p95 of the fx test scene at 1080p on Vulkan (four runs) / on Metal | observed |
| 0.2 s, 10.2 s | small job; a hung job stopped by the watchdog (timeout 20 s) | observed |
| 60.8 / 75.0 / 41.4 MB | macOS universal zip / Linux x86_64 (binary plus .pck) / Web (9 files) export of Base3D | observed, L9 |
| 430 MB | iOS Xcode project (export_project_only), mostly the engine xcframework | observed, L9 |
Quality gates
- Measurable:
ok true with zero parse, script and shader errors; check_all failed_files == []; JUnit failures == 0 and tests > 0; audit flags fixed or justified; budget_check ok (p95 under target, hitch ratio at most 1%); export exit 0, artifact size recorded, verify_pack lists the expected files.
- Visual: every capture passes
image_checks (ok false on all_black, all_white, uniform, transparent) and was opened in a contact sheet; before and after from the same views.
Common mistakes
| Mistake | What it looks like | Fix |
|---|
| trusting exit codes | parse error, exit 0 | run_script/check_all read the log |
--check-only on code using autoloads | "Identifier not found: GameState" | check_all (in-process load); check_only files it under autoload_false_positives |
hand-written project.godot | GodotPhysics3D, stretch disabled, UI does not scale | new_project, or set the keys |
class_name in a fresh project | "Identifier not found" | import_project first |
autoload named like its class_name | 'Class "GameState" hides an autoload singleton.' (GDScript analyzer) | name the autoload differently (Game) or drop class_name |
capture or --write-movie headless | 0 frames drawn; --write-movie crashes | windowed capture_scene |
| GPU timing on Metal | gpu_ms 0 | profile_scene(driver="vulkan") |
| export output inside the project | build imported and packed next time | outside, or .gdignore (auto) |
| macOS export | "ETC2 ASTC" or "Invalid bundle identifier" | import_etc2_astc=true; ensure_preset sets a bundle id |
clones sharing user:// | saves, input maps and the shader cache of other agents' clones show up (every clone was named Base3D) | base_project isolates by default; gd_env.isolate_user_dir(P) for other scratch projects |
per-frame TIME_PROCESS | the same value on every CSV row (the monitor refreshes about once per second; 0 under --fixed-fps) | process_wall_ms column of profile_scene, or sentinel nodes |
Team routing and handoffs
| Skill | Route here when the brief mentions |
|---|
| scenario-godot-architecture | project structure, scenes vs nodes, signals, Resources, autoloads, composition, typing, save data |
| scenario-godot-2d | sprites, TileMapLayer, TileSet, platformer or top-down feel, Camera2D, pixel-perfect, 2D lights, parallax |
| scenario-godot-3d-world | CharacterBody3D, blockout, CSG, GridMap, terrain, GLB import, Jolt, procedural meshes |
| scenario-godot-rendering-lighting | renderer choice, SDFGI, VoxelGI, LightmapGI, environment, fog, shadows, post, Compositor |
| scenario-godot-shaders | .gdshader, VisualShader substitutes, global uniforms, screen reading, compute, stencil |
| scenario-godot-vfx | GPUParticles, trails, sub-emitters, particle collision, impacts, juice |
| scenario-godot-animation | AnimationPlayer, AnimationTree, tweens, skeletons, IK, retargeting, root motion, cutscenes |
| scenario-godot-ui | Control layout, containers, themes, menus, HUD, focus and gamepad, localization, scaling on phones |
| scenario-godot-gameplay | state machines, inventory, dialogue, save and load, navigation, AI, procedural generation, input |
| scenario-godot-audio | buses, effects, spatial audio, adaptive music, SFX variation |
| scenario-godot-multiplayer | high-level multiplayer, RPCs, authority, spawner and synchronizer, lobbies, dedicated servers |
| scenario-godot-pipeline-automation | CLI and CI, editor plugins, GUT and gdUnit4, import plugins, GDExtension, MCP workflows |
| scenario-godot-performance-export | profiler, monitors, stutter, mobile, web, desktop and Steam export, signing |
A cross-cutting item has one owner: shader stutter and web threading go to scenario-godot-performance-export, naming the second skill in the brief. Every handoff packet carries: project path and channel, the brief in numbers, what was done with its toolkit calls, evidence (result JSON path, contact sheet and flags, CSV with budget_check, JUnit totals, export sizes), Verified, Assumed, open issues, and a fence (what the receiver may touch). The receiver reproduces one piece of evidence before building on it. Sister teams: blender-expert (asset fixes before GLB export), unreal-expert, unity-expert, maya-expert, zbrush-expert.
Scenario assets into Godot
| Asset | Generated with | Lands in |
|---|
| sprites, props, icons, tilesets, UI pieces | scenario-game-assets | scenario-godot-2d, scenario-godot-ui |
| animation frames, sprite sheets, VFX loops | scenario-sprite-animation, scenario-sprite-pipeline | scenario-godot-2d (SpriteFrames), scenario-godot-vfx (flipbooks) |
| parallax planes | scenario-parallax-stage | scenario-godot-2d (Parallax2D) |
| GLB meshes | scenario-3d | scenario-godot-3d-world (import), scenario-godot-animation (rigs) |
| PBR textures, tileable materials | scenario-textures | scenario-godot-rendering-lighting, scenario-godot-shaders |
| skyboxes, 360 panoramas | scenario-skyboxes | scenario-godot-rendering-lighting (sky) |
| music, SFX, ambience, voice | scenario-audio, scenario-elevenlabs | scenario-godot-audio |
Check the asset before import (scenario-quality-gate) and again after it, in a capture from the game camera judged by the receiving skill, which also audits GLB scale, pivot and triangle count.
Godot 4.7 traps and macOS notes
Read references/traps.md before the first job: Godot 3 names that pass --check-only, --script hangs, unknown flags ignored, -d waiting on stdin, what headless cannot do (MultiMesh writes, GPUParticles), ProjectSettings.save() rewriting project.godot, import crashes, deprecated APIs; then the macOS binary, template and user:// paths, window focus, and Metal profiling.
References