makepad/examples/gamemaker/resources/aigame-dsl.md
Admin fbd4972450 gamemaker: full engine round — chase camera rig, racing-game APIs, script stdlib math, real error line numbers
Engine (examples/gamemaker): chase camera rig (camera({chase}) — ease-behind
with mouse-wins/recenter authority), writable camera + look deltas, spatial
queries (raycast/overlap_sphere/ground_normal), save/load, sustained tones,
rot_y + collide:false spawnables, HUD slots/bars, terrain noise shaping +
height bands, per-shape instanced render batching with static slabs (3.2x),
error push-loop into the agent chat, unknown-verb/option diagnostics with
game.splash:line:col positions, streaming tail-statement finalization, quiet
toolbar UI, Shh voice hush, fable voice.

Platform: runtime vector methods (.length/.normalized/.dot/.cross), scalar+
vector lerp, TAU; ScriptVm error capture sink; window frame capture API; four
headless-JIT fixes (scalar casts, mat4 mul, Id-arg expansion, commuted
scalar-vec ops) with regression test stages. Widgets: single-line TextInput
baseline centering, TextFlow inline-code baseline alignment, glass button
corner_radius uniform. Voice/ggml Metal backends: debug logs behind
GGML_METAL_TRACE. splashgame.md: the runtime-loaded game API contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:20:03 +02:00

193 lines
12 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.

# The aigame DSL — authoring guide
The Game Maker's games are single `game.splash` files evaluated live inside the
app by `GameView` (examples/gamemaker/src/game_view.rs). This is the canonical
guide to the script surface; the per-game `resources/template/CLAUDE.md` is the
agent-facing copy stamped into every game project (keep the two in sync — the
template is what the AI actually reads).
## Execution model
- The file is a **program**, not a widget tree: statements run top to bottom
inside a dedicated splash isolate, with a native `game` handle injected.
- Every edit re-evaluates the WHOLE file incrementally (`eval_with_append_source`
parser checkpoints — the same machinery aichat uses to stream `runsplash`
blocks). Evaluation rebuilds the world from scratch; a clean eval swaps in
live, a failed eval is rolled back and the previous world keeps running
("last good"). Errors land in `.agent/last_error.txt` + `.agent/game.log`.
- After the build, the engine ticks at a fixed 60Hz: script `on_tick` closures
first, then timers, then physics (gravity + axis-separated AABB sweeps),
then sensor overlap events.
## Vocabulary
See the template CLAUDE.md for the full API table the AI is taught. Summary:
- **Spawn**: `game.box({pos, size, color, tag, sensor, body})`,
`game.mover({pos, size, color, tag, gravity})` → entity id (number).
Bodies: static (default), `"kinematic"` (script-moved platform, carries
riders), mover (gravity + collision).
- **Drive**: `game.on_tick(|dt, input| ...)`; `input` = `{left,right,up,down,
jump,jump_pressed,shoot,shoot_pressed,grab,grab_pressed,axis_x,axis_z,
move_x,move_z}` snapshot (no cross-boundary calls in the hot path).
`move_x/move_z` are the axes rotated by the effective camera yaw (0 for
`side:` cameras) — the canonical walk input. Keyboard (F=shoot, G=grab) and
gamepad (stick analog + dpad, A=jump, X=shoot, B=grab, merged at read time
via `PadState`, tape runs zero it) both feed everything. Terrain noise is
shapeable engine-side (`freq/offset/step/min/max/plaza`, cells ≤ 384) with
`ground_y(x,z)`/`ground_peak()` queries; labels draw with automatic dark
outlines. Verbs: `walk`, `jump`, `on_floor`, `pos`, `vel`, `set_pos`,
`set_vel`, `set_color`, `remove`, `find(tag)`, `tag(id)`, `distance(a,b)`,
`held/pressed/axis`, `gravity`, `camera({follow,distance,side,target})`,
`text(hud)`, `after(secs, fn)`, `on_touch(fn)`, `log(msg)`, `time()`,
`rand()`/`rand_range(a,b)` (xorshift seeded per eval → tape-deterministic,
unlike the Godot corpus's randomize()), `cam_yaw()` (orbit yaw for
camera-relative movement), `attach(id,owner,offset)`/`detach(id)` (vehicle
seats/carrying — pinned post-integration, physics skipped). Input adds
`shoot`/`shoot_pressed` (F). All five were added by the sandbox3d port —
see aigame_port_findings.md for the rationale trail.
- **Sound** (src/synth.rs, a polyphonic synth mixed into the app's audio
callback — the Godot corpus AI hand-built one of these in GDScript, so it's
an engine service now): `game.sfx(name, pitch?)` with a named bank (jump
shoot zap grab angry calm rescue shove board coin hurt win lose squeak roar
bark moo clank whip — the last four match the game's sfx.gd menagerie),
`game.beep({freq, to, ms, wave, gain})` (waves: sine/square/saw/triangle/
noise, `to` = pitch glide), `game.jingle("C5 E5 G5 C6", ms_per_note)`.
Voices are capped at 24 and mute with the app's mute button; headless runs
have no audio output, so tapes are unaffected.
- **Projectiles**: `game.spawn({pos, vel, life, hits, ...})` — a mover with a
lifetime (auto-removed) and, with `hits: true`, contact reporting through
`on_touch`: overlaps against movers/kinematics (movers pass through each
other, so overlap is the hit) plus whatever solid the sweep stopped it
against (`Entity::hit_wall`). Touch events fire every overlapping tick.
- **Terrain**: `game.terrain({size, cells, heights, colors, color, base,
seed, amp})` — engine-built column heightfield (one Static entity per cell;
row-major `heights`, parallel `colors` or auto-shaded `color`, built-in
terraced value noise fallback). An instanced-draw + height-lookup collision
variant is a deferred optimization; columns are plain entities today.
- **Decoration**: `game.part(owner, {pos, size, color})` visual-only child
boxes (drawn at owner + offset, pruned with their owner) and
`game.label(id, text)` billboard nametags (projected to the 2D overlay each
frame — always camera-facing, never occluded, like Godot's Label3D with
no_depth_test).
- **The look** (Fork A of the parity plan, aigame_parity_gap.md): entities
carry a visual-only model transform — auto-face yaw for movers (Godot's
`_drive()` turn-rate clamp; physics AABB never rotates), eased `scale`
(`game.scale`), per-entity/part emission (`glow:` opt + `game.glow`).
Parts are owner-local (rotate/scale with the model, front z), have ids,
and ease toward `game.move_part` pose targets (rate/sec, default 9) — the
arm-reach/ear-wiggle primitive. `game.sky({...})` = gradient dome (drawn as
an 800u cube around the camera) + exponential distance fog fed to every
shader. `game.terrain({smooth: true, water: h})` = one triangulated
heightfield mesh (PbrVertex geometry, flat per-tri normals, per-tri avg
colors) with height-lookup collision in the sweeps (CLIMB 0.55 step-up,
cliffs block sideways) — no column entities — plus a translucent water
sensor slab. Blob shadows render under every free mover (alpha quad at
ground height, fading over 8u). Shaders: DrawGameCube/Alpha/Sky/Terrain in
game_view.rs's script_mod, all verified against the headless JIT.
- **Systems** (Fork B of the parity plan): `game.attach` grows an options form
`{pos, mode: "ride", spin}` — "seat" (default) pins + faces with the owner,
"ride" latches a spinning rider (headcrab); `game.speed_mult(id, f)` scales
walk velocities engine-side (the debuffed script never knows).
`game.beam(from, to, {size, color, glow})` = immediate-mode stretched box
(cleared at every tick start; re-issue from on_tick — grapple cables).
`game.camera({third_person: id, height, boom, pitch})` = the Godot player
rig: pivot at entity+height, drag orbits (pitch clamped [-1.2, 0.25]), boom
marches in past terrain/solids (entities tagged "scenery" ignored), and
`move_x/move_z` stay camera-relative. HUD: `game.text(slot, msg, {color,
size})` slots center/top/hint + `game.crosshair(bool)` (a DrawColor dot);
labels are LabelDefs now — multiple per entity, `{height, color, size}`
opts, `game.label_text(lid, ...)` updates. Tape runs pin BOTH camera angles
(third-person: yaw 0/pitch 0.35; orbit: the widget defaults), so captures
are deterministic.
- **Racing batch** (features.md, my-game-5, 2026-07-10): writable camera
(`set_cam_yaw/pitch/dist/fov` + `cam_pitch/dist/fov/dragging` readers +
`input.look_dx/dy` — script writes go through cam_yaw/pitch_request so the
mouse and script share ONE authoritative rig; tapes pin everything),
`cam_shake` (tick-hashed offset, never the world rng — pixels wobble,
simulation doesn't), unknown VERBS are captured errors (eval fails,
last-good holds) and unknown OPTION keys log warnings (warn_unknown_keys
allow-lists per verb), `game.time()` resets per eval (snapshot-restored on
rollback), `rot_y`/`collide:false` on spawnables (visual yaw renders for
statics via the slab; decor = opaque + non-colliding, still raycast-hittable
and camera-blocking), `raycast`/`overlap_sphere`/`ground_normal` (step-march
vs terrain + AABBs, face normals from deepest axis), `push` (velocity add),
`save`/`load` (SaveVal map → .gamemaker/save.json, 1s debounced flush,
survives eval/reset by design), sustained `tone`/`tone_set`/`tone_stop`
(synth.rs Tone voices, 30ms param smoothing, killed on reset_content),
HUD named slots with 7 anchors + `bar` gauges + `format`, inputs `reset`
(R/pad-Y) + `back` (C), `every`/`cancel` repeating timers (after returns an
id too), `distance` takes ids or points.
- Adding a verb = one match arm in `game_dispatch` + a row in the template
CLAUDE.md. The dispatcher mutates a shared `Rc<RefCell<GameWorld>>`
synchronously — no async widget trampoline — so world-building finishes
before eval returns and ordering is deterministic.
## The harness (`tools/ag`)
File RPC through `<game>/.agent/`, answered by the GameView's tick:
| verb | request | answer |
|---|---|---|
| `ag peek` | `peek_request` | `live/f000N.png` ×4 (via `Cx::capture_next_frame_to_file`) + `live/state.txt` + `live/done` |
| `ag test N tape` | `test_request` (JSON) | game restarts, tape replays through the ActionMap, `cap/fNNNNNN.png` every K frames + `probe.txt`, then `test_done` |
| `ag errors` / `ag logs` | — | `last_error.txt` / tail `game.log` |
`sheet.py` (shared with the godot example) tiles captures into one labelled
contact sheet so the agent reads a single image.
**Errors are also PUSHED to the agent**, not only polled: `GameView` dispatches
`GameViewAction::{EvalOk, EvalFailed, RuntimeError}` (every class the isolate
produces — parse, runtime, pod, and shader-compiler errors all ride the same
captured-error sink with file:line intact). The app wakes the agent with a
SYSTEM prompt (visible in the chat as a "⚠ game error" bubble) when a turn
ends with the eval still broken, or when the running game hits a runtime error
while no turn is active (debounced per eval-generation + error hash). Guard:
at most 2 consecutive wake-ups without a kid message — then the status line
says "The game hit a snag — ask me to try again!" and the loop waits for a
human. `GAMEMAKER_NO_AGENT=1` disables the agent entirely (headless tests
must never spend tokens). Engine-level registration errors (the app's own
script VM) are deliberately NOT injected — the game AI can't fix those; they
re-log to the console and surface in the status bar.
Input tapes are frame-indexed `{"f":N,"press":"jump"}` JSON — the same format
the Godot harness used; actions are the ActionMap names (`left right up down
jump`). Keyboard and gamepad are disabled while a tape runs, the camera yaw is
pinned to 0 (camera-relative `move_x/move_z` must not depend on where the kid
left the orbit), and the world is re-evaluated first — so runs are repeatable.
## Renderer
Batching model (see the `PERF:` block at `draw_scene` in game_view.rs):
- **Shapes**: every entity/part carries a `shape:` — box (default), sphere,
cylinder, cone, wedge — a shared unit geometry spanning [-0.5, 0.5], scaled
by the instance's `cube_size`. Physics stays the AABB `size` box. Rendering
is **one draw call per shape per pass** (opaque + alpha), instanced via
`begin_many_instances` (no per-instance area bookkeeping); empty batches are
skipped. Windings are outward (the opaque pass backface-culls) — enforced by
the `shape_windings_face_outward` unit test.
- **Static slab**: instance data for static entities and settled parts of
static owners is packed once into per-shape `Vec<f32>` slabs and re-emitted
with a single `extend_from_slice` per frame. The slabs are keyed by
`GameWorld::render_rev`; every mutation that changes what static content
looks like must call `mark_render_dirty()` (spawn/remove/restyle/scale/
glow/move_part on statics, sky/fog changes, world reset, and a static
owner's part-animation settling). Forget the bump and the edit won't show.
- **Dynamics** (movers, kinematics, their parts, beams, blob shadows) repack
every frame; part easing skips settled parts (`anim_active`), and dynamic
part owners are resolved once per frame via binary search on the
spawn-ordered entity ids — never per shape.
- Measured on a 2000-static + 50 movers ×10 parts stress scene (headless CPU):
draw_scene 1117µs → **352µs** (3.2×), with correct visuals byte-identical
tape probes.
## Known gaps (extension points)
- `TODO(aigame)` markers in game_view.rs: box3d physics behind the same verbs
(the xr port has landed; the mini-AABB world is still in place).
- `capture_next_frame_to_file` grabs the whole window (chat + game). Good
enough for the agent; a pane-only capture needs a texture readback API for
child passes.
- Headless runs render frames to `MAKEPAD_HEADLESS_OUT_DIR` on their own; the
capture-to-file path is GPU-only (metal completion handler).