872922e9e2
ci / verify (push) Successful in 48s
The jitter had two causes, and the larger one is embarrassing: boss_state() handed back the newest snapshot raw while players and enemies both went through the interpolator. The boss therefore stepped at the 20Hz snapshot rate instead of the frame rate. Invisible for as long as every boss stood still, and the first one that moved looked broken. It is interpolated now -- but not across an instance change, where the previous snapshot describes a different fight in a different room and lerping to it would fling the new boss across the map for a frame. The smaller cause was server-side: a CHASE boss corrects by the SIGN of its distance error, so at the standoff the sign flipped every tick and the boss vibrated a couple of pixels at 60Hz. It has a dead band now. The settings screen covers rebindable controls and volume, reachable from both the main menu and the in-game menu. Bindings are stored as physical keycodes -- following key position, the choice setup_input_map.gd already made -- and labelled back through the active layout so an AZERTY player reads the letter on the key their fingers are on. A rebind replaces every event on the action rather than the first, because an action that kept its alternates would still answer to the key you just moved away from. A key already in use is refused and the clash is named. Reset restores what the PROJECT shipped, captured once before anything overrides it -- captured later it would restore the last session's choice, which is the thing being undone. Effects play on an SFX bus created at runtime, so both sliders are real mixer settings rather than a number multiplied into every play() call. Worth recording: the test suite AND the smoke test both passed while a client logged twelve engine errors on every startup. ConfigFile.get_value(s, k, null) does not mean "no default" -- it means the key is absent and no default was given, and the engine logs an error per action. Nothing caught it because the smoke refutations matched SCRIPT ERROR and friends, and a plain ERROR: is none of those. It surfaced from running the client and reading the output. smoke.sh now asserts no plain engine errors either, excluding by name the one line Godot prints on every clean exit, and reintroducing the bug makes it fail. One mutation caught nothing and should not have: the early return in linear_to_db_clamped was dead code, since the clamp beneath it already prevents negative infinity. Removed rather than left looking tested. check.sh clean, 439 tests, SMOKE PASS (23 assertions), all four diagnostics green, and a real client boots with zero engine errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
254 lines
12 KiB
Markdown
254 lines
12 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.
|