makepad/splashgame.md
Admin 4f9ce7a8bb gamemaker: right analog stick rotates the camera, engine-default
Deadzone-rescaled right stick feeds the exact mouse-drag pipeline: same
0.01 rad/px orbit through pseudo-pixels (~2.6 rad/s full deflection, stick
up = look up), same look_dx/look_dy for scripts, same chase-rig authority
(stick held = kid owns the camera, recenters after release) and
cam_dragging visibility. Applied before script camera writes each tick,
like real mouse events, so set_cam_yaw still wins its tick. Zeroed under
tape tests for determinism. Camera-only pads now count in device selection.

splashgame.md: right stick documented; new rule — every new ability must
also be reachable from the gamepad (bind to the named actions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:18:55 +02:00

17 KiB
Raw Permalink Blame History

Splash Game DSL Guide

The complete game.* API for games running in the Makepad Game Maker (examples/gamemaker). A game is ONE splash script — game.splash — evaluated live: statements run top to bottom, build the world, then drive it from game.on_tick. Every clean edit hot-reloads the running world instantly; a broken edit never replaces it (the last working world keeps running and the error waits in ./tools/ag errors).

This file is loaded into the game-making agent's system prompt by the app (like splash.md is for UI work). Keep it in sync with the engine: adding a verb = a match arm in examples/gamemaker/src/game_view.rs game_dispatch + a row here.

Key rules

  • Declare fn helpers at the top; let bindings may interleave with build statements (let player = game.mover(...) after terrain is fine).
  • Property syntax is name: value (colon); calls take ({...}) object args.
  • Hex colors containing the letter e need the #x prefix: #x2ecc71, #x1e1e2e. Colors without e (like #ff4444) work with plain #.
  • The file re-runs from the top on each edit — don't accumulate state across edits; rebuild it. game.time() restarts at 0 on every reload.
  • Closures capture top-level let variables — that's how on_tick remembers entity ids. Captured variables are MUTABLE and persist across ticks: lap counters, cooldowns, game phases are just let score = 0 at the top and score = score + 1 inside on_tick.
  • Math: sin cos sqrt atan2(y,x) abs min max floor round sign clamp, %, lerp(a, b, t) (scalars and vectors), constants PI and TAU, and vector methods v.length(), v.normalized(), a.dot(b), a.cross(b) — steering AI is (target - me).normalized() * speed.
  • Everything is procedural: colored shapes and synthesized sound. No image, model, or audio files — no files besides game.splash.
  • ALWAYS check ./tools/ag errors after editing. Empty = live. Error = the player still sees the OLD world.

The shape of a game

let SPEED = 6.0
let JUMP = 11.0

game.sky({})
game.terrain({size: 160, cells: 257, smooth: true, water: 3.5, seed: 7,
              freq: 0.014, offset: 0.5, amp: 30, step: 0.5, min: 0.5, max: 26,
              bands: [{h: 3.6, color: #xd9c780}, {h: 13.0, color: #x5cad4c},
                      {h: 999.0, color: #xf0f5ff}],
              plaza: {r: 26, ramp: 14, h: 7}})

let player = game.mover({pos: vec3(-4, 9, 8), size: vec3(0.8, 1.6, 0.8), color: #x4a7fd6, tag: "player"})
game.part(player, {pos: vec3(-0.18, 0.55, -0.38), size: vec3(0.14, 0.14, 0.06), color: #x11131a})
game.part(player, {pos: vec3(0.18, 0.55, -0.38), size: vec3(0.14, 0.14, 0.06), color: #x11131a})
game.label(player, "You")
game.camera({third_person: player, height: 1.6, boom: 10, pitch: -0.35})

game.on_tick(|dt, input| {
    game.walk(player, input.move_x * SPEED, input.move_z * SPEED)
    if input.jump_pressed && game.on_floor(player) {
        game.jump(player, JUMP)
        game.sfx("jump")
    }
    if game.pos(player).y < -12 { game.set_pos(player, vec3(-4, 9, 8)) }
})

Spawning (returns an entity id)

call meaning
game.box({pos, size, color, tag, sensor, collide, body, glow, shape, rot_y}) a solid. sensor: true = no collision, reports touches (goals, pickups), drawn translucent. collide: false = opaque DECORATION — looks solid, no physics (rotated road slabs!). rot_y: 0.6 turns the visual (collision stays the axis box). body: "kinematic" = script-moved platform (set its vel; movers standing on it are carried). glow: 2 = emissive
game.mover({pos, size, color, tag, gravity, turn_rate, shape}) a character: gravity + collides with the world. gravity: 0 floats. Movers auto-face where they walk (front = -z); turn_rate rad/s (default 7)
game.spawn({pos, vel, size, color, tag, life, hits, gravity, glow, shape}) a projectile: auto-removed after life seconds; hits: true reports everything it touches through on_touch (creatures AND walls)
game.part(owner, {pos, size, color, glow, rot_x/rot_y/rot_z, shape}) → part id a visual-only shape welded to an entity IN ITS FRAME (turns and scales with it; front = -z): eyes, arms, ears, horns, hats, wheels. No collision; dies with its owner
game.terrain({...}) the whole landscape in ONE call — see Terrain below
game.label(id, "Bob") floating outlined nametag above an entity, camera-facing. "" removes. Extra labels: game.label(id, "HELP!", {height: 2.4, color, size}) → label id, update via game.label_text(lid, "...")

shape: on any of the above picks the visual: "box" (default), "sphere" (alias "ball"), "cylinder", "cone", "wedge" (alias "ramp"). Collision is always the size box — shape is looks only. Round eyes (shape: "sphere"), cone horns, cylinder tree trunks, wedge ramps: use them — creatures made only of boxes look stiff. Rendering is instanced per shape, so mixing shapes is free.

Terrain

game.terrain({size: 160, cells: 257, smooth: true, water: 3.5,
              seed: 7, freq: 0.014, offset: 0.5, amp: 30, step: 0.5,
              min: 0.5, max: 26, plaza: {r: 26, ramp: 14, h: 7},
              bands: [{h: 3.6, color: sand}, {h: 13, color: grass},
                      {h: 17.5, color: dirt}, {h: 21, color: stone},
                      {h: 999, color: snow}]})
  • smooth: true always for outdoor worlds — one connected rolling-hills mesh, walkable slopes, collision by ground height. Without it: stepped columns.
  • Engine noise (seed/freq/offset/amp/step/min/max/plaza) costs NO script budget — up to cells: 384. step = terrace size (0 = smooth), plaza flattens a disc at the origin, the max clamp carves plateau peaks.
  • bands paints by height — snow above stone is what makes distant hills read as MOUNTAINS. Or pass heights: (flat row-major z * cells + x array) and colors: for hand-built ground; or color: auto-shades one color.
  • water: 3.5 adds a translucent lake sheet (a sensor tagged "water").
  • game.ground_y(x, z) → height there; game.ground_peak() → vec3 of the highest point. Place spawns, trees, and the goal ON the terrain with these.

Driving the game

game.on_tick(|dt, input| ...) runs 60×/second, fixed step. input fields: left right up down jump shoot grab reset back (held), jump_pressed shoot_pressed grab_pressed reset_pressed back_pressed (this tick only), axis_x axis_z (raw 1..1), move_x move_z — the axes rotated to match the camera. ALWAYS walk with these (raw axes only for side: true 2D games), and look_dx look_dy — the camera-look delta this tick (0 unless the kid is mouse-orbiting or on the gamepad's RIGHT stick; chase cams use it to yield to the kid's hand). Keyboard (WASD/arrows, Space, F shoot, G grab, R reset — kids get stuck upside-down, give them a reset! — C back) and gamepad (left stick/dpad move, RIGHT stick rotates the camera like the mouse, A jump, X shoot, B grab, Y reset) both feed everything automatically — never write your own camera-from-stick code.

When you add an ability, always give it a gamepad path too: bind it to one of the named actions above (jump shoot grab back reset all have pad buttons) instead of inventing keyboard-only triggers, so the kid on a controller is never locked out of something you built.

call meaning
game.walk(id, vx, vz) set horizontal velocity (vertical untouched)
game.jump(id, v) set upward velocity (check game.on_floor(id))
game.on_floor(id) standing on something?
game.pos(id) / game.vel(id) vec3 position / velocity
game.set_pos(id, v) teleport (zeroes velocity)
game.set_vel(id, v) set full velocity
game.face(id, yaw) / game.yaw(id) override / read facing. The override is STICKY (walking doesn't revert it); game.face(id) with no yaw hands facing back to auto-face
game.find("tag") array of ids with that tag
game.tag(id) / game.distance(a, b) tag / distance — a/b may each be an entity id OR a vec3 point (checkpoints are positions)
game.remove(id) despawn (parts and labels go with it)
game.attach(id, owner, offset) / game.detach(id) seat-mount (vehicles, carrying) — rider faces with the owner
game.attach(id, owner, {pos, mode: "ride", spin: 2}) latch ON someone (headcrab): pinned each frame, model spins
game.speed_mult(id, 0.5) scale an entity's walk speed engine-side (debuffs) until changed
game.push(id, v) ADD to velocity (a shunt, a gust) — set_vel overwrites, push nudges. Movers pass through each other: to bump someone, detect overlap (hits/on_touch/overlap_sphere) and push them
game.raycast(from, dir, max) → nil or {hit, pos, normal, dist}. Hits terrain (hit = -1), walls, creatures, decor. THE sense for wall-avoiding AI, brake-for-the-car-ahead, line of sight, aimed guns. It also hits the caster — cast from just outside your own body, or skip a hit whose id is you
game.overlap_sphere(pos, r) → array of entity ids near a point
game.ground_normal(x, z) → terrain surface normal (align cars to slopes)
game.save("best_lap", 42.3) / game.load("best_lap", 999) persist numbers/strings across edits, reloads AND app restarts — high scores live here. Second load arg = default
game.every(secs, || ...) → timer id / game.cancel(id) repeating timer (game.after also returns a cancellable id now)
game.after(secs, || ...) → timer id run once, later; game.cancel(id) aborts it
game.on_touch(|a, b| ...) a sensor overlapped a mover, or a hits projectile touched something. Fires EVERY overlapping tick — latch or remove
game.rand() / game.rand_range(a, b) random, seeded per eval — replays stay repeatable (never bring your own RNG)
game.held("left") / game.pressed("jump") / game.axis("left","right") input outside on_tick
game.log("msg") / game.time() debug line into .agent/game.log / seconds since reload
game.api() dump every verb + its option keys into .agent/game.log — self-lint when an option "did nothing"

The look

call meaning
game.sky({}) daylight gradient sky + distance fog. Call it for every outdoor game
game.set_color(id, c) / game.glow(id_or_part, e) restyle / emissive energy (eyes 34; ramp it with AI state)
game.scale(id, s) ease the whole model's scale (giants 1.9, sleep-curl via vec3(1, 0.6, 1))
game.move_part(part, {pos, rot_x/y/z, size, rate}) ease a part toward a pose (arm reach: {rot_x: -1.5}); rate defaults 9/s
game.beam(a, b, {size: 0.12, color, glow}) a stretched cable/laser between two points — re-issue it every tick while it exists (grapple ropes, tethers)
game.camera({third_person: id, height: 1.6, boom: 10, pitch: -0.35, fov: 70}) THE camera for 3D exploring: drag looks, wheel zooms, slides in when hills block the view. Tag pure-decoration entities "scenery" so they don't pull the camera in. Also: {follow: id, distance: 16} orbit, {side: true} 2D platformer
game.camera({chase: id, boom: 13, height: 2.4, pitch: -0.22, lag: 0.3, recenter: 1.2, speed_tighten: 0.15}) the racing camera in ONE line — third_person's rig plus engine-side ease-behind-the-target. lag = ease time-constant (s); speed_tighten tightens it with the target's speed; the kid's drag takes over instantly and the rig resumes recenter s after the drag ends (wheel zoom is never fought). Angle wrapping is handled engine-side — do NOT hand-roll yaw math on top. chase: 0 stops the easing, keeping the rig for the mouse
game.set_cam_yaw(a) / game.set_cam_pitch(p) / game.set_cam_dist(d) / game.set_cam_fov(f) WRITE the camera — the same state the mouse drags. Writes stick: under a chase rig a write becomes the new camera state and easing continues from there (a scripted look-at burst just works)
game.cam_yaw() game.cam_pitch() game.cam_dist() game.cam_fov() game.cam_dragging() read the whole camera pose (preserve the kid's wheel zoom before scripting it)
game.cam_shake(0.4) impact shake — decays over ~half a second, stacks
game.text("You win!") big center banner; "" clears. Named slots: game.text("lap", "LAP 2/3", {anchor: "top_right", color, size}) — anchors top_left top top_right center bottom_left bottom bottom_right; slots stack per anchor. "hint"/"top"/"center" keep their classic homes
game.bar("speed", 0.62, {color, anchor}) a gauge (speedometer, boost). Negative fraction removes it
game.format(3.14159, 2) → "3.14" — lap times without hand-rolled math
game.crosshair(true) center aiming dot (shooting games)

Blob shadows under movers, label outlines, near-camera clipping (a creature overlapping the lens clips open instead of filling the screen) are automatic.

House style: give every creature a face (game.part eyes) and a name (game.label) — two lines each, do it without being asked. Build big characters from many parts and animate them with move_part/scale/glow.

Sound (all synthesized — never files)

call meaning
game.sfx("jump") named bank: jump shoot zap grab angry calm rescue shove board coin hurt win lose squeak roar bark moo clank whip. Pitch: game.sfx("bark", 1.4) — animals sound distinct by pitch (chicken high, cow low)
game.beep({freq: 440, to: 880, ms: 120, wave: "square", gain: 0.25}) one tone; to glides pitch; waves: sine square saw triangle noise
game.jingle("C5 E5 G5 C6", 100) note names at N ms/note (sharps: "F#5")
game.tone({freq: 80, wave: "saw", gain: 0.15}) → tone id a SUSTAINED tone — the car-engine primitive. Starts and keeps sounding
game.tone_set(id, {freq: 80 + speed * 6}) retune it per tick — smoothed, never retriggers
game.tone_stop(id) fade it out. Tones also stop on every reload (no stuck hums)

Add sounds without being asked — jumps, pickups, winning. They make it real.

Checking your work — ALWAYS

  1. ./tools/ag errors after every edit. Empty = your edit is live.
  2. Playtest: ./tools/ag test 120 tools/tapes/selftest.json — replays a frame-numbered input tape, writes .agent/sheet.png (frames over time) and .agent/probe.txt (pos/vel of probed tags every 15 frames). Read the image, read the numbers — "the jump clears the step" should be a probe line you saw. Same tape = same frames, byte-identical.
  3. ./tools/ag peek — 4 screenshots of the live game + entity state, without interrupting the player.
  4. ./tools/ag logs — your game.log() lines + eval reports.

Tapes: {"probe": ["player"], "events": [{"f":5,"press":"right"}, {"f":30,"press":"jump"},{"f":33,"release":"jump"}]} — actions are the input names above (left right up down jump shoot grab).

Gotchas found the hard way

  • Movers are ~0.8×1.6×0.8. Keep playfields within the terrain you built.
  • Use tags + game.find for groups (coins, enemies) — like scene groups.
  • on_touch fires every overlapping tick: latch with a bool or remove the sensor, or you'll play 60 win jingles a second.
  • turn_rate: 0 on a mover means "NEVER auto-face" — steer its visual with game.face yourself (cars want this). One game.face(id, yaw) call takes over facing permanently; game.face(id) gives it back to auto-face.
  • Typos are loud now: an unknown game. verb FAILS the eval (the kid keeps the old world; the error gives the game.splash line, names the verb, and suggests the nearest real one); an unknown option key logs a warning to .agent/game.log and ag errors shows the warning count. If a thing you set "did nothing", check both — or game.api() to see the real keys.
  • Errors report REAL game.splash line numbers (game.splash:118:9) — trust them, jump straight there.
  • game.time() restarts at 0 on every reload — durable numbers (best laps, high scores) belong in game.save/game.load.
  • Small, visible changes. Tune constants and add shapes; avoid big rewrites.
  • Intercept AI (bodyguards): pick threats with TWO distance gates (threat-to-player AND threat-to-me), steer at threat + (player-threat) .normalized() * 2, act within a bonk range. The engine gives you find, distance, pos — the brains are yours.
  • Weeping-angel AI: freeze when watched — game.cam_yaw() gives the camera yaw; the look direction is (sin(yaw), -cos(yaw)) on the ground plane (the same rotation move_x/move_z use); dot it with the direction to-me and gate on > 0.55. Note the camera yaw is NOT an entity yaw — entities face (-sin(e_yaw), -cos(e_yaw)); the x sign differs. Don't equate the two — that's why chase cams belong to camera({chase}), not hand-rolled math.