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>
308 lines
19 KiB
Markdown
308 lines
19 KiB
Markdown
# 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`+`ColorRect` ground 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**: `Label`s 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):
|
||
|
||
1. **Separate process.** Applying `.tscn` changes 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.
|
||
2. **Capture needs a real (hidden) window** — Godot's `--headless` crashes with
|
||
`--write-movie`. Makepad has a true headless CPU renderer.
|
||
3. **Opaque runtime.** The only introspection is print statements the harness happened to
|
||
include; errors come from log-grepping.
|
||
4. **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`: the `Splash` widget 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** via
|
||
`eval_with_append_source` parser checkpoints — this is how aichat streams a growing
|
||
`runsplash` block into a live widget. Per-isolate `let`/`fn` state 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.md` is the
|
||
authoring manual the AI already follows; `examples/splash_preview/` is an offline
|
||
generate-and-verify corpus harness (drives the `claude` CLI, 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.rs` already shows the renderer we need: instanced lit
|
||
boxes/spheres (`DrawPhysMesh` script-shader, per-instance color+transform), an
|
||
`XrCamera` orbit, `NextFrame`-driven stepping. ~600 lines, self-contained.
|
||
|
||
### 3.3 makepad-xr — the 3D scene layer (`xr/`, crate `makepad-xr`)
|
||
|
||
- `XrNode` scene 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** (`XrNode` handles
|
||
`apply.is_reload()`; `on_render` closures 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=headless` builds 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` / `Screenshot` RPC into a running
|
||
app — the skeleton of `peek` without 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):
|
||
|
||
```splash
|
||
// 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 new
|
||
**`aigame-dsl.md`** authoring guide (the `splash.md` equivalent for games: the
|
||
vocabulary table, tick/input/tape rules, the `#x` hex rule, worked platformer example).
|
||
- Template: `~/games/<name>/game.splash` starter + `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
|
||
|
||
1. **Curated `game.*` API vs raw box3d bindings** — recommended: curated (smaller prompt,
|
||
stable across engine refactors, sandboxable). Raw bindings can come later for power use.
|
||
2. **2D story** — one engine (3D + locked axis + orthographic-ish side camera), not a
|
||
second 2D engine. Needs the motion-lock verification in Phase 0.
|
||
3. **Where behaviors live** — corpus says script-side `on_tick` closures suffice (steering
|
||
is ~10 lines); compiled-Rust behaviors (xr's Tank pattern) stay an escape hatch for
|
||
things script is too slow for.
|
||
4. **Splash-in-chat vs GameHost** — games do NOT run as `runsplash` chat blocks; the
|
||
GameHost is a dedicated widget with its own isolate, tick, and input focus. Chat
|
||
blocks stay for the AI showing UI snippets.
|
||
5. **Error feedback for aichat generally** — the VM-error round-trip built for aigame
|
||
(drain_errors → agent) should be upstreamed to the `Splash` widget too; blank-on-error
|
||
hurts every runsplash use case.
|