libs/tts was never tracked despite being a build dependency of the gamemaker example. tools/download_tts.sh fetches the public upstream weights (HuggingFace Kokoro-82M + whisper.cpp) and converts them locally with the in-repo stdlib-only converter; model artifacts are gitignored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
19 KiB
aigame — moving the AI Game Maker from Godot to Makepad
Plan for replacing the Godot backend of the kids' Game Maker (examples/godot) with a
small, AI-generatable game engine running inside makepad itself, built from three
things we already have: the splash script VM (hot-reloadable, per-isolate), the
box3d physics engine, and the xr 3D scene framework. Written 2026-07-09.
1. What we have today (the Godot version)
Where games go: ~/games/<name>/ (override with GAMEMAKER_HOME), one Godot 4.7
project per game. The app owns tools/ (agent harness) and CLAUDE.md; the kid's game
is project.godot + scenes/*.tscn + scripts/*.gd. Per-game app state (chat log,
Claude session id, model choice) lives in <game>/.gamemaker/.
The loop: kid holds F1 and talks (Whisper) or types → transcript goes to Claude Code
(via makepad_ai::ClaudeCodeAgent, tool policy locked to Read/Glob/Grep/Edit/Write +
tools/gd only) → Claude edits GDScript/tscn files → the app keeps the kid on the
last-good running game and relaunches it only when the turn completes.
What the AI actually builds (from the real ~/games/my-game, ~2600 lines generated):
- Everything is procedural colored boxes — no image/model/sound assets at all
(enforced by the system prompt). 2D:
StaticBody2D+ColorRectground segments, platforms, moving platforms, walls, a goal flag. 3D: cube ground/stairs/towers/trees, a gold goal block, box-people. - Character controllers:
CharacterBody2D/3D+ gravity + jump +move_and_slide, respawn on fall, mount/dismount vehicles. - Behaviors: per-frame
_physics_process(delta)steering (villagers chase, soldiers patrol), group queries (get_nodes_in_group("vehicle")), distance checks, spawn/despawn, timers (create_timer), win/lose conditions. - HUD:
Labels toggled visible ("You win!", "Caught!"). - Input through named actions only (
ui_left/ui_right/ui_accept) so keyboard, input tapes, and gamepad (AgentEye binds the A button) all work through one vocabulary.
This is the complete capability envelope a kids' game engine needs. It is small.
2. How the agent remote-operates the game and looks at pictures
Three mechanisms, all file-based (Claude only has tools/gd as a shell surface):
| Verb | Mechanism | What the AI gets |
|---|---|---|
gd peek |
Drops .agent/peek_request; the AgentEye autoload in the live game polls (250ms), grabs 4 viewport screenshots over ~1.2s via get_viewport().get_texture().get_image(), writes player pos/vel/is_on_floor() |
.agent/sheet.png + state.txt — sees what the kid sees, zero interruption |
gd shot <scene> [frames] [tape] |
Second Godot instance boots tools/harness.tscn; harness loads the target scene, replays a JSON input tape by frame number ({"f":30,"press":"ui_accept"} → Input.action_press), prints [probe] pos/vel/floor lines every 15 frames; recorded with --write-movie --fixed-fps 60 (deterministic: same tape ⇒ same frames). Since 2026-07-09 it launches via open -g + an unfocusable offscreen window so it never steals the kid's focus |
contact sheet + numeric probe log — "the jump feels floaty" becomes a number |
gd errors |
Greps the run log for SCRIPT ERROR / ERROR: |
error text |
The pictures: tools/sheet.py tiles N evenly-spaced frames into one labelled contact
sheet (.agent/sheet.png) so the agent reads one image showing motion over time instead
of 120 frames.
Weaknesses inherent to the Godot backend (what the migration removes):
- Separate process. Applying
.tscnchanges needs a full game restart (state loss, focus management, pid babysitting, zombie processes). We just spent a day making this tolerable; in-process it disappears entirely. - Capture needs a real (hidden) window — Godot's
--headlesscrashes with--write-movie. Makepad has a true headless CPU renderer. - Opaque runtime. The only introspection is print statements the harness happened to include; errors come from log-grepping.
- Two unfamiliar languages (GDScript + tscn) and a giant API surface the model can misuse. A curated DSL of ~25 constructs is easier to prompt and to verify.
3. Building blocks in makepad (surveyed 2026-07-09)
3.1 Splash script VM — the hosting/hot-reload story is already built
widgets/src/splash.rs: theSplashwidget evaluates DSL strings in a dedicated isolate VM (cx.alloc_splash_vm_with_network), with instruction limits (with_instruction_limit(200_000, …)), and re-evaluates incrementally viaeval_with_append_sourceparser checkpoints — this is how aichat streams a growingrunsplashblock into a live widget. Per-isolatelet/fnstate persists across events.- The DSL is a real language:
let/fn, templates,for,if/else, closures,on_click-style handlers, struct arrays,promise()/.await(), HTTP.splash.mdis the authoring manual the AI already follows;examples/splash_preview/is an offline generate-and-verify corpus harness (drives theclaudeCLI, evals every generated app, flags empty widget trees). - Gaps for games: no script-facing frame tick or timers, no keyboard events routed
into isolates, and eval errors are not fed back (a broken block renders blank; errors
only reach stderr via
ScriptVm::drain_errors). All three are core workstreams below.
3.2 box3d — the physics core (libs/box3d, pure Rust port of Erin Catto's Box3D)
- Shapes: sphere, capsule, convex hull (
make_box_hull), triangle mesh, heightfield, compound. Bodies: static/kinematic/dynamic. Full joint set. Sensors with begin/end touch events, contact/hit events, ray/shape casts, explosions, wind. - A kinematic character controller (
mover.rs,world_collide_mover,world_cast_mover) — exactly what the player/NPC vocabulary needs. - Bit-exact deterministic across architectures and worker counts, faster than Rapier on 8/9 benchmark scenes, with a built-in snapshot + record/replay substrate (hash-exact). This upgrades the whole verify story: same tape ⇒ same simulation, not just same frames.
- Consumer API is flat free functions (
create_world,create_body,create_hull_shape,world_step,body_get_transform). Rust-only today — zero script bindings. That binding layer is the single biggest work item. examples/box3d/src/main.rsalready shows the renderer we need: instanced lit boxes/spheres (DrawPhysMeshscript-shader, per-instance color+transform), anXrCameraorbit,NextFrame-driven stepping. ~600 lines, self-contained.
3.3 makepad-xr — the 3D scene layer (xr/, crate makepad-xr)
XrNodescene graph (pos/rot/scale, physics body kind, children), object library (Cube,IcoSphere,Gltf,FractalTree, splats), behaviors in Rust (Tank,Car,Shooter), 2D-UI-on-a-plane (XrView— a free HUD system), PBR-ish shading.- Worlds are authored in the script DSL and hot-reload (
XrNodehandlesapply.is_reload();on_renderclosures rebuild geometry live) — proof that "AI edits script → live 3D scene updates" already works in this codebase. - Desktop fallback: orbit camera + gamepad gameplay (
cx.game_input_states()). Quest: hands, passthrough, depth-scanned colliders, multiplayer (xr/net). - Caveats: physics is Rapier3D (not box3d), no audio, no keyboard gameplay input, and behaviors are compiled Rust (only the scene layer is scriptable).
3.4 Remote-operate substrate we already proved this session
- Headless renderer:
MAKEPAD=headlessbuilds render real frames to PNG on CPU with JIT-compiled shaders (--draws=N,MAKEPAD_HEADLESS_OUT_DIR). We used it today to pixel-verify a widget fix. This replaces the hidden-window capture instance outright. - makepad_test (
libs/makepad_test): selector-driven UI automation (click/fill/ wait_text) against a headless app instance, with failure screenshots — the skeleton of an input-tape runner. - Studio hub bridge:
WidgetTreeDump/Click/ScreenshotRPC into a running app — the skeleton ofpeekwithout file polling.
4. The aigame engine — design
Principle: keep the exact product shape (voice → Claude edits files → kid keeps playing until the AI is happy → instant apply), swap the engine underneath. The Game Maker app shell (chat, Whisper, TTS, sessions, model picker, relaunch policy) is reused as-is; only "relaunch Godot" becomes "hot-swap the game script".
4.1 Architecture
~/games/<name>/game.splash ← the file(s) Claude edits (Read/Edit/Write, same as now)
│ (file watch)
GameMaker app ─── GameHost widget ← owns a splash isolate + a box3d World + fixed 60Hz tick
│ │
│ ├─ shadow-eval on change: new isolate, eval, 1 smoke tick
│ │ ├─ clean → swap in (kid sees change in <1s, mid-play)
│ │ └─ errors → keep last-good running, errors go to the agent
│ ├─ renderer: instanced boxes/spheres/capsules (from examples/box3d)
│ ├─ HUD: plain makepad widgets overlaid (or XrView in 3D)
│ └─ ActionMap: keyboard + gamepad + tape → named actions
└─ agent harness (tools/ag): test / peek / errors — see §5
- One process. The game is a widget in the Game Maker window (optionally poppable into its own window later). No pids, no focus stealing, no restart.
- "It works" state, upgraded: today the kid keeps the old process; here the last-good isolate + world keep running while the new source shadow-evals. A turn that ends broken never even flickers the kid's game — and the AI gets the error text immediately instead of a blank screen (fixes the Splash blank-on-error gap).
- Fixed timestep (1/60, 4 substeps, like the box3d example) for determinism; render interpolation optional later.
4.2 The script surface the AI writes (curated, not raw bindings)
Bind a small game vocabulary into the isolate rather than exposing raw box3d — a ~25-construct API is easier to prompt, to sandbox, and to keep stable. Sketch (syntax illustrative, follows splash rules):
// game.splash — everything the AI edits lives here
let SPEED = 240.0
let JUMP = 520.0
fn build_world() {
game.gravity(vec3(0, -30, 0))
game.box{pos: vec3(0, -1, 0) size: vec3(120, 2, 8) color: #x3a8f4a} // ground
for i in 0..6 {
game.box{pos: vec3(10 + i * 8, i * 2, 0) size: vec3(4, 1, 4) color: #x8a6a3a}
}
game.box{pos: vec3(58, 13, 0) size: vec3(2, 2, 2) color: #xf5c13a tag: "goal" sensor: true}
}
player := game.mover{pos: vec3(0, 2, 0) size: vec3(1, 2, 1) color: #x4466aa lock_z: true}
npc := game.mover{pos: vec3(30, 2, 0) size: vec3(1, 2, 1) color: #xaa4444 lock_z: true
on_tick: |dt| { self.walk_towards(player.pos(), 3.0) }
}
player.on_tick: |dt| {
self.walk(input.axis("left", "right") * SPEED * dt)
if input.pressed("jump") && self.on_floor() { self.jump(JUMP) }
if self.pos().y < -20 { self.teleport(vec3(0, 2, 0)) }
}
game.on_touch: |a, b| {
if a.tag() == "goal" || b.tag() == "goal" { ui.hud_win.set_visible(true) }
}
game.camera.follow(player, side_2d: true)
Vocabulary checklist, derived 1:1 from what the Godot corpus actually used:
| Corpus need (Godot) | aigame construct | Backed by |
|---|---|---|
| ColorRect/box world building | game.box/sphere/capsule{...} (static/dynamic/kinematic, color, tag, sensor) |
box3d shapes + instanced DrawPhysMesh |
| CharacterBody + move_and_slide | game.mover{...} + walk/jump/on_floor/teleport |
box3d mover.rs character controller |
_physics_process(delta) |
on_tick: |dt| {...} per entity + game.on_tick |
fixed-step pump into isolate |
groups / get_nodes_in_group |
tag: + game.find("tag"), e.distance_to(x) |
engine-side registry |
| Area2D / goal triggers | sensor: true + game.on_touch |
box3d sensor events |
| moving platforms | kinematic body + on_tick setting velocity |
box3d kinematic |
| vehicles/mount | attach/detach (weld joint or parent) |
box3d joints |
| HUD labels | plain widgets over the viewport (ui.hud_win…) |
makepad widgets (free) |
| respawn / timers | teleport, game.after(secs, || {...}) |
engine timer wheel |
| input actions | input.pressed("jump"), input.axis(..) |
ActionMap (§4.3) |
| 2D platformer | lock_z: true + side_2d camera |
box3d motion locks / parallel joint — verify which; fallback: post-step plane clamp |
| win/lose sounds | game.beep{...} synth SFX |
cx.audio_output (already used for TTS) |
4.3 Input: one ActionMap for keyboard, gamepad, and tapes
Mirror the Godot design that made tapes+controllers free: script code only ever sees
named actions (left/right/jump/up). The engine maps arrow keys/WASD + gamepad
(cx.game_input_states(), as xr's Tank does) + tape events onto the same names.
Makepad has full keyboard events (Event::KeyDown/KeyCode) — xr just never wired them;
we wire them in the GameHost, not in xr.
4.4 Renderer choice
Phase 1: lift examples/box3d's renderer (instanced lit unit-cube/sphere +
XrCamera, ~200 lines) into the GameHost. It draws exactly the corpus art style.
Later: converge with xr — adopt XrNode/XrView for scene+HUD and port xr's physics
from Rapier to box3d (justified independently: box3d is faster on 8/9 scenes and
deterministic; one physics engine in the tree instead of two). That convergence buys
Quest/hands/multiplayer for the same game scripts — a kid's game playable in VR — but
it is explicitly not on the critical path.
5. The agent harness on makepad (remote-operate, tier by tier)
Same three verbs, better substrate. tools/ag (or a gd-compatible shim so the prompt
barely changes):
| Verb | Godot today | aigame |
|---|---|---|
ag test [frames] [tape] |
hidden Godot instance, --write-movie, probe prints | headless run of the same GameHost (MAKEPAD=headless): eval game.splash, feed the tape into the ActionMap, world_step N frames, render PNGs on CPU, emit probe lines (engine reads pos/vel/on_floor directly — no print statements needed). box3d determinism ⇒ bit-exact repeatability, stronger than Godot's fixed-fps movie |
ag peek |
file-RPC into live game (AgentEye), viewport screenshots | in-process: the app screenshots its own game pass texture + dumps entity state on request (file trigger kept for CLI compat, or a local socket). Kid keeps playing, same as now |
ag errors |
grep run logs | drain the script VM error queue — precise parse/runtime errors with line numbers, returned as text. Also auto-attached to the turn when a shadow-eval fails, so the AI often self-corrects without running anything |
| pictures | sheet.py contact sheet |
keep sheet.py verbatim (it's engine-agnostic: dir of PNGs → one labelled sheet) |
| tapes | JSON {"f":N,"press":"ui_accept"} |
same format, actions renamed; probe list = tags |
Bonus unlocked by box3d: ag test --record / snapshot scrubbing — the AI can capture a
deterministic recording once and re-probe it at different frames without re-running.
6. Changes to the Game Maker app (small)
play_game()/relaunch_if_pending()→GameHost::reload(path)(shadow-eval + swap). The turn-completion policy from 2026-07-09 stays: kid keeps last-good until the AI is happy — it just gets cheaper (no process restart, sub-second apply).- System prompt + per-game
CLAUDE.md: rewritten against the aigame DSL; ship a newaigame-dsl.mdauthoring guide (thesplash.mdequivalent for games: the vocabulary table, tick/input/tape rules, the#xhex rule, worked platformer example). - Template:
~/games/<name>/game.splashstarter +tools/ag+ tapes.refresh_harness(added today) already re-stamps tools on project switch. - Permission policy shrinks: Claude gets
Edit/Write(./**)+Bash(./tools/ag:*)only.
7. Phases and milestones
Phase 0 — proof of loop (the risky part, do first)
Script-bind the minimum box3d surface (world/step, box+sphere bodies, transforms) into a
splash isolate; GameHost widget with fixed tick, keyboard ActionMap, on_tick dispatch
into script closures; lift the box3d example renderer.
Milestone: a ~100-line game.splash platformer (locked z) runs at 60fps and hot-reloads
on file save without dropping world state of the running instance until swap.
Verify here: per-tick script-call overhead with ~20 entities × 60Hz under instruction
limits; box3d 2D locking mechanism; headless JIT compiles DrawPhysMesh (we fixed the
scalar-cast JIT bug today — same risk class).
Phase 1 — the corpus vocabulary
Mover controller, sensors → on_touch, tags/queries, spawn/despawn, timers, camera
follow (side-2D + third-person), HUD overlay widgets, gamepad, synth SFX (game.beep).
Milestone: hand-port ~/games/my-game (both the 2D level and the 3D chase sandbox) to
game.splash — the real generated corpus is the acceptance test for vocabulary
completeness.
Phase 2 — the agent loop
Headless ag test (tape → frames → probe → sheet), ag peek, VM-error round-trip +
shadow-eval, Game Maker integration, new prompt + aigame-dsl.md, template swap.
Milestone: end-to-end kid session: "make him jump higher" → edit → self-test headless →
turn completes → game hot-swaps mid-play; zero focus steal; broken edits never reach the
kid and come back to the AI as line-numbered errors.
Regression harness: splash_preview-style batch corpus — a set of recorded kid requests
run through the real claude CLI against aigame-dsl.md, each result eval-checked and
tape-smoke-tested headlessly.
Phase 3 — convergence and reach
Port makepad-xr physics Rapier→box3d; host aigame scenes on XrNode/XrView; the same
game.splash then runs on Quest (hands/passthrough) and inherits xr multiplayer.
Optional: box3d record/replay scrubbing in the harness; state-preserving hot reload via
world snapshots.
8. Open questions / decision log
- Curated
game.*API vs raw box3d bindings — recommended: curated (smaller prompt, stable across engine refactors, sandboxable). Raw bindings can come later for power use. - 2D story — one engine (3D + locked axis + orthographic-ish side camera), not a second 2D engine. Needs the motion-lock verification in Phase 0.
- Where behaviors live — corpus says script-side
on_tickclosures suffice (steering is ~10 lines); compiled-Rust behaviors (xr's Tank pattern) stay an escape hatch for things script is too slow for. - Splash-in-chat vs GameHost — games do NOT run as
runsplashchat blocks; the GameHost is a dedicated widget with its own isolate, tick, and input focus. Chat blocks stay for the AI showing UI snippets. - Error feedback for aichat generally — the VM-error round-trip built for aigame
(drain_errors → agent) should be upstreamed to the
Splashwidget too; blank-on-error hurts every runsplash use case.