Files
transcience/docs/WORKFLOW.md
T
claudeandClaude Opus 5.5 08f58af1a8
ci / verify (push) Successful in 53s
Documentation pass before a compact
Audited rather than appended. What had drifted, now corrected:

- ROADMAP's opening still described the project before this session: no
  word of the anti-cheat fixes, 468 tests, 23 smoke assertions, and a
  screenshot list half its current size. It now summarises the session,
  lists seven open items raised with the user and not yet decided (the
  boss-kill experience edge, the hub frame rate, aggro through corner
  cracks, the Warden-hall alcoves, one-tile arena doors, the Proving
  Grounds before release, hit feedback), and quotes current numbers.
  Its radius table still gave BULLET_INTEREST_RADIUS as the bullet cull
  at 2200; it is a 2900 ceiling over a per-bullet check.
- NETCODE described the flat bullet radius as the design, claimed the
  respawn lockout gates connected players (they are unbound on death),
  and called actor scoping "next stage". All corrected, and a new section
  covers what the handshake and server now refuse: duplicate sessions,
  reasons that now arrive, paced control messages, coalesced store
  writes, bounds-checked hot decoders.
- DECISIONS gains the audit's rules, the Proving Grounds' status, the
  tunnel and entrance rules, and the interface calls -- each flagged as
  the user's or as a judgement call.
- ARCHITECTURE's file map lacked the actor classes, the dev auth
  provider, the menu backdrop and any tools section.
- README told people to start a client with --name, which now only names
  bot characters, and its feature list predated fog, tunnels and carried
  health. CLAUDE.md gains three invariants (health on the record, one
  session per account, tools that host use scratch data); WORKFLOW gains
  the four traps this session fell into or nearly did.

Every doc was checked for unintended deletions against the session's
start, and every relative link resolves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:05:56 +02:00

318 lines
16 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.
### Slicing between two headings deletes whatever was between them
Replacing a section by cutting from its heading to "the next big heading" took
**451 lines** of the roadmap with it — every stage section sat between the two —
and it was committed and pushed before anything noticed, because no gate reads
prose. End a slice at the marker that actually closes the section (here the
`---` rule after it), and before committing any doc edit, check
`git diff --numstat` for a deletion count bigger than the edit you meant.
### A mutation that survives is a test that cannot fail
Twice in one session a new test passed with its fix reverted. One staged a turret
behind a barricade — a turret's muzzle offset cannot clear its own radius plus a
tile, so the scenario could never have failed. Another computed its sample point
*from* the constant under test, so zeroing the constant moved the sample instead
of failing it. Reverting the fix and watching the test go red is the only way to
know it tests anything.
### A flaky gate needs its logs kept, not rerun
The smoke test failed intermittently for weeks, always in the pre-push hook,
which discards logs; every rerun passed and the trail went cold. Pushing with
`KEEP=1 git push` kept the third failure's logs, which named the cause in one
grep (a bot walking through the wrong portal). Keep the evidence first; rerun
second.
### A tool that hosts a server must not use the player's data
`screenshot.tscn` hosted a listen server with the default character store and
account — the player's own — and walked their character into dungeons for
pictures. Every diagnostic already set `GameOpts.store_path` and
`GameOpts.account_override`; the screenshot tool had simply never been asked to.
Anything new that calls `Net.host()` sets both first.
## 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.
Styling the rest of the interface then produced two more of exactly the same
shape: an invisible slider track and an invisible scrollbar. Both resolved the
right stylebox, reported the right texture, and drew nothing, because a
`Slider` and a `ScrollBar` take their thickness from the stylebox's *minimum
size* — its content margins — and mine were zero. Neither was findable without
looking.
Three lessons, and the last is the general one:
- After touching `src/ui/`, look at it. There is no substitute.
- When a control resolves the right theme item and still looks wrong, check
what Godot derives from that item's *size* rather than its picture.
- 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.