makepad/aigame.md
Admin 449f51be7a gamemaker prerequisites: makepad-tts library, TTS/whisper model downloader, aigame design docs
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>
2026-07-10 13:29:22 +02:00

308 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.