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>
12 KiB
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
gamehandle injected. - Every edit re-evaluates the WHOLE file incrementally (
eval_with_append_sourceparser checkpoints — the same machinery aichat uses to streamrunsplashblocks). 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_tickclosures 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_zare the axes rotated by the effective camera yaw (0 forside: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 viaPadState, tape runs zero it) both feed everything. Terrain noise is shapeable engine-side (freq/offset/step/min/max/plaza, cells ≤ 384) withground_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 addsshoot/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, withhits: true, contact reporting throughon_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-majorheights, parallelcolorsor auto-shadedcolor, 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) andgame.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), easedscale(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 towardgame.move_partpose 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.attachgrows 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), andmove_x/move_zstay 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/draggingreaders +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:falseon 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), sustainedtone/tone_set/tone_stop(synth.rs Tone voices, 30ms param smoothing, killed on reset_content), HUD named slots with 7 anchors +bargauges +format, inputsreset(R/pad-Y) +back(C),every/cancelrepeating timers (after returns an id too),distancetakes ids or points. - Adding a verb = one match arm in
game_dispatch+ a row in the template CLAUDE.md. The dispatcher mutates a sharedRc<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'scube_size. Physics stays the AABBsizebox. Rendering is one draw call per shape per pass (opaque + alpha), instanced viabegin_many_instances(no per-instance area bookkeeping); empty batches are skipped. Windings are outward (the opaque pass backface-culls) — enforced by theshape_windings_face_outwardunit 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 singleextend_from_sliceper frame. The slabs are keyed byGameWorld::render_rev; every mutation that changes what static content looks like must callmark_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_filegrabs 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_DIRon their own; the capture-to-file path is GPU-only (metal completion handler).