Verify a change by running the game
scene validate tells you the file is well formed and that its property values
are the right types. It cannot tell you the HUD ended up behind the background,
that a button does nothing, or that a script threw on the first frame. For that
you run the game.
godot-cli project run --project-root . --json
That imports the project, runs the main scene for sixty frames while writing a
movie, keeps the last frame, and reads the log back. It exits 1 when Godot did
not exit cleanly or the log holds an ERROR or SCRIPT ERROR line, which is
the signal that says the change is not finished. Over MCP the same thing is the
project_run tool, and the frame comes back as an image the agent can look at.
The result names what you need:
| Field | What it is |
|---|---|
frame |
Path of the last PNG. Read it — a run can pass with a wrong layout |
log, log_tail |
The log file, and its last 40 lines inline |
errors, error_count |
Every ERROR / SCRIPT ERROR line with its backtrace |
exit, import_exit |
Godot's exit codes for the run and the import pass |
frames_written, duration_ms |
What the run actually did |
The loop
Validate, run, read. In a rules file it is three lines:
After a scene change:
godot-cli scene validate <scene> --project-root . --json
godot-cli project run --project-root . --json
Read data.frame and data.errors. Any error line means the change is not done.
The frame matters as much as the log. A scene with a Control under a Node2D
loads, runs, logs nothing, and draws nothing; the only thing that shows it is
the picture.
Prove a button works, not just that it exists
A frame shows the button is there. Clicking it shows the wiring is right:
godot-cli project run --project-root . --frames 30 \
--click /root/Main/HUD/PlayButton@20 --json
That presses the left mouse button at the centre of the node on frame
20 and releases on the next, so a Button's pressed signal fires and whatever
it is connected to runs. Your handler's print lands in log_tail, which is
the evidence that the connection works.
After the release the game's cursor moves off the node, so the last frame shows the button in its normal style rather than its hover style. To see the hover style instead, keep the cursor there:
godot-cli project run --project-root . --frames 30 \
--click /root/Main/HUD/PlayButton@20 --keep-cursor --json
Two runs, two frames, and you have verified both states of a button plus the signal, without adding a line of test code to the game. Only the game's own cursor moves; nothing touches the desktop pointer.
This works under --headless as well, which is what makes a button's wiring
checkable in CI. The signal fires and the handler's print lands in the log;
only the frame is missing.
Drive a form, so a sign-in is watched rather than reviewed
A screen behind a form cannot be reached by clicking alone. --type fills a
field:
godot-cli project run --project-root . --frames 40 \
--type '/root/Main/%Email@10=someone@example.com' \
--type '/root/Main/%Password@14=hunter2' \
--click /root/Main/Box/Submit@20 --json
The field is focused and emptied, then the text goes in as real key events, so
text_changed fires and a validating form runs the way it does for a person.
Assigning LineEdit.text emits nothing, which is why this does not do that.
The frame number sits between the last @ of the node path and the first =
of the text, so an address in the value and an = in a password both survive.
A value can be empty — %Email@3= clears the field.
Leave a frame or two before the click. The keys are delivered on the frame after they are sent, so a Submit clicked on the same frame sees the old value.
--focus <node-path>@<frame> moves keyboard focus without typing, for a frame
that shows a focus ring or proves a tab order.
Movement works the same way with input actions:
godot-cli project run --project-root . --frames 60 \
--press move_right@10..40 --json
move_right@10..40 holds the action from frame 10 to 40. It is sent as a real
InputEventAction as well as polled state, so a focused Control reacting to
ui_accept and a script polling Input.get_vector both see it.
What to watch out for
--headless clicks work; what you lose is the frame. The headless
display server reports no window size, which would leave the root viewport at
64×64 with every Control laid out in that corner, so the run puts the
project's own size back before the first click. Layout and input picking then
match a windowed run, and a Button at (960, 540) is pressed at (960, 540).
What headless cannot give you is a picture, so the layout the click landed on
is the part still unverified — run with a window when the layout is the
question.
A click that cannot reach its target fails the run. If the node is laid out beyond the viewport, or an ancestor has moved it off-screen, the run ends with the node's position and the viewport size rather than reporting a press that never happened.
Frames are numbered from 0, presses and clicks from 1. --frame-at 20
keeps that movie frame as well as the last one, for a mid-run state such as a
menu part-way through opening. One frame is one physics step in both modes —
the run pins Godot's frame rate to the project's
physics/common/physics_ticks_per_second, so --frames 40 is 40 frames on
any machine and --click …@20 happens at the same point every time.
Autoloads are fine. The run injects input through a generated script under
the capture folder, and that script loads your scene after Godot has registered
autoload singletons, so a scene whose script names GameState runs normally.
The capture folder is ignored. Frames and the log go to
.godot/godot-cli/, which carries a .gdignore, so Godot never imports the
PNGs as textures. Only the last frame is kept unless you pass --keep-frames.
Running it by hand
project run is a wrapper around commands you can type yourself, which is
worth knowing when you want to adapt it:
mkdir -p capture && touch capture/.gdignore
godot --headless --path . --import --quit
godot --path . --resolution 640x360 --write-movie capture/shot.png \
--quit-after 60 --log-file capture/godot.log --no-header
The import pass matters after adding files: Godot assigns UIDs there, and a run
before it logs invalid UID … using text path instead. --write-movie needs a
display; drop it and keep --headless for the log alone.
Everything the game prints ends up in the log — print(), push_warning,
push_error, and script errors with a GDScript backtrace:
hello from _ready
ERROR: deliberate error
at: push_error (core/variant/variant_utility.cpp:1024)
GDScript backtrace (most recent call first):
[0] _ready (res://scenes/noisy.gd:4)
Grepping that for ERROR and treating a hit as a failed change is exactly what
project run does for you, along with pulling out the backtrace lines that
follow each one.