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

12 KiB
Raw Permalink Blame History

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