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>
17 KiB
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
fnhelpers at the top;letbindings 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
eneed the#xprefix:#x2ecc71,#x1e1e2e. Colors withoute(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
letvariables — that's howon_tickremembers entity ids. Captured variables are MUTABLE and persist across ticks: lap counters, cooldowns, game phases are justlet score = 0at the top andscore = score + 1insideon_tick. - Math:
sin cos sqrt atan2(y,x) abs min max floor round sign clamp,%,lerp(a, b, t)(scalars and vectors), constantsPIandTAU, and vector methodsv.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 errorsafter 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: truealways 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 tocells: 384.step= terrace size (0 = smooth),plazaflattens a disc at the origin, themaxclamp carves plateau peaks. bandspaints by height — snow above stone is what makes distant hills read as MOUNTAINS. Or passheights:(flat row-majorz * cells + xarray) andcolors:for hand-built ground; orcolor:auto-shades one color.water: 3.5adds 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 3–4; 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
./tools/ag errorsafter every edit. Empty = your edit is live.- 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. ./tools/ag peek— 4 screenshots of the live game + entity state, without interrupting the player../tools/ag logs— yourgame.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.findfor groups (coins, enemies) — like scene groups. on_touchfires every overlapping tick: latch with a bool or remove the sensor, or you'll play 60 win jingles a second.turn_rate: 0on a mover means "NEVER auto-face" — steer its visual withgame.faceyourself (cars want this). Onegame.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.logandag errorsshows the warning count. If a thing you set "did nothing", check both — orgame.api()to see the real keys. - Errors report REAL
game.splashline 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 ingame.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 youfind,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 rotationmove_x/move_zuse); 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 tocamera({chase}), not hand-rolled math.