Back to Blog

Letting an Agent See Your Godot Game, Headless

GodotAI AgentsClaude CodeAutomationGame Dev

When an AI coding agent edits gameplay logic, it can run the test suite and know whether it worked. When it edits something visual, a mesh, a material, a light, a camera angle, the post-processing, it is flying blind. It writes the change, the change compiles, and then it confidently tells you the player model now faces the camera. It has no idea. It never saw a single pixel.

I hit this constantly building a 4-player co-op fishing game on Godot 4.6. So I built a way for the agent to actually look at its work, and I just pulled it out into workways as the new godot cluster. This is how it works and why each piece is shaped the way it is.

The key fact: headless Godot still renders

The instinct is that --headless means "no rendering." It does not. Godot's headless mode swaps in a dummy DisplayServer, so there is no window and no input, but the rendering pipeline still runs internally. That means you can boot the game with no screen at all and still read the rendered frame back out of the viewport:

var img := get_viewport().get_texture().get_image()
img.save_png("res://_shot.png")

This is the whole trick. It works on CI, inside a VM, and over an SSH session, which is exactly where an agent tends to live. You do not need a virtual framebuffer, an xvfb wrapper, or a real GPU-attached display. You do not even need the OS screen-grab to be allowed (it often is not, in those environments). The engine hands you the pixels directly.

Getting a clean frame is the hard part

Naively capturing on the first frame gives you garbage: a black image, or a half-lit scene with no shadows. The capture has to wait for the right moment, and there are two separate things to wait for.

func capture(file_name: String, settle_frames: int = 30) -> void:
	# 1. Let the scene SETTLE. Global illumination (SDFGI), volumetric fog, and
	#    shadow cascades take several frames to finish computing. One frame is
	#    not enough.
	for _i in settle_frames:
		await get_tree().process_frame
	# 2. Sync to the GPU. frame_post_draw fires after the draw for THIS frame is
	#    actually done, so the framebuffer we read is complete.
	await RenderingServer.frame_post_draw
	# 3. Now read and write.
	get_viewport().get_texture().get_image().save_png("res://" + file_name)

Those are two different kinds of waiting. The process-frame loop is about the scene converging (lighting bouncing, fog accumulating). The frame_post_draw await is about the GPU finishing the frame you are about to read. Skip the first and you capture a scene mid-bake. Skip the second and you race the renderer and read a stale or empty buffer. For a heavy scene with SDFGI you might push settle_frames past 200; for a flat UI you can drop it low.

The bare -- is the whole CLI contract

The capture lives behind a command-line flag so a wrapper script can drive it without the editor:

godot --headless --path . -- --shot dusk

The bare -- matters. Everything after it goes to OS.get_cmdline_user_args() inside the game, so your own flags never collide with engine flags. Inside, a small autoload (dev_shots.gd in the cluster) reads --shot <name>, looks the name up in a registry of scene setups you define, runs that setup, captures, and quits.

The wrapper script (godot-shot.sh, with a PowerShell twin) handles the boring parts: finding the Godot binary, rebuilding the class-name cache so a fresh checkout does not explode, running the shot, and copying the PNG somewhere the agent can read it. That class-cache step is not optional, which brings me to the other half of the cluster.

The gotchas you only learn by shipping

The screenshot tooling is half the cluster. The other half is a docs/methods/godot-coop.md file: the lessons that each cost an afternoon, written down so the agent reads them before it makes the same mistake. A few of the ones that hurt most:

  • The RPC checksum trap. Godot builds a checksum over the @rpc methods on a node. The moment you add or remove an @rpc on an always-present node (your Main or an autoload), every peer running the old build gets every RPC rejected with a bare "checksum failed." Nothing tells you it is a version mismatch. The fix is to carry a PROTOCOL_VERSION in the join handshake and reject mismatched peers cleanly at auth, so a stale client sees "please update" instead of a silently broken session. This one bit the live game.
  • The class cache. .godot/ is gitignored, and it holds the cache that maps class_name declarations to scripts. On a fresh checkout, or in a Docker build, the first headless boot dies with "Identifier not declared" for every one of your class_name types until you run godot --headless --import first.
  • Headless still links the GUI libraries. The Linux binary is dynamically linked against libX11, libGL, libEGL, and friends even in --headless. A slim Docker image fails at startup with a link error until you apt-get them in.
  • Persist user://. Mount a named volume at Godot's userdata path or your dedicated server forgets its save state on every redeploy.
  • HeightMapShape3D, not ConcavePolygonShape3D, for terrain. A hand-built concave shape can silently fail to register collisions; the heightmap collider is reliable and lines up 1:1 with the visual mesh.

Take the headless-render fact even if you skip the cluster

It is the godot cluster in workways, alongside the React Native and browser ones:

npx workways add godot

That scaffolds the autoload, the wrapper scripts, the skill, and the methods doc into your repo, where you own and edit them. The headless-render fact is the part worth taking even if you never touch the rest: your engine will hand an agent (or a CI job, or you) a screenshot from a machine with no screen, and that is enough to stop guessing about what your change actually looks like.