makepad/splashgame.md
Admin 6623408a57 Bind the library and composition into script — and tell the model it exists
8 new verbs (table now 115): find_model (DISTINCT ids, not ranked duplicates),
find_palette (matched set from one pack), model, kits, cast, road_network,
town, dungeon. game.find was ALREADY TAKEN by entity-by-tag lookup — the
duplicate-name test caught the clash before it shipped, and find_model/
find_palette now match the agent TOOL names, so the model's knowledge
transfers between the tool it calls and the verb it writes.

Verbs run synchronously (search and layout are pure CPU); only GLB load and
draw need a host, so placements queue through the same mechanism as audio and
particles — which also means a scene composes headlessly with no renderer
attached. Tiles carry their own collider from the kit pitch, so scripted props
are as solid as hand-placed ones.

THE MOST IMPORTANT EDIT WAS A DELETION. splashgame.md said "Everything is
procedural... No image, model, or audio files" — the doc was actively telling
the model it had no models, which is why generated games were bare primitives
while 4,442 models sat unused. Replaced with an instruction to reach for the
library before game.box, three rules (never place result #1 five times; one art
pack per region; generate layouts rather than hand-placing) and a wrong-vs-
right example. A test asserts that claim cannot come back.

Two bugs found by probing the REAL library rather than reasoning:
- town() would have placed ZERO buildings, silently: it selects
  TileRole::Building, but every role-less model mapped to Prop — and
  city-kit-suburban is 40 whole buildings with no parsed roles. A role-less
  model is genuinely ambiguous (a building on a lot, or a cone at a kerb), so
  kit_from_index now takes a KitUse hint. Against the real library: 104
  buildings, 136 road tiles, 0 adjacency errors
- the index folds crossroads and T-junctions into one `junction` role, but a
  4-way cell needs four open edges; a T standing in for a crossroad leaves a
  road stub pointing at nothing. Disambiguated by name

village.splash is the scenery counterpart to racing.splash: a town, a wood of
four different conifers, a dungeon, a playable character — and not one model id
written by hand.

NOT BOUND, and why: game.tree/rock/blob and game.scatter generate MESHES, and
set_models takes an asset id, not geometry — there is no mesh-upload path for
generated meshes yet, so binding them would have meant faking it. Additive once
a generated-mesh queue exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:24:10 +02:00

426 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Splash Game DSL Guide
The complete `game.*` API for games running in Makepad Arcade (`apps/arcade`;
also `examples/gamemaker`, which shares the same engine and is being retired).
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`.
- Arrays: `let xs = []`, then **`xs.push(v)`** (a METHOD — there is no free
`push(xs, v)` function; calling one is an error that stops the game),
`xs.len()`, `xs[i]`, and `for x in xs { }`. `game.find("tag")` and
`game.overlap_sphere(pos, r)` hand you arrays to walk the same way.
- **Use the stock library.** ~4,700 CC0 models ship with Arcade — houses,
vehicles, trees, rocks, furniture, dungeon and road tiles, rigged
characters. A scene built from bare `game.box` primitives looks cheap; the
same scene with real models looks like a game. See **The stock library**.
Sound is synthesized; the only file a game itself owns is `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, density, friction, restitution})` | 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). `body: "rigid"` = REAL physics (box3d): stacks, tumbles, rotates, bounces — crates, balls, dominoes. Rigids collide with statics/kinematics/each other, NOT with movers; `density`/`friction`/`restitution` tune the material; `shape:"sphere"` rigids roll (collider radius = half width). Rigid state is shared-tier (networked); `push` gives a real impulse. `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. On a `body:"rigid"` entity this is a true mass-scaled impulse (same Δv feel; wakes the body) |
| `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`.
## The stock library — ~4,700 CC0 models
Arcade ships Kenney's low-poly library: houses, vehicles, trees, rocks, walls,
furniture, food, weapons, road and dungeon tiles, and rigged characters.
**Reach for these before `game.box`.** A scene of coloured primitives reads as
a prototype; the same layout with real models reads as a game.
| call | meaning |
|---|---|
| `game.find_model("pine tree", {count: 4})` | search → **a LIST of DISTINCT model ids**. Options: `count`, `spread` (`mixed` default / `kinds` / `variants`), `seed`, `kind` (`model`/`sound`), `rigged: true`, `max_size` |
| `game.find_palette("village", 7)` | → `{pack, group: [ids], ...}` — a MATCHED set from ONE art pack |
| `game.model(id, {pos, yaw, scale, collide, tag})` | place one. Ids come from `find_model` — never invent one; a wrong id reports near-misses and places nothing |
| `game.kits()` | → `[{pack, tiles, tile_size, roles}]` — the packs whose tiles snap together |
| `game.cast()` | → `[{joints, members, states}]` — rigged characters, grouped by shared rig (one animation set drives every member of a group) |
### The three rules that decide whether it looks good
1. **Never place result #1 five times.** This is the single most common way to
make a scene look cheap. `find_model` returns a list precisely so you can
walk it — a village wants five different houses, a wood wants several
species and several variants of each.
2. **One art pack per region.** Spreading across the whole library gives a
suburban house beside a hex-tile house beside a sci-fi house. Use
`find_palette` for a region, or keep to one `pack`.
3. **Generate layouts; don't hand-place a hundred things.** `game.town`,
`game.dungeon` and `game.road_network` lay real tiles with correct corners
and junctions, which is tedious and error-prone by hand.
```
// WRONG — one house, five times
let h = game.find_model("house")[0]
for i in 0..5 { game.model(h, {pos: vec3(i * 8.0, 0.0, 0.0)}) }
// RIGHT — five different houses, all from one pack, all facing the street
let houses = game.find_model("suburban house", {count: 5, seed: 3})
for i in 0..5 {
game.model(houses[i], {pos: vec3(i * 8.0 - 16.0, 0.0, -10.0), yaw: 0.0})
}
```
## Building a place (composition)
Tiles from a kit snap onto a grid; the generators pick corners, junctions and
dead ends from how the layout actually connects, so you never name a piece.
| call | meaning |
|---|---|
| `game.road_network({kit, paths: [[vec3, vec3, ...], ...], seed})` | polylines → a road. Two paths that cross give a crossroad, one that tees gives a T — automatically |
| `game.town({roads_kit, buildings_kit, props_kit, extent: 24, block: 4, density: 0.8, seed})` | a street grid with buildings FRONTING the streets |
| `game.dungeon({kit, extent: 32, min_room: 5, depth: 4, seed})` | rooms + corridors, every room reachable. Returns `{tiles, entrance, exit}` — spawn the player at `entrance` |
All three return `{tiles}` (a count) and take `collide` (default on for towns
and dungeons, off for roads so cars drive over them). All are
**seed-deterministic**: the same seed gives the same level every load.
`modular-dungeon-kit`, `modular-cave-kit` and `modular-space-kit` share one
role vocabulary — the same `game.dungeon` call gives a crypt, a cavern or a
space station purely by swapping `kit`.
```
// A small place: a road, a village along it, and a dungeon to find.
let d = game.dungeon({kit: "kenney/modular-dungeon-kit", extent: 24, seed: 5})
game.town({
roads_kit: "kenney/city-kit-roads",
buildings_kit: "kenney/city-kit-suburban",
extent: 20, block: 5, density: 0.7, seed: 5,
})
let trees = game.find_model("pine tree", {count: 4, seed: 5})
for i in 0..24 {
let a = i * 0.26
game.model(trees[i % 4], {pos: vec3(cos(a) * 34.0, 0.0, sin(a) * 34.0)})
}
let hero = game.mover({pos: d.entrance, size: vec3(0.6, 1.7, 0.6)})
game.camera({third_person: hero, boom: 9, pitch: -0.3})
```
## Light and weather
| call | meaning |
|---|---|
| `game.sun({time_of_day: 8.5})` | one sun for the whole scene: 0..24 local hours. Morning and evening are warm and throw long shadows, noon is white and short |
| `game.sun({dir: vec3(0.3, 0.9, 0.2), color: vec3(1.0, 0.9, 0.7)})` | or aim it yourself. `ambient` lifts the shadow side, `shadow_alpha` (0..1) sets how dark cast shadows draw |
Objects cast real shadows that stretch and swing as the sun moves — you get
them for free, there is nothing to turn on.
## Particles (device-local — never affects the game)
| call | meaning |
|---|---|
| `game.particles(id, {kind: "smoke", rate: 20})` → emitter | a continuous emitter that FOLLOWS an entity: exhaust, fire, a dust trail |
| `game.particles(vec3(x,y,z), {kind: "dust"})` → emitter | or pinned to a spot |
| `game.burst(vec3(x,y,z), {kind: "spark", count: 16})` | a one-shot puff: impacts, pickups, explosions |
| `game.particles_stop(emitter)` | stop one emitter |
Kinds: `spark` (fast, falls), `smoke` (rises, grows), `dust` (drifts, settles),
`trail` (fades in place). Tune with `life size color spread speed gravity`.
Particles are **cosmetic only**. They never collide, never touch game state,
and each device draws its own — so never make a rule depend on one. A phone may
draw fewer than a PC in the same game, and that is fine by design.
## 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) |
| `game.sfx_at(vec3(x,y,z), "clank", {range: 40})` | same bank, but POSITIONED: quieter with distance, panned left/right by where it is relative to the camera. Use it for anything with a place — impacts, engines, other players |
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.
## Building blocks
High-level prefabs. **The engine runs these at 60Hz** — you call the verb once
to create and configure, then the block drives itself. Don't reimplement their
behaviour in `on_tick`; steer them with `game.drive` or let them run.
All block state is **Shared tier**: laps, scores, positions and control intent
replicate to other players. Animation blending is Derived (recomputed from
velocity), so it costs nothing over the network.
| verb | what you get |
|---|---|
| `game.car({pos, color, player: true, top_speed, accel, grip, seats})` | A driveable raycast vehicle on a rigid chassis: suspension, grip, arcade steering, and it will not roll over. Returns the entity id. |
| `game.character({pos, color, player: true, model, speed, jump, view: "third"})` | A walker on the mover sweep (0.55 step-up, jumps, camera-relative movement) with idle↔walk↔run blending driven by its own speed. |
| `game.plane({pos, color, player: true, thrust, lift_speed})` | Arcade flight: lift from airspeed, auto-level, weathervane stability. Holding pitch loops; it cannot stall or spin. |
| `game.drive(id, {steer, throttle, brake, handbrake, pitch, roll, move_x, move_z, jump})` | Feed control intent to any block — this is how script-driven or AI opponents are controlled. |
| `game.autodrive(car, {points: [vec3, ...], pace})` | Hands a car a racing line to follow. `pace` is 0..1 of its top speed. |
| `game.speed(id)` | Forward speed for a car, airspeed for a plane, planar speed otherwise. |
### Brains
Attach an AI to any mover. Re-issuing a brain on the same entity replaces it.
| verb | behaviour |
|---|---|
| `game.wander(id, {home, range, speed, pause})` | Amble to random points near home, pausing between trips. |
| `game.chase(id, {tag, range, catch, speed})` | Hunt the nearest entity carrying `tag`. `game.caught(id)` returns who it reached this tick. |
| `game.patrol(id, {points: [vec3, ...], speed})` | Walk a fixed route, looping forever. **Do not pass `loop:`**`loop` is a reserved word and using it as an option key hangs the script until the instruction limit kills the eval, so the game never starts. Looping is the default; ping-pong is unavailable until the engine renames that key. |
### Race kit
| verb | behaviour |
|---|---|
| `game.spawnpoint({pos, yaw})` | Declares a start slot; returns its index. |
| `game.checkpoint({pos, size})` | Declares a gate. Gates are numbered in declaration order and **must be crossed in order** — cutting the course scores nothing. |
| `game.place(id, slot)` | Puts an entity on a start slot and enters it in the race. |
| `game.race({laps})` | (Re)starts lap tracking. Call it again to restart a race. |
| `game.standings()` | `[{entity, lap, checkpoint, finished, score}]`, leader first. |
| `game.lap(id)` / `game.rank(id)` / `game.finished(id)` | Per-racer progress. |
| `game.score(id, points)` / `game.score_of(id)` | Scoring for non-racing games too. |
A complete 4-car race is ~60 lines — see
`examples/gamemaker/resources/fixtures/racing.splash`.
## Players (multiplayer)
Every world has at least one player: id `0`, this device. A hosted room adds one
per connected client, plus any bots the game creates. Blocks take `player:` to
say who drives them.
| verb | what it does |
|---|---|
| `game.players()` | all player ids, local first — **Shared** |
| `game.player_name(p)` | display name — **Shared** |
| `game.player_entity(p)` | the entity this player drives, 0 if none — **Shared** |
| `game.player_input(p)` | that player's input object (same shape `on_tick` gets) |
| `game.bot(name)` | add a host-side player with no device — **Shared** |
| `game.on_join(fn(p))` | someone joined the room |
| `game.on_leave(fn(p))` | someone left; their body is freed for you |
Movement stays camera-relative per player: `move_x`/`move_z` are rotated by *that
player's* camera yaw, which travels inside their input packet. A client's camera
is presentation only and never replicates.
**Replication tiers.** The host simulates; clients receive. What crosses the wire
is decided per field, not per entity:
- **Shared** — position, velocity, size, body kind, tag, score, lap progress.
Host to clients, every tick.
- **Derived** — facing yaw, walk-cycle blending, part animation, scale and glow
easing, blob shadows. Never sent: a client recomputes these from Shared state,
which is why 200 moving props cost nothing to rotate.
- **Local** — camera, audio, particles. This device's business alone.
Gameplay must not depend on Local state, or late joiners will see a different
game from everyone else.