132646f6c3
ci / verify (push) Successful in 48s
Crusenho's Complete UI Essential Pack is CC BY 4.0 -- redistributable and
commercial-friendly, confirmed from the License.txt the pack itself ships --
so unlike the two bdragon packs a subset is committed: twelve PNGs, 48 KB,
under assets/sprites/ui/. Only what is used, because each committed PNG costs
a Godot .import sidecar and a directory nothing references is one nobody
prunes.
UiTheme builds a Theme in code from it -- button states, panels, line edits --
and every screen roots itself through UiTheme.themed_root(). The HUD's bars are
the pack's frame with a tinted fill, drawn as three horizontal slices because
Godot's nine-patch lives on nodes and the HUD is drawn rather than built from
controls. Inventory slots use the pack's slot art at exactly twice the source
size; a non-integer scale on a 1px border reads as a wobble along every edge.
The credits screen is the other half of the request and it is a licence
obligation, not a nicety: two packs are now CC BY, which asks for attribution
"in any reasonable manner", and a markdown file in a source repo is not
reasonable for someone who downloaded a build. Settings -> Credits shows every
source with its terms and a link to the licence text. test_credits.gd asserts
CREDITS.md and docs/ASSETS.md name every entry, so the three cannot drift.
Two things found by actually looking at the screen, which is the point:
- The FIRST version of this styled nothing. A Control inherits its theme from
Control ANCESTORS only, and the chain breaks at the first plain Node or
CanvasLayer -- which is every screen here. get_window().theme set the
property, changed nothing, and read as correct. check.sh, 458 tests and a
clean smoke run all passed with the entire interface unstyled. The theme
test now instantiates every screen and asks what its buttons resolve.
- The settings screen showed Fire bound to the right mouse button, because
the test suite was writing the player's real user://settings.cfg --
rebinding calls save() and nothing had redirected the path. Settings.path
is now redirectable, the fixture points it at a scratch file, and a test
asserts the default is still the player's own.
tools/screenshot.tscn is what found both. It boots the client windowed and
saves the menus, the HUD, settings and credits. Manual, needs a display, and
the only thing in the project that can tell you the interface rendered.
check.sh clean, 460 tests, SMOKE PASS, all four diagnostics green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
275 lines
13 KiB
Markdown
275 lines
13 KiB
Markdown
# Agent-assisted Godot development
|
|
|
|
Research notes and the resulting setup for this repo. The short version: Godot
|
|
is an editor-centric engine and a coding agent is a text-centric tool, so the
|
|
whole workflow is built around making the game fully runnable, inspectable and
|
|
verifiable from the command line — with the editor as an optional viewer rather
|
|
than a required step.
|
|
|
|
## The core problem
|
|
|
|
An agent editing a Godot project cannot see the editor. It cannot click play,
|
|
cannot look at the scene dock, and cannot tell whether a `.tscn` it wrote is
|
|
valid. Left alone it will produce code that looks right and does not run.
|
|
|
|
Everything below exists to close that gap. Three properties matter:
|
|
|
|
1. **A fast, honest failure signal.** Parse errors must surface in seconds, from
|
|
the CLI, with the engine's own words.
|
|
2. **A way to actually play the game without a human.** Not "the tests pass" —
|
|
the real binary, the real socket, the real loop.
|
|
3. **Source of truth in text.** Binary scenes and inspector-tuned resources are
|
|
invisible to review and produce meaningless diffs.
|
|
|
|
## Godot's CLI, as an agent uses it
|
|
|
|
| Command | Use |
|
|
| --- | --- |
|
|
| `godot --headless --path . --import` | Rebuild the import + **global class cache**. Required after adding any `class_name`. ~2s. |
|
|
| `godot --headless --path . res://scene.tscn` | Run a scene headless with autoloads alive. |
|
|
| `godot --headless --path . --script tools/x.gd` | Run a `SceneTree` script. **No autoloads.** |
|
|
| `godot --headless --check-only -s file.gd` | Parse one file. |
|
|
| `godot --headless --path . -s addons/gut/gut_cmdln.gd -gdir=res://tests -gexit` | Run the test suite. |
|
|
| `godot --headless --path . --export-release <preset> <out>` | Build. |
|
|
|
|
Four things cost real time to discover, so they are worth stating plainly:
|
|
|
|
- **The global class cache is not built by running the game.** It lives in
|
|
`.godot/global_script_class_cache.cfg` and is written by the editor or by
|
|
`--import`. Add a `class_name`, run your code, and every reference to it fails
|
|
with `Identifier "Foo" not declared in the current scope` — a message that
|
|
looks exactly like a typo and is not one. `tools/check.sh` runs `--import`
|
|
first for this reason.
|
|
- **Autoload singletons do not exist under `--script`.** `--script` replaces the
|
|
main loop, so autoload names are unresolved identifiers at compile time. Any
|
|
tool that needs an autoload has to run as a scene instead. The practical
|
|
consequence for design: keep autoloads to the minimum that genuinely needs to
|
|
be a node. This repo has exactly one (`Net`, because RPC routing is by node
|
|
path); logging and CLI options are static classes, which makes them usable
|
|
from tests and tools alike.
|
|
- **`ResourceLoader.load()` on a script with a parse error can return a non-null
|
|
object.** Detecting failures by null-checking the return value silently passes.
|
|
Read the engine's stderr instead — that is what `tools/check.sh` does.
|
|
- **Never call `Script.reload()` on the script currently executing.** It hangs
|
|
the engine with no output.
|
|
|
|
## What this repo does about it
|
|
|
|
### `tools/check.sh` — the inner loop
|
|
|
|
Refreshes the class cache, loads every `.gd` in the project, greps the engine's
|
|
stderr for `Parse Error` / `SCRIPT ERROR` / `Failed to load script`, and exits
|
|
non-zero on any hit. About five seconds. This is the command to run after every
|
|
edit; it catches the entire class of "looks right, does not compile" mistakes
|
|
that an agent produces most often.
|
|
|
|
### `tools/test.sh` — GUT, headless
|
|
|
|
[GUT 9.7.1](https://github.com/bitwes/Gut) is vendored into `addons/gut/`.
|
|
Chosen over [gdUnit4](https://github.com/godot-gdunit-labs/gdUnit4) because this
|
|
project is GDScript-only, where GUT is the lighter and simpler of the two;
|
|
gdUnit4 is the better pick when C# is in play or when you want its scene-runner
|
|
utilities. Either runs headless in CI.
|
|
|
|
The suite runs in about two seconds because of an architectural choice, not a
|
|
testing trick: **the entire simulation is plain `RefCounted` objects** — no
|
|
nodes, no physics server, no rendering. A test constructs a `SimWorld`, drives
|
|
a thousand ticks, and asserts. Nothing to await, nothing to instantiate, no
|
|
frame timing. If the simulation had been built out of `CharacterBody2D` and
|
|
`Area2D`, every one of these tests would need a live SceneTree and would be
|
|
slower and flakier by an order of magnitude.
|
|
|
|
That is the single highest-leverage decision in this repo for agent-assisted
|
|
work, and it is worth stating as a general rule: **keep game logic out of nodes.**
|
|
Nodes are for presentation and input. Logic in plain objects is testable,
|
|
diffable, reviewable, and runs on a headless server for free.
|
|
|
|
### `tools/smoke.sh` — the thing tests cannot do
|
|
|
|
Boots the real dedicated server, connects two scripted bot clients over a real
|
|
ENet socket, and asserts on the server and client logs that the whole loop
|
|
happened: handshake, lobby, portal into a dungeon, emergency escape back out.
|
|
|
|
Unit tests structurally cannot cover RPC wiring, codec round-trips over the
|
|
wire, instance transfers or client reconciliation. This does, in ~35 seconds,
|
|
with no display. The bot input lives in `ClientRuntime._bot_input()` behind
|
|
`--bot`, so the "player" driving it is the same code path a human uses.
|
|
|
|
This is the pattern to reach for whenever an agent needs to verify something
|
|
interactive: give the program a scripted-input mode and a `--autoquit`, run it
|
|
headless, and assert on structured log lines. Log markers like `SERVER_READY`
|
|
exist specifically to be grepped.
|
|
|
|
### `tools/bench.gd` — performance as a number
|
|
|
|
Runs the simulation with no transport and no rendering and prints milliseconds
|
|
per tick. Current numbers, 4 players, 60s of play:
|
|
|
|
```
|
|
boss fight 0.26 ms/tick peak ~300 bullets headroom x64
|
|
trash wave 0.16 ms/tick peak ~82 bullets headroom x103
|
|
```
|
|
|
|
Note what this does *not* cover: it measures the simulation only, with no
|
|
transport. Per-peer snapshot encoding was measured separately (95.6us for four
|
|
filtered snapshots against 24.9us for one shared, or 0.032 ms/tick amortised).
|
|
Quoting a bench number for something the bench does not exercise is its own
|
|
version of the trap below.
|
|
|
|
A 60 Hz tick has a 16.6 ms budget, so one instance uses ~1.4% of one core. That
|
|
is the measurement that says a single server process can host dozens of
|
|
concurrent dungeons, and it took ten seconds to get because the simulation has
|
|
no engine dependencies.
|
|
|
|
## Content as code, not as `.tres`
|
|
|
|
Enemies and bosses are built by GDScript functions in `src/content/content.gd`,
|
|
not authored as `.tres` files. For agent-assisted work this is the right default:
|
|
|
|
- A boss is a readable diff. A `.tres` full of `SubResource` ids is not.
|
|
- No resource UID churn in version control.
|
|
- A test can build content inline without touching the filesystem.
|
|
- The agent can write a boss without an editor.
|
|
|
|
`tools/export_content.gd` writes `.tres` copies into `resources/` for anyone who
|
|
wants to tune numbers in the inspector, with the direction of truth documented:
|
|
code wins, port inspector changes back.
|
|
|
|
The same reasoning applies to scenes. This project has three `.tscn` files, each
|
|
a handful of nodes. Anything dynamic — the HUD, the menu — is built in code,
|
|
because a scene file for it would only be one more thing to keep in sync.
|
|
|
|
## Godot MCP servers
|
|
|
|
An MCP server lets an agent talk to a live Godot editor: inspect the scene tree,
|
|
read node properties, create nodes, run the project and read its output. The
|
|
most established option is
|
|
[Coding-Solo/godot-mcp](https://github.com/Coding-Solo/godot-mcp) (~5.5k stars,
|
|
Node ≥18):
|
|
|
|
```bash
|
|
claude mcp add godot -- npx @coding-solo/godot-mcp
|
|
```
|
|
|
|
It exposes `launch_editor`, `run_project`, `stop_project`, `get_debug_output`,
|
|
`get_godot_version`, `get_project_info`, `create_scene`, `add_node`,
|
|
`load_sprite`, `save_scene`, `export_mesh_library`, and UID helpers for 4.4+.
|
|
Set `GODOT_PATH` if `godot` is not on `PATH`.
|
|
|
|
**Where it earns its place:** projects with large hand-authored scene trees,
|
|
where the agent otherwise has to guess node names and paths. Being able to read
|
|
the real tree instead of inferring it is the biggest single quality jump in that
|
|
situation.
|
|
|
|
**Where it does not:** this repo. `run_project` and `get_debug_output` are what
|
|
`tools/server.sh` and `tools/smoke.sh` already do, more precisely and with
|
|
assertions attached; and the scene-authoring tools matter little when there are
|
|
three small scenes. It also adds a live-editor dependency, and editor
|
|
auto-reload racing against agent file writes is a known source of stale state.
|
|
|
|
The honest recommendation: **start with the headless CLI loop, add the MCP when
|
|
scene inspection becomes the bottleneck.** It is one command to add later.
|
|
|
|
## Verification traps, learned the hard way
|
|
|
|
Every entry below cost at least one round trip of reporting something as fixed
|
|
when it was not. They are specific to this setup, and they generalise.
|
|
|
|
### Verify the behaviour, not the artefact you produced
|
|
|
|
Bullet sprites were reported fixed three times before they were. Each time the
|
|
*atlas* was checked and found correct. The actual fault was one layer down: a
|
|
`MultiMeshInstance2D` cannot display a sub-region of a texture, so the renderer
|
|
was structurally incapable of showing one cell no matter what the atlas held.
|
|
Checking the input to a broken stage will confirm the input forever.
|
|
|
|
Ask what the *player* would see, and find a way to check that.
|
|
|
|
### An ASCII dump only proves what you dumped
|
|
|
|
The same sprites were "verified" by rendering their alpha channel. The pack
|
|
animates as a colour shimmer over a fixed silhouette, so identical-looking
|
|
frames proved nothing at all. The check was real; it was measuring the wrong
|
|
channel.
|
|
|
|
### Measure the configuration that has the bug
|
|
|
|
The client-prediction gap was measured at 0.1px on a listen server and reported
|
|
fixed. A listen server runs both halves in one process on one physics tick, so
|
|
the quantity that had drifted — two independent clocks — was constant *by
|
|
construction*. The bug needed two processes to exist at all.
|
|
|
|
If a bug is described as appearing "after a while", ask what accumulates, and
|
|
make sure your harness lets it accumulate.
|
|
|
|
### Have the test tell you the setup was valid
|
|
|
|
`test_firing_is_reported_even_when_the_bullet_dies_instantly` asserts that no
|
|
`BULLET_SPAWN` event occurred, purely to prove the interesting condition was
|
|
actually reached. The first version placed a turret as the victim — and turrets
|
|
shoot, so the assertion passed on the turret's own bullets while testing
|
|
nothing. A setup check inside the test caught it.
|
|
|
|
### `git checkout` to clean up a probe reverts real work too
|
|
|
|
A one-file revert to remove a temporary debug hook also discarded a fix made to
|
|
the same file earlier in the session, and it had already been verified and
|
|
reported. Only `git status` showing the file *missing* from the staged set
|
|
caught it. Prefer editing the probe back out, or stash.
|
|
|
|
### A pattern-based edit can match twice
|
|
|
|
A `replace()` intended for the snapshot decoder also matched inside the
|
|
character decoder, which then read a field its encoder never wrote. Both are
|
|
codecs and both had the same trailing lines. Check the match count when patching
|
|
by pattern, not by line.
|
|
|
|
## CI
|
|
|
|
`.github/workflows/ci.yml` runs check → test → smoke on a container image with
|
|
Godot preinstalled. All three are headless, so nothing extra is needed.
|
|
|
|
## Sources
|
|
|
|
- [Coding-Solo/godot-mcp](https://github.com/Coding-Solo/godot-mcp)
|
|
- [GUT](https://github.com/bitwes/Gut) · [gdUnit4](https://github.com/godot-gdunit-labs/gdUnit4)
|
|
- [Godot: high-level multiplayer](https://docs.godotengine.org/en/stable/tutorials/networking/high_level_multiplayer.html)
|
|
- [Godot: command line tutorial](https://docs.godotengine.org/en/stable/tutorials/editor/command_line_tutorial.html)
|
|
|
|
### A green suite is not a quiet one
|
|
|
|
`tools/test.sh` and `tools/smoke.sh` both passed while a client logged twelve
|
|
engine errors at every startup. `ConfigFile.get_value(section, key, null)` does
|
|
not mean "no default" — it means the key is absent *and* no default was given,
|
|
so the engine logs an error for each one. Nothing caught it because the smoke
|
|
test's refutations matched `SCRIPT ERROR|Parse Error|USER ERROR`, and a plain
|
|
`ERROR:` is none of those.
|
|
|
|
It surfaced from actually running the client (`--listen --autoquit`) and reading
|
|
the output, which is worth doing after any change to startup.
|
|
|
|
`smoke.sh` now asserts no plain engine errors either, excluding by name the one
|
|
line Godot prints on every clean exit — naming it means anything else that turns
|
|
up is a real finding rather than noise to be squinted past.
|
|
|
|
### Nothing automated can see the screen
|
|
|
|
Every gate passed — 458 tests, a clean smoke run, no engine errors — while the
|
|
entire interface rendered in Godot's default style and none of the UI pack was
|
|
visible. A Control inherits its theme from Control *ancestors only*, and the
|
|
chain breaks at the first plain `Node` or `CanvasLayer`; every screen in this
|
|
game hangs off one. `get_window().theme = ...` set the property, changed
|
|
nothing, and looked correct in the code.
|
|
|
|
It took `tools/screenshot.tscn` — boot the client, save a PNG, open it — to
|
|
find. The same run then showed **Fire bound to the right mouse button**, which
|
|
was the test suite writing the player's real `user://settings.cfg`, because
|
|
rebinding calls `save()` and nothing had redirected the path.
|
|
|
|
Two lessons, and the second is the general one:
|
|
|
|
- After touching `src/ui/`, look at it. There is no substitute.
|
|
- A test that exercises a code path which writes to user state **will write to
|
|
user state**. Redirect the path in the fixture, and assert the default is
|
|
still the real one so the redirect cannot escape.
|