SLOPSHOPPER

gdh

Lets Claude make Godot 4 games: see and drive them off-screen on the GPU, work through the open editor with real UIDs, check scripts and the API, run tests and…

newbandnetworktimer
v0.2.0no licenseupdated 2026-10-08astrosteveo/gdh
A shopper browsing a rack in a slop shop
README

gdh

Renders Godot scenes off-screen on the real GPU and saves screenshots, debug views, engine errors and checks for likely defects. An AI agent or a script can then inspect what the game draws.

It also lets an agent change a project without fighting the Godot editor: a live link into the editor (gdh bridge) for UIDs, script checks and edits the user can undo, plus gdh api for the installed Godot's class reference, gdh test for a project's tests and gdh export for builds.

Linux only for now.

Requirements

  • Linux
  • Godot 4.7 (tested with 4.7.2)
  • A Vulkan driver for your GPU
  • weston and Xwayland, for the display the GPU presents to (Arch: weston xorg-xwayland, Debian/Ubuntu: weston xwayland). Tested with weston 15 and Xwayland 24.1. Older releases may not have what gdh uses (rootful Xwayland, weston's --renderer=gl on its headless backend), and then gdh falls back to Xvfb.
  • Xvfb, the fallback when that display can't start (Arch: xorg-server-xvfb, Debian/Ubuntu: xvfb)
  • uv (it provides Python 3.10+, Pillow and NumPy)
  • For a C# project: Godot's .NET build as godot-mono, and the .NET SDK (dotnet)

C# projects

A project with a .csproj is a C# project. Before capture, live start and import, gdh builds its assemblies with dotnet build, since Godot run from the command line loads them but never builds them. It builds only when a .cs, .csproj, .sln or props/targets file changed since its last build, so don't build by hand first; --rebuild forces a build. It runs godot-mono unless GODOT names another binary. A failed build stops gdh with the compiler's errors. --no-build skips the build.

C# objects are invisible to eval unless the game hands them over as Godot values, so give the project a node or autoload with methods that return dictionaries and arrays.

Importing

Godot run from the command line, as gdh runs a game, never imports assets. A texture or model whose imported copy in .godot/imported is missing fails to load, and the game draws without it, so a fresh checkout or worktree renders with missing textures. A changed asset with a stale copy draws as it was. So before capture and live start, gdh checks the project's import cache and imports it (godot --headless --import) when it's missing or stale, and says why on stderr. The check reads file times only, and hashes an asset only when it's newer than its import (a checkout touches files without changing them), so it costs next to nothing when nothing changed; a Godot import costs a couple of seconds. It counts as stale when .godot or .godot/uid_cache.bin is missing, an importable asset has no .import file, an imported copy named in an .import file is missing, an asset's content changed since its import, or a script's class_name is missing from Godot's class cache. An asset Godot can't import is remembered and skipped, and every other asset is still checked. A change to an asset's import settings alone isn't detected: run gdh import. --no-import skips the check. gdh editor needs none of this: it runs the editor, which imports as it opens.

Resources that still fail to load (Failed loading resource, Error loading resource, No loader found for resource, a scene's [ext_resource] referenced non-existent resource, a missing .ctex) are a defect, not log noise: capture prints a DEFECT: line naming them and lists them in report.json under missing_resources, and live prints the same line on stderr, with the errors of the command that raised them.

Window size

--resolution sets the game's window. gdh checks the window's real size once the game has started (DisplayServer.window_get_size()), since a game can size its window itself, from a saved setting say. If it isn't the size asked for, capture saves what it got and exits 1 with the size it found (also in report.json under size_mismatch), and live start stops the game and says so. A game that resizes its window later in a live session gets a note on the next step. The window is what's compared: under stretch mode viewport the screenshots are at the project's base size whatever the window is.

Install

From a clone of this repo:

uv tool install .

This puts gdh on your PATH. After pulling changes, run uv tool install --reinstall ..

To run it from the repo without installing, use uv run gdh ....

gdh guide prints a one-page cheat sheet of the commands that save an agent the most calls, for an agent that has gdh but not the plugin below.

Claude Code plugin

The repo is also a Claude Code plugin. To try it without installing:

claude --plugin-dir /path/to/gdh

To install it from GitHub (astrosteveo/gdh, private for now, so you need access to it):

/plugin marketplace add astrosteveo/gdh
/plugin install gdh@gdh

It runs gdh from the plugin's own copy of this repo, so uv is the only extra install. It holds:

PartWhat it does
gdh skillWhen and how to see and drive the game: which mode to pick, how to read the views and crops, how to drive a live session, what to report
godot-editor skillChanging a project with the editor open: the bridge, live edits the user can undo, UIDs
godot-gdscript, godot-project, godot-export skillsTyped Godot 4.7 GDScript and its Godot 3 pitfalls; scenes, autoloads, the Input Map and saves; exporting builds
playtester agentPlays a scene with gdh live against a goal and reports pass or fail with evidence, keeps each passed goal as a scenario, and leaves notes for the next playtest
HooksBefore each Write or Edit in a Godot project: refuse edits to .godot/, to scenes with unsaved changes in the editor, to project.godot under a running editor, and uid:// values the project doesn't have. After: rescan the file, reload it if it's open, fill in a new scene's UIDs and check GDScript, returning Godot's errors. GDH_HOOKS=off turns them off.
BandA row above Claude Code's prompt in a Godot project: the editor bridge, the open scene, unsaved scenes, the running game and editor errors waiting for Claude

Capture a scene

gdh capture --project path/to/game --scene res://levels/level_1.tscn --out captures/level_1

Arguments after -- go to the game, and OS.get_cmdline_user_args() returns exactly them:

gdh capture --project path/to/game --scene res://main.tscn --out captures/main -- --level 3

Nothing is installed into the project. For each scene, the output directory gets these files:

FileContents
normal.pngThe frame as the player sees it
unshaded.pngBase colors with lighting off
lighting.pngLighting only
normals.pngSurface directions (normal buffer)
wireframe.pngTriangle edges
overdraw.pngHow many times each pixel is drawn
report.jsonGPU used, display, window and image size, engine errors and warnings, resources that failed to load, render stats, probe findings
crops/A zoomed crop for each finding that has a screen area
godot.logFull engine output
display.logThe display's own output (weston and Xwayland, or Xvfb)

Options:

OptionDefaultMeaning
--scenerequiredScene to capture. Repeat it to capture several scenes, each in its own subdirectory.
--modesall sixComma-separated list of views, e.g. normal,wireframe
--warmup30Frames to render before capturing
--resolution1280x720The game window's size, checked once the game has started (Window size)
--displayautogpu, xvfb or auto (Displays)
--timeout120Seconds allowed per scene
--tilesoffAlso save normal.png as four 2× tiles in crops/
--baseline DIRoffCompare each view with the PNG of its name in DIR (a subdirectory per scene, as --out), report a changed view as a finding with a crop, and exit 1 when one changed past --tolerance (docs/measure.md)
--update-baselineoffWrite the views into --baseline DIR instead
--tolerance PCT0With --baseline: the percent of a view's pixels that may change
--threshold T2With --baseline: a pixel has changed when a channel differs by more than T (of 255)
--no-importoffDon't import the project first when its import cache is missing or stale (Importing)

Environment variables:

VariableMeaning
GODOTPath to the Godot binary. Default: godot-mono for a C# project, godot otherwise.
GDH_GPU_INDEXVulkan device index to render on. Default: Godot picks one.
GDH_DISPLAYThe display when --display isn't given: auto (the default), gpu or xvfb.

Drive a running game

gdh live start --project path/to/game
gdh live step 30 --hold ui_right --shot
gdh live eval "get_node('Player').position"
gdh live stop

gdh live starts the game off-screen, held at frame 0, and runs it an exact number of frames per step. Steps can inject actions, keys and shortcuts, clicks, drags, typed text (--type), the wheel, a gamepad's buttons and sticks, touches and mouse-look. Between commands you can save frames in any view, run the probes, inspect the scene tree and evaluate expressions.

Most checks take one command:

gdh live step --until "scene.name == 'Hangar'" --max 1200   # run until it holds, or exit 1
gdh live step 120 --trace "get_node('Ship').position.y"       # a value, frame by frame
gdh live find Play                                             # what shows "Play", and where
gdh live step 2 --click-text Play                              # click it, no coordinates
gdh live shot --node UI/Inventory --zoom 2 --out inv.png       # that part of the frame, zoomed
gdh live batch --session s < commands.txt                      # many commands, one process

After a code change, gdh live reload loads changed GDScript into the running game with its state kept, and gdh live restart --replay starts the session again with the same options and companions and replays its input log back to the same frame. start --recipe FILE runs lines written as for batch once the game is ready (a project's "get to the hangar"), --seed N makes the global random numbers repeat, and --user-data fresh or --user-data-from DIR give the session a user:// of its own.

Results go to stdout. Engine errors (with a script's backtrace), DEFECT: lines, notes and what the game printed go to stderr, so they survive a discarded stdout; --strict exits 1 when the game raised engine errors. gdh live list lists every session.

A session can also start companion processes beside the game, such as a server, wait until they're ready, hand their ports to the game and stop them with it, and it can run several instances of the game that step together. A script can drive it over one pipe:

gdh live start --project path/to/game --instances 2 \
  --companion 'server=exec ./server --port {port}' -- --connect 127.0.0.1:{server.port} --name player{instance}
gdh live step 60 --hold move_right --instance 1
gdh live eval "get_node('Player').position" --instance all
gdh live pipe < requests.jsonl

Processes the game spawns (a launcher's game, a tool) are listed by gdh live status and end with the session. gdh live start --keep-children keeps the session and its display until they have ended too, for a launcher that hands off to the game and exits, or until the idle timeout passes with no gdh live command on the session; the harness stays in the game gdh started, so a handed-off game can be watched but not stepped.

See docs/live.md.

See what the editor shows

gdh editor --project path/to/game --scene res://levels/level_1.tscn --out captures/editor
gdh editor --project path/to/game --scene res://levels/level_1.tscn --out captures/editor --zoom 30 --orbit=-150,40
gdh editor --project path/to/game --scene res://levels/level_1.tscn --out captures/editor --view 0,40,80:0,0,0 --far 5000

gdh editor opens scenes in the Godot editor itself, on a display of gdh's own, and saves what the editor shows. Godot runs as godot --editor --script <gdh's harness>, so the editor starts as it does for a person, with the project's tool scripts, plugins and importers, and gdh's harness as its main loop: nothing is installed into the project. For each scene the output directory gets:

FileContents
viewport.pngThe editor's first 3D viewport (or its 2D one), as the editor draws it: the scene, the grid and the gizmos
editor.pngThe whole editor window: the docks, the inspector, the viewport and its menus
report.jsonErrors and warnings while the scene opened and after, how long it took to open, how often the editor redrew over idle seconds, what one redraw of the viewport costs, the camera, and the scene's nodes with how many tool scripts made under each

Beside them, editor.json holds the startup (how long the editor took to be ready, its errors) and project_changes: files outside .godot that the editor wrote in the project (Godot often rewrites project.godot in its own format). gdh lists them and leaves them as they are. godot.log and display.log are the engine's and the display's output.

Options:

OptionDefaultMeaning
--scenerequiredScene to open. Repeat it to open several in one editor run, each in its own subdirectory.
--resolution1600x900The editor window's size
--warmup60Frames to wait after a scene opens
--idle2Seconds to count the editor's redraws while nothing happens
--orbit DX,DYTurn the 3D view as dragging with the middle button does (write --orbit=DX,DY for a negative DX)
--zoom STEPSZoom the 3D view as the mouse wheel does: out, or in when negative
--focus NODECenter the 3D view on a node, as the View menu's Focus Selection does; in a 2D scene, frame it (Frame Selection)
--rebuildoffBuild a C# project's code again with the scene open and give the editor the focus so it loads the new build; saves viewport-rebuilt.png
--saveoffSave each scene through the editor after capturing it; the report says whether the file changed
--view X,Y,Z:X,Y,ZLook from the first point at the second, through a camera gdh adds and previews (never saved)
--farthe editor camera'sThe --view camera's far plane
--set NODE:PROPERTY=VALUESet a property before capturing, in memory only; repeatable. The value is read as Godot's syntax; a res:// path is that resource, loaded; anything else is plain text
--select NODESelect a node, so the inspector shows it
--display, --timeout, --no-buildAs for capture

NODE is a path from the scene's root (. is the root). The editor's settings, data and caches live in ~/.local/share/gdh/editor-home (GDH_EDITOR_HOME picks another), never your own, and the project's .godot/editor (its layout, recent scenes and each scene's camera) is put back as it was. See docs/editor.md.

Measure what it draws

gdh measure puts numbers on frames, any game's: flicker on a still camera, shimmer on a moving one (the second difference over time), a light's jitter, what one setting adds, a thin line's width and brightness, the size of each point of light (stars, dust), doubled or empty pixels in a dissolve, black from a NaN, crushed blacks, and frame times. A live session records the frames, or measures as it goes, and records each frame's GPU time and, with --gpu-passes, each render pass's.

gdh live record 120 --session s --out frames/still       # 120 frames, each saved
gdh measure flicker frames/still                          # every pixel's change over them
gdh measure line frame.png --from 400,120 --to 400,580    # a line's width at half its height
gdh measure spots frame.png --radius 6                     # each star's or mote's width at half its peak, and sigma
gdh live measure black --frames 60 --fail --session s     # black cut into something lit: a NaN
gdh live start --project game --session s --gpu-passes    # time each render pass too
gdh live bench 600 --budget-p99 8.3 --session s            # time 600 frames in one call; exit 1 over budget
gdh live monitors --leak --session s                       # nodes, orphans, objects, memory: exit 1 on steady growth
gdh live audio --session s                                 # each bus's peak over the last step, and what played
gdh measure sheet frames/still --out still-sheet.png      # 16 frames of a run (or a video) in one image
gdh measure diff before.png after.png --out diff          # what changed: share, box, heatmap, amplified crop

A recording (live record, live measure) also writes a contact sheet beside its frames' directory and flags a UI panel that covered the middle of the screen for most of it (docs/movie.md).

See docs/measure.md for each measure's definition and limits.

Record a movie

gdh movie --project path/to/game --out captures/clip --seconds 20 --resolution 3840x2160

gdh movie records the game with Godot's Movie Maker at a fixed frame rate (--fps, 60), every frame and the game's audio, as an MJPEG AVI at its best quality, and ffmpeg encodes it to movie.mp4: H.264 (crf 18, yuv420p, +faststart) with AAC audio. The output directory also gets sheet.png, a contact sheet of 16 frames over the run, and report.json. Movie Maker ignores --resolution (it records at the size the project's settings give its window), so gdh writes an override.cfg into the project for the run and removes it once Godot has read it; it refuses to touch an override.cfg it didn't write. gdh checks the movie's size and frame count, and flags a UI panel left over the middle of the screen for most of the run (a covered warning). It needs ffmpeg and ffprobe. See docs/movie.md.

Probes

After capturing, gdh checks the scene's data for likely defects. Examples are floating objects, a tilted camera, geometry cut off by the far plane, material values out of range, blurry pixel art, raw translation keys and misaligned UI items. It prints each finding and saves a zoomed crop of it. See docs/probes.md for the checks, their thresholds and test results.

Work through the editor

gdh bridge install                 # copy the addon into addons/gdh_bridge; the user enables it in Project Settings > Plugins
gdh bridge start                   # or: gdh's own headless editor, with nothing installed in the project
gdh bridge status                  # open scenes, unsaved scenes, the current scene, the running game
gdh bridge scan                    # rescan; waits until imports and new .uid files are done
gdh bridge uid res://player.gd     # path to UID, or uid:// to path
gdh bridge resave res://level.tscn # save through Godot, which fills in the scene's UIDs
gdh bridge check res://player.gd   # Godot's errors for a script (headless when no editor runs)
gdh bridge open res://level.tscn   # show it to the user
gdh bridge exec edit.gd            # run func run(editor) inside the editor, as an undoable edit

The bridge is a small HTTP server on 127.0.0.1 inside the Godot editor, with a random token in .godot/gdh_bridge.json. Every reply carries the editor's errors since the last one. gdh bridge start runs it in a headless editor of gdh's own (settings in gdh's editor home, the project's .godot/editor put back when it stops), which quits after 30 idle minutes. See docs/bridge.md.

Look up the API

gdh api CharacterBody2D                 # its chain, properties, methods, signals, constants
gdh api CharacterBody2D.move_and_slide  # one member's signature and description, found up the chain
gdh api --search floor

The reference is the installed Godot's own: the editor's help cache, built once per Godot version (about ten seconds, in a headless editor) and kept in ~/.cache/gdh/api/. A wrong name exits 1 with close matches.

Run tests

gdh test --project path/to/game                       # res://test and res://tests
gdh test --project path/to/game res://test/test_player.gd --out captures/tests

gdh test runs GUT (addons/gut) or gdUnit4 (addons/gdUnit4) through their own command-line runners, or, in a project with neither, gdh's own runner: each method named test* in a test*.gd file is a test, and a test fails when it raises an engine error (a failed assert(), push_error(), a script error). It runs headless unless --display asks for one, and prints each failure; report.json and junit.xml hold the rest. It also runs the *.scenario.json files there (below). See docs/test.md.

Scenarios

gdh live save-scenario --session s level1.scenario.json   # what a session was sent, with its seed
gdh scenario run level1.scenario.json                       # replay it, check it, exit 1 on a failing check

A scenario is a JSON playthrough: start options, steps (gdh live batch lines or objects), checks (expect an expression truthy, equals or approx a value, until it holds) and checkpoint shots compared with baselines. gdh's steps are frame-exact, so a scenario replays the same way each time. gdh scenario run writes results.json and junit.xml and always stops its session. A script can drive a game from Python instead: from gdh.client import Session, then with Session.start(project, scene=...) as g: g.step(30, hold="ui_right"); g.until("..."); g.eval("..."), all over one pipe. See docs/scenarios.md.

Export

gdh export --project path/to/game                    # list the presets
gdh export --project path/to/game --preset Linux     # release build to the preset's export path
gdh export --project path/to/game --preset Linux --pack   # the .pck alone, no templates needed

It checks the preset and the export templates for the exact Godot version first, and says what's missing. --smoke SECONDS then runs the build off-screen for that long, fails on a crash, an early exit or engine errors in its log, and saves smoke.png.

To drive an exported build, a launcher or any other X program as it is (where OS.has_feature("editor") matters), run it as a black box on gdh's display: gdh live start --binary build/game.x86_64, then wait --log REGEX, shot, input --click X,Y --type TEXT --key Return, status and stop. Shots read the display, input goes through XTest, and gdh gives the window the focus so one click is one click. Commands that need gdh's harness (step, eval, tree, find) say so. See docs/live.md.

Displays

Each Godot run gets an X display of its own, and Godot draws to it with its X11 driver and Vulkan. There are two kinds:

  • gpu: a virtual display the GPU presents to. It's weston's headless backend, compositing with OpenGL on the GPU, running a rootful Xwayland. Xwayland has DRI3, so Vulkan hands each finished frame over as a GPU buffer, and the game runs as fast as the GPU draws it.
  • xvfb: Xvfb, which has no DRI3. Vulkan copies every frame through the CPU, which takes about 100 ms a frame at 3840x2160 while the GPU idles.

--display auto, the default, uses the GPU display, and falls back to Xvfb with a note when it can't start: weston or Xwayland isn't installed, or weston finds no GPU to composite on. --display gpu fails instead of falling back. GDH_DISPLAY sets the default for scripts. Screenshots are the same on both, pixel for pixel, since gdh reads them from the game's o

Source 2 files
hooks/register.tsx 112 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GodotView } from '../types'
5
6// A band above the prompt for a session in a Godot project: whether an editor runs gdh's bridge, which scene is
7// open, which have unsaved changes, whether the game runs, and how many editor errors wait for Claude's next bridge
8// command. It reads the bridge with a peek, which leaves those errors for Claude.
9
10const view = atom({ plugin: 'gdh', key: 'view' } as const, { kind: 'none' } as GodotView)
11const hidden = atom({ plugin: 'gdh', key: 'hidden' } as const, false)
12const POLL_MS = 3000
13
14type BridgeInfo = { port: number; token: string }
15type BridgeStatus = {
16  ok: boolean
17  godot: string
18  headless: boolean
19  current_scene: string
20  unsaved_scenes: string[]
21  playing: boolean
22  playing_scene: string
23  pending_errors?: number
24}
25
26const parent = (dir: string) => dir.replace(/\/[^/]+\/?$/, '') || '/'
27
28async function findProject($: EngineInterface, start: string): Promise<string | null> {
29  let dir = start
30  for (let i = 0; i < 40; i++) {
31    if (await $.fs.exists(`${dir}/project.godot`)) return dir
32    const up = parent(dir)
33    if (up === dir) return null
34    dir = up
35  }
36  return null
37}
38
39const short = (res: string) => res.replace(/^res:\/\//, '')
40
41async function poll($: EngineInterface, project: string | null): Promise<void> {
42  if (project === null) return
43  let next: GodotView = { kind: 'no-bridge', project }
44  try {
45    const info = JSON.parse((await $.fs.read(`${project}/.godot/gdh_bridge.json`)) as string) as BridgeInfo
46    const res = await $.http.fetch(`http://127.0.0.1:${info.port}/`, {
47      method: 'POST',
48      headers: { 'Content-Type': 'application/json', 'X-Gdh-Token': info.token },
49      body: JSON.stringify({ cmd: 'status', peek: true }),
50    })
51    const s = JSON.parse(res.text) as BridgeStatus
52    if (s.ok) {
53      next = {
54        kind: 'bridge',
55        project,
56        godot: s.godot,
57        headless: s.headless,
58        current: s.current_scene,
59        unsaved: s.unsaved_scenes,
60        playing: s.playing ? s.playing_scene || 'the main scene' : null,
61        errors: s.pending_errors ?? 0,
62      }
63    }
64  } catch {
65    // No info file, or no editor answering it: no bridge.
66  }
67  const before = JSON.stringify(await read($, view))
68  if (JSON.stringify(next) !== before) await update($, view, () => next)
69}
70
71
72export const register: Register = on => {
73  let project: string | null = null
74
75  on('session.start', async ($, e, next) => {
76    const result = await next(e)
77    project = await findProject($, await $.session.root())
78    if (project !== null) {
79      await poll($, project)
80      $.clock.every(POLL_MS, () => poll($, project))
81    }
82    return result
83  })
84
85  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
86    const v = await read($, view)
87    if (e.props.hasSurvey || v.kind === 'none' || (await read($, hidden))) return next(e)
88    const { Box, Button, Text } = $.ui.resolve(e)
89    const hide = <Button key="hide" label="Hide" onPress={() => update($, hidden, () => true)} />
90    if (v.kind === 'no-bridge') {
91      return (
92        <Box>
93          <Text dimColor>Godot: no editor bridge (gdh bridge start, or enable the gdh bridge addon) </Text>
94          {hide}
95        </Box>
96      )
97    }
98    const where = v.headless ? "gdh's headless editor" : 'editor'
99    return (
100      <Box>
101        <Text dimColor>Godot {v.godot.replace(/-.*/, '')} · {where}</Text>
102        {v.current ? <Text dimColor> · {short(v.current)}</Text> : null}
103        {v.unsaved.length > 0 ? <Text color="yellow"> · unsaved: {v.unsaved.map(short).join(', ')}</Text> : null}
104        {v.playing ? <Text color="green"> · ▶ {short(v.playing)}</Text> : null}
105        {v.errors > 0 ? <Text color="red"> · {v.errors} editor error{v.errors === 1 ? '' : 's'}</Text> : null}
106        <Text> </Text>
107        {hide}
108      </Box>
109    )
110  })
111}
112
types/index.d.ts 21 lines
1/** What the Godot band shows: the project Claude Code's session is in, and its editor bridge's state. */
2export type GodotView =
3  | { kind: 'none' }
4  | { kind: 'no-bridge'; project: string }
5  | {
6      kind: 'bridge'
7      project: string
8      godot: string
9      headless: boolean
10      current: string
11      unsaved: string[]
12      playing: string | null
13      errors: number
14    }
15
16declare module 'claude-code' {
17  interface PluginState {
18    gdh: { view: GodotView; hidden: boolean }
19  }
20}
21