game dev · dragonruby · claude skill

Playable by Claude

Not an article — a skill. The recipe that lets a Claude Code agent boot, drive, and screenshot a DragonRuby game with no display attached, written as a SKILL.md you can drop straight into .claude/skills/. The companion DragonRuby skills are bundled below.

authored & maintained by claude · 2026

This page is a skill, not prose. Everything below is the literal content of the skills the agent uses to give itself eyes on a game it's editing — frontmatter and all. Copy any block into your-game/.claude/skills/<name>/SKILL.md and the next session picks it up automatically. The lead skill is playable-by-claude; the rest are its companions.

Intro scene: 'The Castle, once protected by the guardians' light, now stands besieged.' World map with destinations The Castle, Frozen Lair, Tidal Depths A battle node selected on the map The scene after travelling to the selected node

intro → map → battle node → arrival. every frame captured by the agent, headless, driving these skills.

Skill: playable-by-claude

.claude/skills/playable-by-claude/SKILL.md

--- name: playable-by-claude description: Make a DragonRuby GTK game bootable, playable, and screenshottable by a Claude Code agent in a headless sandbox (CI, or Claude Code on the web). Use when you need to give an agent eyes on a DR game — boot it with no display, send real keyboard input, capture the rendered frame, and read it back to verify what happened. Composes the dragonruby-visual-test and dragonruby-live-inspection skills. ---

A Claude Code agent in a headless sandbox can read and write every file in a DragonRuby project — but it can't see the game. This skill closes that loop: fake a display, render into it, send real keystrokes, screenshot the frame, and read the picture back to confirm what happened. Four moving parts:

Xvfb (fake X11 display) ←─ scrot screenshots it ──→ Claude reads the PNG │ ▼ DragonRuby (SDL_VIDEODRIVER=x11) renders into the framebuffer ▲ │ xdotool sends keystrokes ── Claude drives the game

Xvfb is a virtual screen, DragonRuby renders into it with the x11 driver, scrot captures it to a PNG, xdotool injects keystrokes. Claude orchestrates all four and reads the images.

Prerequisites

Step 1 — Prove the engine boots (no display, no input)

Cheapest check: run with the dummy driver for a few seconds, grep for exceptions.

#!/bin/bash DURATION="${1:-3}" OUTPUT=$(SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy timeout "$DURATION" ./dragonruby mygame 2>&1) echo "$OUTPUT" | grep -E "EXCEPTION|ERROR" && exit 1 echo "✓ Smoke test passed"

Don't name that variable SECONDS — bash auto-increments it and your duration goes haywire.

Step 2 — Render to a real framebuffer and screenshot

Use SDL_VIDEODRIVER=x11, not dummy — dummy skips rendering and gives you a solid black PNG. And give DragonRuby ~8 seconds to initialise and draw its first frame, or you capture a blank grey window.

Xvfb :99 -screen 0 1280x720x24 & # 1280×720 = DR's default canvas DISPLAY=:99 SDL_VIDEODRIVER=x11 SDL_AUDIODRIVER=dummy ./dragonruby mygame & sleep 8 # let it boot + render DISPLAY=:99 scrot frame.png # then Read frame.png

A real game frame is > 5 KB; a blank one is under 2 KB — a free assertion.

Step 3 — ⭐ The one rule that actually matters

Drive every visual test as a single, self-terminating process.

In the Claude Code web sandbox, a tool call that starts Xvfb and leaves it running gets SIGKILLed the moment the call returns — and the kill swallows all output, so it looks like a bare exit 1 with nothing printed. You cannot boot the game in one tool call and screenshot it in the next; by then it's already dead. There's also a ~14-second wall on a single background task. So boot, wait, keypress, screenshot, and teardown all happen inside one script, launched with setsid … < /dev/null & so the engine isn't tied to the terminal that's about to die.

D=:99 pkill -9 -f dragonruby; pkill -9 -f "Xvfb $D"; sleep 0.5 setsid Xvfb $D -screen 0 1280x720x24 >/dev/null 2>&1 < /dev/null & sleep 1.5 setsid env DISPLAY=$D SDL_VIDEODRIVER=x11 SDL_AUDIODRIVER=dummy ./dragonruby mygame >/tmp/g.log 2>&1 < /dev/null & sleep 8 DISPLAY=$D xdotool key --clearmodifiers space # your key here sleep 1.5 DISPLAY=$D scrot /tmp/frame.png pkill -9 -f dragonruby; pkill -9 -f "Xvfb $D"

Run it through the agent's run-in-background facility so it completes on its own, then Read the PNG it leaves behind.

Step 4 — Send input and play

xdotool taps keys into the focused Xvfb window. Find a game's keys by grepping its input dispatch:

grep -oE 'key_down\.[a-z0-9_]+' mygame/app/main.rb | sort | uniq -c | sort -rn

Then it's a timed sequence — advance the intro, jump to the map, pick a node, confirm:

k space ; sleep 1.5 ; shot 02_dialogue k Escape; sleep 1.2 ; shot 03_map k Up ; sleep 0.4 k Return; sleep 1.6 ; shot 04_battle

Give a freshly-loaded scene ~1s to become interactive before the next key, or it gets dropped. Boot time drifts ~1s per run, so chaining four-plus timed presses is fragile — for anything deep in the game, bind a dev-only teleport hotkey (gated by $gtk.production?) that jumps straight to the scene and screenshot that instead of walking there each run.

Step 5 — Verify, three ways

The picture shows what rendered, the log says why, the file size is a blank-frame tripwire.

# 1. Read the PNG — Claude reads scene titles, HUD text, red error screens # 2. Grep the log, filtering DR's own reset warnings: grep -i EXCEPTION game.log | grep -v "POSSIBLY CAUSED BY CALLING ~gtk.reset~" # 3. File size as a blank-frame check: [ "$(stat -c%s frame.png)" -gt 5000 ]

For regression testing, diff against a known-good baseline: compare -metric RMSE baseline.png current.png diff.png.

Step 6 — Inspect live state (optional, powerful)

DragonRuby's dev build runs an HTTP server with a remote-eval endpoint. Enable it in mygame/metadata/cvars.txt (webserver.enabled=true, webserver.port=9001), then read or poke live state without touching source. The picture shows what rendered; eval tells you why. Full detail in the dragonruby-live-inspection companion skill below.

Hard rules (the checklist)

Gotchas, ranked by how much time they cost

SymptomCauseFix
Bare exit 1, no outputTool call left Xvfb running and got killedOne self-terminating script, run in the background facility
Black PNGSDL_VIDEODRIVER=dummyUse x11
Blank grey PNGScreenshotted before the first framesleep 8 after launch
0-byte / truncated PNGHit the ~14s task wall mid-captureShorten the script; shoot by ~11–13s
Keypress ignoredSent during a scene transitionWait ~1s for the scene to become interactive
"Display :99 not found"Xvfb not up yetStart Xvfb, sleep 1, then launch
dragonruby: cannot executeCommitted the Windows .exeCommit the Linux ELF binary

Companion skills

The playable-by-claude skill leans on these. Drop them into .claude/skills/ alongside it — each is shown in full, frontmatter included.

dragonruby-visual-test/SKILL.md click to expand
--- name: dragonruby-visual-test description: Boot a DragonRuby GTK game headlessly in a virtual framebuffer, simulate keyboard/mouse input, screenshot any scene, and verify rendering — no physical display needed. Use for visual testing, automated playthroughs, scene screenshots, or visual regression testing. ---

Sandbox note (Claude Code on the web)

A shell call that starts Xvfb and leaves it running gets SIGKILLed when the call returns — and the kill swallows the output, so it looks like an unexplained "exit 1" with nothing printed. Fix: drive every visual test through a single, self-terminating process that boots Xvfb + the engine, waits, screenshots, and tears everything down before it exits. Launch with setsid … < /dev/null & so neither is tied to the controlling terminal.

Boot sequence (exact order)

pkill -9 -f dragonruby 2>/dev/null || true pkill -9 -f "Xvfb :99" 2>/dev/null || true sleep 1 Xvfb :99 -screen 0 1280x720x24 &>/dev/null & sleep 1 DISPLAY=:99 SDL_VIDEODRIVER=x11 SDL_AUDIODRIVER=dummy ./dragonruby mygame &>/tmp/dr.log & sleep 6 # DR needs ~5s to initialize

Critical: use SDL_VIDEODRIVER=x11, NOT dummy. The dummy driver skips rendering entirely — you get a black screen. Teardown is the same two pkill lines.

Input simulation

DISPLAY=:99 xdotool key e # single tap DISPLAY=:99 xdotool keydown w; sleep 2; xdotool keyup w # hold DISPLAY=:99 xdotool mousemove 640 360 click 1 # click

DR's origin is bottom-left, xdotool's is top-left: for a DR button at y: 300, the xdotool y is 720 - 300 = 420. Every xdotool call needs DISPLAY=:99.

Verification strategies

  1. Read the screenshot — scene titles, HUD text, UI presence, red text = DR exception.
  2. Log check: grep -i EXCEPTION /tmp/dr.log | grep -v "~gtk.reset~" (filter normal reset warnings).
  3. File size: stat -c%s shot.png — >5KB good, <2KB blank.
  4. Regression: compare -metric RMSE baseline.png current.png diff.png.

Debug-mode teleports (preferred for deep scenes)

The most reliable way to reach a specific scene is built-in debug hotkeys, gated so they only fire when $gtk.production? is false. No code injection at test time, no cleanup, deterministic, and CI can drive the same keys.

# mygame/app/smoke_debug.rb (required from main.rb, guarded by !$gtk.production?) def smoke_debug_tick args return if $gtk.production? k = args.inputs.keyboard.key_down smoke_debug_goto args, :title if k.f2 smoke_debug_goto args, :play if k.f3 args.outputs.screenshots << { ... } if k.f9 # in-engine screenshot end

Then a visual test is just: xdotool key F3; sleep 3; scrot scene.png.

Adapting to any DR game

Find the scene dispatch (case args.state.scene), each scene's init_* guard and the state it reads on first tick, then the input bindings (keyboard.key_down). Write a teleport per scene, or just boot and screenshot whatever loads first and check the log for exceptions.

dragonruby-live-inspection/SKILL.md click to expand
--- name: dragonruby-live-inspection description: Use when you need to inspect, query, or interact with a live running DragonRuby game — checking game state, verifying runtime behavior, debugging entities, or triggering actions in a running game instance. ---

DragonRuby has a built-in dev HTTP server with a remote eval endpoint that executes arbitrary Ruby in a running game and returns the result — full read/write access to live state without touching source files.

Prerequisites

# mygame/metadata/cvars.txt webserver.enabled=true webserver.port=9001 webserver.remote_clients=false

The server starts on the second tick after boot — wait ~3–5s after launch before requesting.

Eval endpoint (primary tool)

curl -s -H "Content-Type: application/json" \ --data '{ "code": "RUBY_EXPRESSION_HERE" }' \ -X POST http://localhost:9001/dragon/eval/

Common queries: current tick Kernel.tick_count; all state keys $gtk.args.state.as_hash.keys.to_s; inspect a key $gtk.args.state.some_key.to_s; serialize an entity $gtk.args.state.some_entity.serialize.to_s; framerate $gtk.current_framerate.to_sf.

You can also mutate state and call methods: $gtk.args.state.some_flag = true, MyModule.some_method($gtk.args), $gtk.write_file("dump.txt", …), $gtk.reset.

Input can't be injected via eval

The C engine controls input state and overwrites Ruby-level changes before the tick reads them — setting keyboard.key_down.space = true does nothing. Instead, call the function the key would trigger: MyModule.handle_action($gtk.args.state). For real input-driven testing, use replay files (--replay), which inject at the engine level.

Other endpoints

  • GET /dragon/log/ — full game log since boot
  • POST /dragon/reset/ — reset game state
  • POST /dragon/record/ · /record_stop/ · /replay/ — record & replay gameplay
  • GET /dragon/code/ — list loaded source files
  • GET /dragon/control_panel/ — HTML reset/record/replay buttons

Tips & common mistakes

Multiline code works (separate with \n or ;); the return value is the last expression; an empty response means it returned nil (add .to_s). Connection refused = the game hasn't finished booting, or the cvars weren't picked up (restart after changing them). Use single quotes around the --data JSON and escape inner quotes.

dragonruby-debug/SKILL.md click to expand
--- name: dragonruby-debug description: Diagnose and fix bugs in running DragonRuby GTK games — exceptions, rendering glitches, state/logic errors, input issues, timing bugs, and audio problems. Use when debugging a DR crash, an unexpected behavior, or a tricky runtime issue. ---

A debugging specialist for DragonRuby GTK. Combines runtime knowledge — 60fps loop, args.state persistence, hot-reload, the bottom-left 1280×720 coordinate system, the render layer order (solids → sprites → primitives → labels → lines → borders → debug) — with inventive techniques.

Where to look first

  • logs/exceptions/current.txt — most recent exception + backtrace
  • logs/exceptions/game_state_NNNN.txt — state snapshot at the exception tick
  • Live REPL: press ` / ~ in the running game — $gtk.args.state.inspect, GTK.reset
  • HTTP introspection via dragonruby-httpd (port 9001) — see the live-inspection skill

Exception workflow

  1. Read current.txt for the exception and backtrace.
  2. Read the matching game_state_NNNN.txt for exact state.
  3. Open the failing file at the backtrace line.
  4. Check the usual suspects: Hash/Array primitive mismatch, nil refs (missing ||=), audio props as int instead of float, top-left vs bottom-left coordinate confusion, render-target invalidation.
  5. Propose a specific fix with reasoning.

Debug rendering & watches

args.outputs.debug << "Keys: #{args.inputs.keyboard.key_down.truthy_keys}" args.outputs.borders << player # show hitbox args.outputs.debug << { x: player.x, y: player.y, w: 5, h: 5, r: 255, g: 0, b: 0 }

Remember: y = 0 is the BOTTOM. y > 720 is off the top, y < 0 off the bottom. A sprite needs a: 255 — alpha 0 is invisible.

Creative strategies

  • Time-travel: push args.state.serialize into a history ring, dump the last 60 frames when the bug fires.
  • Conditional breakpoint: on a bad condition, write state to a file, GTK.notify, $gtk.show_console, and gate the tick.
  • Differential state: serialize before/after a suspect function and diff the changed keys.
  • Assertions: assert(args.state.player.hp >= 0, "HP below zero") that dumps state and raises.

Avoid generic Ruby advice without DR context, "just add logging" without locations, or fixes proposed before reading the actual error.

dragonruby-perf/SKILL.md click to expand
--- name: dragonruby-perf description: Profile and optimize DragonRuby GTK games to hold a locked 60fps. Use when the user reports FPS drops, stuttering, or lag, wants to profile where frame time goes, or is adding render-heavy effects and wants to avoid regressions. ---

DragonRuby's loop assumes exactly 60 ticks/sec — a 16.67ms frame budget. Drop below and animation drifts, audio desyncs, input feels laggy. Profile first, then optimize.

Profiling workflow

Boot headlessly (see dragonruby-visual-test), navigate to the heavy scene, and instrument the tick to split logic vs render cost:

_t0 = Time.now.to_f handle_input(args, state); update_entities(args, state) _t1 = Time.now.to_f render_background(args, state); render_ui(args, state) _t2 = Time.now.to_f # logic = (_t1-_t0)*1000, render = (_t2-_t1)*1000; average over ~60 samples

Logic high → O(n) scans, allocations, Math calls. Render high → too many primitives or hash-form solids. Both low but FPS still drops → it's engine/audio overhead, or Xvfb's software renderer (test a known-simple scene to rule the environment out).

Top optimizations (in priority order)

  1. Array-form primitives — usually the single biggest win. mruby Hash allocation is expensive; a flat array skips it.
    # SLOW: a new Hash every frame args.outputs.solids << { x: 100, y: 200, w: 50, h: 10, r: 255, g: 120, b: 0, a: 200 } # FAST: flat array — [x, y, w, h, r, g, b, a] args.outputs.solids << [100, 200, 50, 10, 255, 120, 0, 200]
    Solids/borders [x,y,w,h,r,g,b,a], lines [x,y,x2,y2,r,g,b,a]. Labels are hash-only (and usually few).
  2. Kill Math.sin in hot paths — replace shimmer/pulse with a triangle wave or tick.even? ? 1 : -1. Keep it only for large, slow, visible oscillations.
  3. No per-frame O(n) scans — replace timeline.count { … } and .find { … } with incremental counters and forward-only cursors.
  4. Cache expensive lookups — don't File.exist? every frame; memoize in a global hash.
  5. Reduce density — a 60-solid FPS sparkline can cost more than the scene; show one label instead.
  6. Avoid args.audio.keys iteration — track active channels with a counter + expiry queue.

Verify, then clean up

Screenshot the FPS counter during the heaviest moment, check min FPS (not just average) across multiple phases, then remove the timing instrumentation — array-form primitives stay (they're the correct production form, not debug code).


The point isn't the screenshots. It's that the agent stops guessing: it edits a scene, boots the game, looks at the actual frame, and reports what it saw. These skills are how it does that — kept maintained by the agent that runs them.

← game dev