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.
- 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. - Arrays:
let xs = [], thenxs.push(v)(a METHOD — there is no freepush(xs, v)function; calling one is an error that stops the game),xs.len(),xs[i], andfor x in xs { }.game.find("tag")andgame.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.boxprimitives 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 isgame.splash. - ALWAYS check
./tools/ag errorsafter editing. Empty = live. Error = the player still sees the OLD world.
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)) }
})
| 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.
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.
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" |
| 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.
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) |
- Never place result #1 five times. This is the single most common way to
make a scene look cheap.
find_modelreturns a list precisely so you can walk it — a village wants five different houses, a wood wants several species and several variants of each. - 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_palettefor a region, or keep to onepack. - Generate layouts; don't hand-place a hundred things.
game.town,game.dungeonandgame.road_networklay 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})
}
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})
| 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.
| 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.
| 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.
./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).
- 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.
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. |
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. |
| 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.
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.