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>
238 lines
17 KiB
Markdown
238 lines
17 KiB
Markdown
# 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
|
||
|
||
```splash
|
||
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
|
||
|
||
```splash
|
||
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 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
|
||
|
||
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.
|