{"id":46300,"plugin_id":"plugin_asdk_app_6acd235a70a48191812b3ba9c471b4ac","kind":"skill","collection_source":"plugin_package","comparison_source":null,"observed_at":"2026-10-24T13:29:11.388Z","digest":"80c5a90e3691d080f850247d7f841f2ebcb3bdf800f2ed115080a30f32431346","against":null,"payload":{"description":"DEFAULT recipe for playable games and game-like scenes: plain JS code logic in the HTML layer (set_html_content + the spline bridge) — start-screen gate, HUD, keyboard input, a rAF game loop, a glitch-free chase camera, JS collision checks — with the scene built by run_code and sound recipes in game-audio. The built-in Game Controls feature is OPT-IN only (load game-controls when the user explicitly asks for it).","included_files":[],"name":"spline-3d-games","skill_md_contents":"---\nname: spline-3d-games\ndescription: \"DEFAULT recipe for playable games and game-like scenes: plain JS code logic in the HTML layer (set_html_content + the spline bridge) — start-screen gate, HUD, keyboard input, a rAF game loop, a glitch-free chase camera, JS collision checks — with the scene built by run_code and sound recipes in game-audio. The built-in Game Controls feature is OPT-IN only (load game-controls when the user explicitly asks for it).\"\n---\n\n# Games (JS game loop + HTML UI)\n\nGames and game-like scenes are CODE-DRIVEN BY DEFAULT: the world is authored\nwith `run_code`, and ALL gameplay — input, movement, camera, collisions,\nscore, win/lose — is plain JavaScript living in the file's HTML layer\n(`set_html_content`), driving objects through the `spline` bridge at 60fps.\n\nDo NOT reach for the engine's game machinery unless asked:\n\n- The built-in Game Controls feature (the `GameControl` event) is OPT-IN:\n  use it ONLY when the user explicitly asks for it (says \"game controls\",\n  wants the editor's built-in character controller, touch joysticks, or a\n  navmesh). Then load the `game-controls` skill and follow it instead of\n  the loop recipe here. Never mix both movement systems on one player.\n- Engine physics (`physics()` + Collision/Trigger events) is for\n  physics-driven scenes, not for the core loop of a code-driven game: the\n  bridge's per-frame writes are transform teleports the simulation cannot\n  see. Do the gameplay collision math in JS (below). Decorative physics on\n  objects the code never moves is fine.\n- No engine KeyDown/Start events for game logic either — the HTML document\n  handles its own input and boot.\n\nWhy this is the default: a JS loop gives conventional game feel (proper\nchase camera, smooth steering, instant restart), real UI (start screen,\nHUD, game-over), procedural sound (WebAudio synthesis — no audio assets),\nand one debuggable place for all the rules — and the same document ships\nwith exports (public URLs, viewer embeds, runtime code exports), where the\nidentical `spline` bridge is injected.\n\nLoad the `html-content` skill alongside this one for the full bridge API\n(`updateObject`, snapshots, transparent-background and passthrough rules).\n\n## Layer 1 — the scene (run_code)\n\nBuild the world exactly like any scene (`buildingScenes` conventions),\nplus these game rules:\n\n- **Game actors live at the page ROOT, not inside groups — the play camera\n  included.** The bridge reads/writes LOCAL transforms; at the root,\n  local == world and the math below just works. (A generated character\n  wrapped in a facing-fix group is fine — the GROUP is then the root-level\n  actor the code drives.)\n- **Stable, unique names** — `'Player'`, `'CamRig'`, `'ChaseCam'`,\n  `'Coin 1'`… The game code finds objects by these names at boot, then\n  drives them BY ID (name lookup traverses the whole scene).\n- **The player's visual front faces its +Z side** (where Spline meshes and\n  imports face when created). The movement recipes assume heading θ ⇒\n  forward = (sin θ, 0, cos θ) with `rotation.y = θ`. If a generated model\n  leads with another side, nest the mesh in a group, pre-rotate the MESH\n  inside so the GROUP's +Z is the front, keep the group at the root, and\n  drive the group (the full recipe: \"When the generated model lands\"\n  below).\n- **Ground EVERY actor at its contact patch — player, rivals, props.**\n  Cars that spawn inside the floor are the top game-breaker, and it hits\n  the whole grid, not just the hero. Two rules make it impossible:\n  1. Every actor group's origin IS its contact patch: offset the meshes\n     inside so wheels/feet sit at group-local y = 0. Generated models\n     need this explicitly — they arrive with ARBITRARY pivots (a\n     centered pivot sinks half the car); primitive rivals need their\n     wheels/body built above local 0, not centered on it.\n  2. ONE shared height constant, used everywhere:\n     `const SURFACE_Y = 4;` (the TOP of the asphalt per the height\n     layering below — never 0, which is the ground plane). The build\n     places every car at `y: SURFACE_Y`; the drive loops either never\n     write y or write exactly `SURFACE_Y` — never a recomputed value.\n  Verify with a low, close screenshot of the START GRID: every car's\n  wheels touching, none buried, none floating.\n- **When the hero replaces a placeholder, the placeholder DIES.**\n  `remove()` it, then give the hero's wrapper the placeholder's exact\n  name — the scene must end with exactly ONE object of that name. Two\n  objects sharing 'Player' means `findObjectByName` picks one\n  arbitrarily: the loop drives the hidden/leftover one while the visible\n  car sits still at the grid.\n- **Generate the hero with the AI mesh tool.** Grey primitives make a game\n  look like a prototype, so reach for `generate_3d_model` PROACTIVELY for\n  the thing the scene is about — the car, the runner, the ship — starting\n  with the player. 1 generation is the target, 2 when a secondary subject\n  earns it, 3 is the hard maximum (do NOT generate more than 3 unless the\n  user explicitly asks). Kick the hero generation off FIRST — it returns\n  immediately and bakes in the background — then build ground, props,\n  camera and UI while it bakes. The finished model arrives selected, at the\n  build anchor: in the app it is handed back to you before the request ends,\n  while over MCP the call returns a `job_id` and you collect the result by\n  polling `get_generation` (it will not arrive on its own). That's when\n  you rename it, fix its facing, and wire it into the loop. Everything\n  else — ground, track, walls, pickups — is primitives via `run_code`.\n\n### When the generated model lands — the swap procedure\n\nThe completion report (\"finished and inserted\") is the START of the swap, not\nthe end: generated meshes arrive at the build anchor with ARBITRARY authored\nfacing, scale, and pivot. Dropped in raw, the car rides sideways down the\nstraight, sits buried or afloat, and is scaled to nothing the track was\nderived from. And the one place a facing fix must NOT go is the driven\nobject itself: the loop's `updateObject` writes ABSOLUTE rotations every\nframe, so a yaw correction on the driven root is erased on the first tick\n— it has to be baked one level DOWN. In order:\n\n1. **Measure it**: `get_scene` → the inserted object's `size`\n   [w, h, d] (world bbox — fresh inserts are unrotated, so this is the\n   authored box).\n2. **Scale to the anchor size, uniformly**: for a car\n   `scale({ x: k, y: k, z: k })` with\n   `k = CAR.length / Math.max(w, d)` — the long edge must match the\n   length the world was sized from, never whatever the generator chose.\n3. **Wrap it**: `group()` the mesh — the GROUP is the actor the loop\n   drives. Then set the mesh's LOCAL transform inside DELIBERATELY\n   (`position({ x: 0, y: h/2, z: 0 })` — scaled h; a centered pivot is\n   the common case and h/2 rests the wheels at group-local 0). Never\n   trust whatever offsets the wrap left behind.\n4. **Yaw the MESH so the group's +Z is the nose.** The measured bbox\n   names the long AXIS (length on x ⇒ start with\n   `rotation({ y: 90 })`), but a bbox cannot tell nose from tail:\n   `take_screenshot`, look at the nose, add 180 if it points backwards.\n   90° multiples only — anything else drives skewed forever.\n5. **Place the group at the placeholder's slot**: rotation { 0, 0, 0 },\n   position { grid x, y: SURFACE_Y, z: grid z }.\n6. **The placeholder DIES and the group takes its exact name**:\n   `remove()` the placeholder, then `rename()` the group — exactly one\n   'Player' in the scene (the loop's boot lookup must have one answer).\n7. **Prove it with a screenshot** — low, close, from behind the grid:\n   nose pointing along the group's +Z, wheels touching the asphalt, car\n   about a third of the track width. Fix it HERE, before play — not\n   after the user watches it drive sideways.\n\nRivals cloned from the hero inherit the calibration with the wrapper —\n`clone` the GROUP, never the bare mesh inside.\n\n### World scale & trackside props (every prop, every game)\n\n- **Anchor every size to the player.** Pick the player's size first (a\n  stylized car ~340–450 units long, a character ~180–220 tall; 200 units\n  = 1 m), then derive the rest: a racing track 3–4 car widths across,\n  curbs ~40 wide, barriers ~60 tall, a tire ~110 across. A decorative\n  prop bigger than the player is almost always a bug.\n- **Prop sizes are named CONSTANTS; loop math is for POSITIONS only.**\n  When placing props along a track/curve, the loop computes where each\n  one goes — never how big it is. Deriving a size from curve/track\n  variables is how circuits end up buried under 5,000-unit cylinders:\n  ```\n  const TIRE = { radius: 55, height: 40 };           // fixed, up front\n  for (let i = 0; i < corner.length; i++) {\n    mesh('Cylinder', { radius: TIRE.radius, height: TIRE.height,\n      hollow: 0.4 });\n    position({ x: corner[i].x, y: TIRE.height / 2, z: corner[i].z });\n    rename('TireStack ' + (i + 1));                  // position from the\n  }                                                  // loop, size never\n  ```\n- **`hollow` is a FRACTION, 0..1.** `hollow: 0.4` = a tire with a 40%\n  opening; `hollow: 40` is out of range (the classic percent mistake —\n  it used to inflate each tire into a scene-swallowing lens; the engine\n  now clamps it, but the tire renders wrong). Same rule for every 0..1\n  geometry fraction.\n- **Keep counts low, grouped, and off the racing line.** A handful of\n  tire stacks (1–2 tall) at the OUTSIDE of hard corners, a few dozen\n  trees/props total — each family in a named group ('Tire Walls',\n  'Trees'). Hundreds of decorative meshes add cost, not looks.\n- **Light size covers the play area.** A directional light's shadows\n  exist only inside its `size` footprint (default 2000 world units);\n  tracks, courts, and arenas span far more, and shadows cut off\n  mid-track. Give the key light `size: 4000` or more, matched to the\n  ground/track span (e.g. `size: 9000` for a 9000-unit ground).\n- **Verify the layout from above before wiring gameplay**: frame a\n  top-down screenshot and CHECK — every prop reads smaller than the\n  player, nothing overflows the track or the ground plane. A group of\n  giant discs/blobs swallowing the scene means a size came from loop\n  math: delete that group and rebuild it from the constants.\n\n### Track ribbons — no self-overlap (racing circuits)\n\nThe classic circuit failure: a track built from overlapping planks folds\ninto itself at hairpins — inner edges cross, coplanar faces z-fight (dark\nsmudges on the asphalt), stray kerb arcs cut across the road. Three rules\nprevent all of it:\n\n- **Corner radius ≥ track width, everywhere.** The inner edge's radius is\n  `centerRadius - width / 2`; as it nears zero the ribbon folds over\n  itself. Keep every corner's CENTERLINE radius at least the track width\n  (1.5x reads better) — tighten a hairpin by narrowing the track or\n  widening the arc, never by dropping the radius below the width.\n- **The asphalt is ONE ribbon mesh, never overlapping segments.** Sample\n  a smooth closed centerline, offset each sample left/right by half the\n  width, and stitch a single `customMesh` — exact joints, zero overlap:\n\n  ```\n  const N = 96, W = 900;                       // samples, track width\n  const cx = (t) => Math.sin(t * 2 * Math.PI) * 2600;   // centerline x(t)\n  const cz = (t) => Math.cos(t * 2 * Math.PI) * 1700;   // centerline z(t)\n  const verts = [], idx = [];\n  for (let i = 0; i < N; i++) {\n    const t = i / N, e = 1e-3;\n    const x = cx(t), z = cz(t);\n    const dx = cx(t + e) - x, dz = cz(t + e) - z;        // tangent\n    const len = Math.hypot(dx, dz);\n    const nx = -dz / len, nz = dx / len;                 // left normal\n    verts.push(x + (nx * W) / 2, 0, z + (nz * W) / 2);   // left edge\n    verts.push(x - (nx * W) / 2, 0, z - (nz * W) / 2);   // right edge\n  }\n  for (let i = 0; i < N; i++) {\n    const a = i * 2, b = a + 1;\n    const c = ((i + 1) % N) * 2, d = c + 1;\n    idx.push(a, c, b, b, c, d);   // two up-facing triangles per quad\n  }\n  customMesh({ vertices: verts, indices: idx });\n  position({ y: 2 }); color('#2a2d33'); rename('Track Asphalt');\n  // (reversed travel direction? if the ribbon renders dark from above,\n  // swap each triangle's last two indices)\n  ```\n\n  Kerbs and barriers are the SAME recipe at offset `±(W / 2 + margin)`\n  with a narrow width — they follow the track exactly instead of\n  overshooting into it. Curve-following geometry comes from this ribbon\n  math; boxes are for straights and props only.\n- **Nothing lies coplanar with the asphalt.** Stack by a few units:\n  ground at y = 0, asphalt at ~y = 2, kerbs a step above the asphalt,\n  painted marks (start line, arrows) a step above that. Two faces at the\n  SAME height z-fight as flickering smudges at any distance.\n\n## Layer 2 — the chase camera (get this right)\n\nThree hard rules, each one fixing a real failure mode:\n\n1. **The play camera lives at the page ROOT — NEVER inside a group.**\n   Parts of the play pipeline treat the camera transform as world-space; a\n   play camera nested in a group/rig comes up mis-aimed from the first\n   frame (staring at the sky instead of the player).\n2. **Turn the play-mode camera controls off: `playControls('none')`.**\n   While any of orbit rotate/pan/zoom is enabled (the file default), play\n   mode attaches orbit controls to the play camera: they re-aim it at\n   their own fixed target on every update and flip its up vector when the\n   camera passes over that point — the \"camera somersaults / gimbal-locks\n   and loses the player\" bug. Code driving the play camera must be its\n   ONLY driver. Skip this only for a static camera the user SHOULD orbit.\n3. **Never hand-write a rotation that both yaws and pitches.** Entity\n   Euler order is XYZ, so `{ x: pitch, y: yaw }` tilts sideways and flips\n   as the heading turns. Aim cameras through `aimEuler` (below) — exact\n   at every heading — and always write ALL THREE axes it returns.\n\nScene side (run_code): author the camera at the root, aim it with the same\nmath the loop will use (`aimEuler` is built into run_code — call it\ndirectly), and hand it to Play:\n\n```\n// player at (px, py, pz), heading playerYaw degrees (0 = facing +Z)\nconst cam = add('PerspectiveCamera');\nrename('ChaseCam');\nconst pr = playerYaw * Math.PI / 180;\nposition({ x: px - Math.sin(pr) * 720,\n           y: py + 320,\n           z: pz - Math.cos(pr) * 720 }); // above & behind the player\nrotation(aimEuler(playerYaw, -12));      // look the way the player faces\nsetPlayCamera(cam);      // Play + exports render through it\nplayControls('none');    // the game code is the ONLY camera driver\n```\n\nRuntime (in the loop): EASE the camera toward its chase point and AIM it\nAT the player every frame. Never bolt it —\n`position = player − offset` written verbatim is the #1 cause of the\ncar \"buzzing/trembling in place\" at speed: the player and camera are two\nseparate `updateObject` writes that do not always land in the same\nrender frame, and a bolted camera turns that one-frame skew into\nscreen-space vibration (the car's screen position is the DIFFERENCE of\nthe two). Exponential easing spreads the skew across frames, and aiming\nat the player keeps it dead-centre no matter how the writes interleave:\n\n```\n// chase point behind the player's heading (px/pz/playerYaw = ON-SCREEN state)\nconst pr = playerYaw * Math.PI / 180;\nconst gx = px - Math.sin(pr) * 720, gz = pz - Math.cos(pr) * 720;\ncx = damp(cx, gx, 6, dt); cy = damp(cy, py + 320, 6, dt);\ncz = damp(cz, gz, 6, dt);\n// aim AT the player (a point at roof height), not along a stored heading\nconst dx = px - cx, dy = (py + 120) - cy, dz = pz - cz;\nspline.updateObject(camId, {\n  position: { x: cx, y: cy, z: cz },\n  rotation: aimEuler(Math.atan2(dx, dz) * 180 / Math.PI,\n                     Math.atan2(dy, Math.hypot(dx, dz)) * 180 / Math.PI),\n});\n```\n\nSeed `cx/cy/cz` at the chase point on reset (snap, don't ease, or the\ncamera swoops in from wherever it last was).\n\nCamera variants:\n- **Racing / third-person chase**: the recipe above — distance 600–900,\n  height 250–400 (the aim pitch lands around −10…−16° by construction).\n  Position ease rate 5–8: the camera trails the chase point by roughly\n  speed/rate units at full speed — tighten the rate, never bolt.\n- **First-person**: position = player position + eye height (~160),\n  rotation = `aimEuler(playerYaw, pitch)`, no easing. Hide the player\n  mesh (`visible: false`) or mount the camera just ahead of its face.\n  Mouse-look: accumulate yaw/pitch from `movementX/Y` after\n  `stage.requestPointerLock()` on click (the iframe sandbox allows it);\n  clamp pitch to about ±80°.\n- **Fixed-angle (side-scroller, top-down, isometric)**: author the\n  orientation once with `rotation(...)`; at runtime only LERP the\n  position after the player — never rotate it. `playControls('none')`\n  still applies whenever the code moves the camera.\n\n## Layer 3 — the game shell (set_html_content)\n\nONE complete HTML document over the canvas. Non-negotiables:\n\n- **The game waits for the user.** Boot into a start screen — title, one\n  line of controls, a Start button — and run gameplay only after the user\n  clicks Start (state machine `'menu' → 'playing' → 'over'`). NEVER\n  auto-start on load: the scene must stay still until asked. The click is\n  also what puts keyboard focus inside the HTML document AND\n  creates/unlocks the AudioContext (see Procedural sound effects).\n- **A full-viewport input layer**: `<div id=\"stage\" style=\"position:fixed;\n  inset:0\"></div>` as the bottom layer, WITHOUT\n  `data-spline-passthrough`. Pointer/wheel events that land on it stay in\n  the document — keyboard focus stays here too — instead of forwarding to\n  the 3D canvas. (Camera safety itself comes from `playControls('none')`\n  in Layer 2; the layer keeps stray drags/scrolls from poking the scene,\n  and is where pointer-lock mouse-look attaches.) Omit it only when the\n  game deliberately lets the user orbit a static play camera.\n- **Keyboard state, not keydown bursts**: track held keys in a Set from\n  `keydown`/`keyup` (`e.code`), `preventDefault()` the game keys, and\n  read the Set inside the loop. Ignore `e.repeat` for one-shot actions\n  (jump, restart).\n- **Pause when focus leaves the document.** Keys only reach the game\n  while ITS document has focus — and entering Play from the toolbar (or\n  tabbing away, or clicking editor chrome) moves focus OUTSIDE it,\n  leaving a 'playing' game deaf to every key (\"the car won't move\"). On\n  window `blur`: clear the held-keys set (or keys stick down forever)\n  and drop to a `'paused'` overlay; its Resume click returns focus and\n  input in one gesture.\n- **One requestAnimationFrame loop, FIXED sim timestep.** Start it once\n  at boot and never stop it; integrate only while `state === 'playing'`.\n  Never integrate physics with the raw frame delta — every hitch changes\n  the step size and the car lurches. Accumulate the (clamped) delta and\n  step the sim in fixed slices, then write the scene a LOW-PASSED copy\n  of the sim state so a dropped frame cannot produce a jump:\n  ```\n  const STEP = 1 / 120; let acc = 0;\n  // per frame: dt = Math.min((now - last) / 1000, 0.05)\n  acc += dt;\n  while (acc >= STEP) { simulate(STEP); acc -= STEP; }\n  vx = damp(vx, x, 25, dt); vz = damp(vz, z, 25, dt);   // visual state\n  vyaw = dampAngle(vyaw, yaw, 25, dt);                  // what gets WRITTEN\n  ```\n  Transforms go out as fire-and-forget `spline.updateObject(id, …)` (no\n  await in the loop), ONE write per actor per frame, built from the\n  visual (`v*`) state — the camera chases and aims at that same visual\n  state, so everything on screen agrees.\n- **Boot reads, then cached ids**: `await spline.ready`, snapshot the\n  actors once (`findObjectByName` / `getAllObjects` — positions in world\n  units, rotations in DEGREES), keep their ids and initial transforms in\n  JS. The loop never reads the scene back — JS state is the source of\n  truth. If a required lookup comes back null (an object renamed or\n  missing), say so on the start screen and stop — a silent null throws\n  in the first tick and freezes every car.\n- **Restart rebuilds from the snapshots**: restore every actor's initial\n  transform and visibility with `updateObject`, reset score/timers, back\n  to `'playing'` (bind it to the game-over button and the R key). Bridge\n  writes are transient (never saved, gone on reload), so restoring\n  snapshots is all a restart takes.\n- **HUD is HTML — and throttled.** Score, lap, speed, time live in styled\n  fixed-position elements. Event-driven values (score, lap) update when\n  they change; continuously-changing readouts (speed, timers) update at\n  ~10 Hz, never every frame — writing DOM text 60×/s adds main-thread\n  hitches that read as scene jitter. Don't build 3D text scoreboards\n  unless asked. Keep the page background TRANSPARENT (style your panels,\n  never html/body) so the scene shows through.\n\n## Movement + camera math (drop-in helpers)\n\nFramerate-independent smoothing, and the SHORTEST-ARC angle damp — easing\nyaw with a plain lerp is what makes cameras spin the long way around and\nglitch when steering flips the sign (the classic 350°→10° bug):\n\n```\nconst wrap = (d) => ((d + 540) % 360) - 180;  // any delta → [-180, 180]\nconst damp = (a, b, rate, dt) => a + (b - a) * (1 - Math.exp(-rate * dt));\nconst dampAngle = (a, b, rate, dt) => a + wrap(b - a) * (1 - Math.exp(-rate * dt));\n```\n\n`aimEuler` — THE way to point a camera. It converts \"look along world\nheading `yawDeg`, tilted by `pitchDeg`\" (same heading convention as the\nplayer: 0° looks toward +Z; pitch < 0 looks down) into the XYZ-order Euler\nthe bridge expects. Smooth yaw/pitch BEFORE converting; never smooth,\npartially write, or hand-edit its outputs. It is BUILT INTO run_code; in\nbrowser game-loop code (which runs outside run_code) define this exact\ncopy inline:\n\n```\nfunction aimEuler(yawDeg, pitchDeg) {\n  const y = (yawDeg + 180) * Math.PI / 180; // camera bodies look down -Z\n  const p = pitchDeg * Math.PI / 180;\n  const cy = Math.cos(y), sy = Math.sin(y);\n  const cp = Math.cos(p), sp = Math.sin(p);\n  const r2d = 180 / Math.PI;\n  // R = Ry(yaw) * Rx(pitch), decomposed into the entity's XYZ order\n  return {\n    x: Math.atan2(sp, cy * cp) * r2d,\n    y: Math.asin(sy * cp) * r2d,\n    z: Math.atan2(-sy * sp, cy) * r2d,\n  };\n}\n```\n\nKeep chase cameras tilted (−8° or steeper): it frames the road ahead, and\na perfectly level camera looking exactly along ±X sits on the conversion's\nbranch point (harmless — the values stay correct — but the tilt keeps the\nnumbers tame).\n\nHeading convention (matches the scene rules above): `yaw` in degrees,\n`rotation.y = yaw`, forward = `(sin yaw°, 0, cos yaw°)` — so\n`x += Math.sin(rad) * speed * dt; z += Math.cos(rad) * speed * dt`.\n\n- **Car**: throttle/brake accelerate `speed`, exponential drag\n  (`speed *= Math.exp(-DRAG * dt)`), clamp to top speeds, and scale\n  steering BY SPEED — `yaw += steer * STEER_DEG * (speed / TOP) * dt` — so\n  the car can't pivot in place and reversing counter-steers naturally\n  (steer: A/← = +1, D/→ = −1; positive yaw turns left on screen).\n- **Boundary flags get hysteresis.** Any boolean derived from a\n  continuous quantity — off-track/on-grass, inside a boost zone,\n  grounded — flips on and off EVERY frame while the car rides its edge,\n  and whatever is keyed to it (a top-speed cap, extra friction) snaps\n  with it: visible stutter at the track border. Use two thresholds (go\n  off beyond edge + 25, come back inside edge − 25) or require the state\n  to hold for ~0.1 s before it takes effect — never a single `>=` test.\n  While the flag is on, apply its effect as a RATE (extra drag), not an\n  instant clamp to a lower cap.\n- **Third-person character**: camera-relative input —\n  `wx = iz * sin(camRad) - ix * cos(camRad)`,\n  `wz = iz * cos(camRad) + ix * sin(camRad)` (ix: D=+1/A=−1, iz: W=+1/S=−1),\n  then face the travel direction `yaw = dampAngle(yaw,\n  atan2(wx, wz) * 180 / Math.PI, 12, dt)` and move along (wx, wz).\n- **Jumping**: keep `vy`; `vy -= G * dt; y += vy * dt`; land when\n  `y <= groundY` (reset y, vy, grounded). ~200 units = 1 m, so G ≈ 4000\n  u/s² and jump velocity ≈ 1300 u/s feel right; only jump when grounded\n  and `!e.repeat`.\n- **Collisions are plain JS — 2D tests, depenetrating responses.**\n  Pickups/enemies by distance — `Math.hypot(o.x - x, o.z - z) < r`;\n  walls/barriers as AABBs; a goal as a distance or box test → `'over'`.\n  Collect the lists ONCE at boot from `getAllObjects()` by name prefix.\n  Three rules keep collision code from freezing (or shaking) the game:\n  - Tests compare **x/z only — never y**. Ground games play in 2D; a\n    height mistake anywhere must not register as \"inside a wall\".\n  - Responses **depenetrate, never veto**: on overlap, push the car out\n    along the shallowest axis (or slide it along the wall) and keep\n    going. Code shaped as \"if the next position overlaps, cancel the\n    move\" turns ANY overlap — a bad spawn, a deep hit, a car inside the\n    floor — into a permanently frozen car: every attempted move is\n    rejected, forever. A car that starts intersecting geometry must\n    drive out of it, not lock up.\n  - **Push path-driven actors through their STATE, never their x/z.** A\n    rival that recomputes its position from a racing line every frame\n    snaps back the same tick you move it — so the push-apart repeats\n    forever and both cars shake. Give every path follower a persistent\n    lateral offset it ADDS to the line each frame (decaying back to 0\n    over ~a second), and write the push into that offset — the rival\n    then actually gives way.\n\n## Procedural sound effects (WebAudio — on by default)\n\nGames ship with sound ON BY DEFAULT — procedural WebAudio synthesis, no\naudio assets. Load the `game-audio` skill for the synth helpers and the\nper-effect recipes (engine, weapons, footsteps, pickups, win/lose) before\nwriting the sound code; the shell recipe above already reserves the mute\nbutton and the AudioContext resume-on-first-command wiring it teaches.\n\n## Worked example\n\nBuilding a NEW game (or your first in this session)? Load the\n`game-example` skill: a complete drive-&-collect game — scene call, full\nHTML document (input, loop, chase camera, collisions, HUD, restart) — that\ninstantiates every rule above. Editing an existing game usually doesn't\nneed it; the layer contracts and math above are the reference.\n\n## Verify\n\n- `play()` then a screenshot shows the INITIAL framing through the play\n  camera — the player seen from behind, above, both in frame, and EVERY\n  car on the grid resting ON the surface. Any car sunk into the floor or\n  floating = wrong contact-patch offset or resting height — fix the mesh's\n  local y inside that car's group and the shared `SURFACE_Y`, before\n  wiring anything else. Screenshots capture the 3D canvas ONLY: the\n  HTML start screen/HUD is never in them, so verify the document through\n  the Console strip (`console.log` + errors surface there) and by\n  telling the user what they should see.\n- You cannot press keys, click Start, or hear audio yourself: tell the\n  user the controls and ask them to confirm — ESPECIALLY that steering\n  isn't inverted, the camera stays smoothly behind the player while\n  turning (no long-way spins), the player holds steady on screen at TOP\n  speed (buzzing/trembling in place = a bolted camera or raw-dt\n  integration — the smooth-motion rules above), the game waits on the\n  start screen until they click Start, and the sound effects play after\n  Start.\n- The document also runs on the stage's Preview tab, but through the\n  EDITOR camera — the chase camera is only the view in Play mode and\n  exports. The Edit tab unmounts it so the scene stays editable.\n- `stop()` when you're done testing. The preview reload chip (and a fresh\n  Play) restores every transient bridge write.\n\nGotchas:\n- The play camera: at the page ROOT (never inside a group) and with\n  `playControls('none')` whenever code drives it. A grouped play camera\n  comes up mis-aimed; live play orbit controls re-aim and up-flip a driven\n  camera (the somersault/gimbal-lock bug).\n- Hand-written yaw+pitch rotations tilt sideways as the heading turns (XYZ\n  Euler order): aim cameras with `aimEuler`, write all three axes it\n  returns, and smooth the yaw/pitch INPUTS — never its outputs.\n- The player buzzing/trembling IN PLACE at speed = a camera bolted to\n  `player − offset`: the two writes land a frame apart and the skew\n  shows as vibration. Ease the camera toward the chase point and aim it\n  AT the player every frame (Layer 2).\n- The car lurching on frame hitches = integrating with the raw frame\n  delta. Fixed `STEP` with an accumulator; the scene gets a low-passed\n  copy of the sim.\n- Speed (or friction) snapping at the track edge = a boundary flag\n  flipping every frame. Hysteresis: two thresholds or a hold time, and\n  rate-based effects instead of instant caps.\n- Two AI cars (or you and a rival) shoving each other forever = the\n  push-apart wrote the rival's x/z while its path-follower recomputed\n  them the same frame. Push into the follower's lateral-offset state.\n- HUD text written every frame (60×/s DOM writes) = main-thread hitches\n  that LOOK like scene jitter. Event-driven values update on change;\n  continuous readouts at ~10 Hz.\n- Smoothing angles with a plain lerp = the long-way-around spin. Wrap the\n  delta to [-180, 180] first (`dampAngle`).\n- Bridge transforms are LOCAL and rotations are DEGREES, both ways. Keep\n  driven actors at the page root.\n- Keep the #stage input layer even with play controls off — it holds\n  keyboard focus in the document and keeps stray drags from raycasting\n  the scene.\n- `set_html_content` writes the whole document; to tweak an existing one,\n  `get_html_content` then `edit_html_content` (find-and-replace) instead\n  of resending it. `updateObject` by cached id, not by name, in the loop.\n- `spline.addEventListener('mouseDown'…)` fires only for events the SCENE\n  authors and only in Play — a code-driven game doesn't need it; handle\n  input in the document.\n- Audio before a user gesture is blocked: create/resume the AudioContext\n  in the Start click, never at boot — and never try to fetch sound files\n  (the sandbox has no guaranteed network; synthesize instead).\n- Two objects named 'Player' (a kept placeholder + the generated hero) =\n  the loop drives one arbitrarily while the other sits at the grid.\n  `remove()` the placeholder the moment the hero replaces it.\n- A generated hero that drives SIDEWAYS = the facing fix went on the\n  driven root, where the loop's absolute rotation writes erase it every\n  frame. Bake the yaw on the mesh INSIDE the group (swap procedure), and\n  verify the nose with a screenshot — the bbox can't tell front from\n  back.\n- ANY car inside the floor (or floating) = wrong pivot or wrong resting\n  height: contact patch at group-local y = 0 and one shared SURFACE_Y\n  for the whole grid. Sunk cars also jam veto-style collision checks —\n  the buried-AND-frozen combo. Depenetrate; never cancel moves on\n  overlap.\n- A thrown error inside the rAF tick kills the whole game silently —\n  every car frozen. Guard optional lookups and read the Console strip.\n- The user asked for the built-in controller/joysticks/navmesh? That's the\n  `game-controls` skill (GameControl event) — not this loop, never both\n  on one player.\n"},"changes":[],"summary":"First saved snapshot. No earlier version is available for comparison.","summary_kind":"deterministic","summary_metadata":{}}