Files
transcience/docs/WORKFLOW.md
T
claude ded7bf96d5
ci / verify (push) Successful in 47s
Documentation pass before context compaction
The roadmap was actively misleading: an earlier stage renumbering left Stage 2's
completed work sitting under a "Stage 3 -- todo" heading, and the inventory
table orphaned with no heading at all. Since that file is the primary handoff
document, a fresh session would have started by re-implementing accounts and
characters. Rewritten.

Captured in full, from the original brief rather than from memory, the two
stages not yet built:

- Stage 3 (inventory and loot): 4 slots, potions rare from trash and guaranteed
  from bosses, world-shared loot, and the player-instanced food item -- with a
  note that two loot visibilities must exist from the start, because proving the
  instanced path works is the food item's entire purpose.
- Stage 4 (upgrades): every upgrade with its exact stated effect, plus the two
  constraints it will collide with -- sniper's 2x bullet speed against the
  tunnelling threshold, and per-player bullet travel against the interest radius
  that test_interest.gd currently derives from static content.

Ten open questions are listed as explicitly do-not-guess, seven of them blocking
Stage 4.

ARCHITECTURE.md still claimed "the arena is a rectangle" and "no tilemap
collision", both untrue since Stage 1. Rewritten around MapGrid, with what the
grid costs (axis-aligned, 32px-quantised, bullets under a tile per tick) rather
than only what it buys.

Added a "verification traps" section to WORKFLOW.md recording six mistakes made
during this work, each of which cost a round trip of reporting something fixed
that was not: verifying the artefact rather than the behaviour, dumping the
wrong channel, measuring a configuration where the bug cannot exist, a test
whose setup silently invalidated it, git checkout reverting real work alongside
a probe, and a pattern edit matching in two files. They are specific enough to
be actionable.

Also documented the diagnostics in CLAUDE.md -- they were undiscoverable -- and
recorded the current verification surface so "everything passes" has a stated
meaning.

206 tests, 15 smoke assertions, both diagnostics pass.
2026-09-04 20:34:45 +02:00

12 KiB

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 is vendored into addons/gut/. Chosen over 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 (~5.5k stars, Node ≥18):

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