{"id":46299,"plugin_id":"plugin_asdk_app_6acd235a70a48191812b3ba9c471b4ac","kind":"skill","collection_source":"plugin_package","comparison_source":null,"observed_at":"2026-10-24T13:29:11.361Z","digest":"9f8518b798019ff8d61d7389354712840af70a0d83f28df4aea83e481c6596e4","against":null,"payload":{"description":"The complete worked example for the games skill: a drive-&-collect game end to end — the run_code scene call and the full HTML game document (start gate, input, fixed-timestep loop, chase camera with exact aim math, JS collisions, HUD, restart-from-snapshots, sound hooks). Load it when building a NEW game or game-like scene; edits to an existing game usually only need the games skill itself.","included_files":[],"name":"spline-3d-game-example","skill_md_contents":"---\nname: spline-3d-game-example\ndescription: \"The complete worked example for the games skill: a drive-&-collect game end to end — the run_code scene call and the full HTML game document (start gate, input, fixed-timestep loop, chase camera with exact aim math, JS collisions, HUD, restart-from-snapshots, sound hooks). Load it when building a NEW game or game-like scene; edits to an existing game usually only need the games skill itself.\"\n---\n\n# Games — worked example (drive & collect)\n\nThis is the reference implementation of the `games` skill's rules — load\nthat skill first; every contract it states is instantiated here.\n\n## Worked example — drive & collect\n\nScene (one run_code call):\n\n```\nmesh('Plane', { width: 9000, height: 9000 });\nrotation({ x: -90 }); position({ y: 0 }); color('#8fbf6f'); rename('Ground');\n\n// player car — placeholder box. When the generated hero lands, run the\n// swap procedure above: scale/wrap/yaw-fix the mesh, remove() THIS\n// object, give the wrapper this exact name, wheels at group-local y = 0.\nmesh('Cube', { width: 180, height: 90, depth: 340 });\nposition({ x: 0, y: 60, z: 0 }); color('#e0552b'); rename('Player');\n\nfor (let i = 0; i < 6; i++) {\n  const a = (i / 6) * Math.PI * 2;\n  mesh('Cylinder', { radius: 60, height: 16 });\n  position({ x: Math.cos(a) * 1600, y: 90, z: Math.sin(a) * 1600 });\n  rotation({ x: 90 }); color('#f2c14e'); rename('Coin ' + (i + 1));\n}\n\n// chase camera at the ROOT (never grouped), aimed like the game loop aims it\nconst cam = add('PerspectiveCamera');\nrename('ChaseCam'); position({ x: 0, y: 320, z: -720 }); // behind & above\nrotation({ x: 12, y: 180 }); // ≡ aimEuler(0, -12): along +Z, tilted down 12°\nsetPlayCamera(cam);\nplayControls('none'); // game code drives the camera — no play orbit controls\n\n// size matches the ground span — the default 2000 shadow footprint would\n// cut shadows off a quarter of the way across this 9000-unit play area\nlight('Directional', { intensity: 0.9, size: 9000 });\ntranslate({ x: 500, y: 900, z: 400 });\nbackground('#bfd9ee');\n```\n\nThe complete game document (set_html_content):\n\n```html\n<!doctype html>\n<html>\n<head>\n<style>\n  #stage { position: fixed; inset: 0; }\n  .panel { position: fixed; inset: 0; display: flex; flex-direction: column;\n           align-items: center; justify-content: center; gap: 14px;\n           background: rgba(10,12,18,.55); color: #fff;\n           font: 15px/1.5 system-ui; text-align: center; }\n  .hud { position: fixed; top: 16px; left: 16px; padding: 10px 14px;\n         border-radius: 10px; background: rgba(10,12,18,.7); color: #fff;\n         font: 13px/1.6 system-ui; }\n  button { font: 600 15px system-ui; padding: 12px 28px; border: 0;\n           border-radius: 10px; background: #4c8dff; color: #fff;\n           cursor: pointer; }\n  .hidden { display: none; }\n</style>\n</head>\n<body>\n<div id=\"stage\"></div>\n<div class=\"hud hidden\" id=\"hud\">\n  Coins: <span id=\"score\">0</span>/6 · <span id=\"kmh\">0</span> km/h\n</div>\n<div class=\"panel\" id=\"menu\">\n  <h1>Coin Chase</h1>\n  <p>W/↑ throttle · S/↓ brake · A D/← → steer · R restart</p>\n  <button id=\"start\">Start</button>\n</div>\n<div class=\"panel hidden\" id=\"paused\">\n  <h1>Paused</h1>\n  <button id=\"resume\">Resume</button>\n</div>\n<div class=\"panel hidden\" id=\"over\">\n  <h1>You win!</h1>\n  <button id=\"again\">Play again</button>\n</div>\n<script>\n(async () => {\n  await spline.ready;\n  const player = await spline.findObjectByName('Player');\n  const cam = await spline.findObjectByName('ChaseCam');\n  const coins = (await spline.getAllObjects())\n    .filter((o) => o.name.startsWith('Coin '));\n\n  const keys = new Set();\n  let state = 'menu'; // 'menu' | 'playing' | 'paused' | 'over'\n  let x, z, yaw, speed, score; // sim state — integrated at a FIXED step\n  let vx, vz, vyaw;            // visual state — low-passed copy that gets written\n  let cx, cy, cz;              // eased chase camera\n\n  // ---- procedural sound (created on Start — the click unlocks audio) ----\n  let ctx, master, engine;\n  function audioInit() {\n    if (ctx) { ctx.resume(); return; }\n    ctx = new AudioContext();\n    master = ctx.createGain();\n    master.gain.value = 0.2; // headroom — every SFX routes through this\n    master.connect(ctx.destination);\n    const filt = ctx.createBiquadFilter();\n    filt.type = 'lowpass';\n    engine = { osc: ctx.createOscillator(), filt, gain: ctx.createGain() };\n    engine.osc.type = 'sawtooth';\n    engine.gain.gain.value = 0; // silent until 'playing' (see setState)\n    engine.osc.connect(filt).connect(engine.gain).connect(master);\n    engine.osc.start();\n  }\n  function blip(freq, dur = 0.09, type = 'square', delay = 0) {\n    const t = ctx.currentTime + delay;\n    const o = ctx.createOscillator();\n    const g = ctx.createGain();\n    o.type = type;\n    o.frequency.setValueAtTime(freq, t);\n    g.gain.setValueAtTime(0.5, t);\n    g.gain.exponentialRampToValueAtTime(0.001, t + dur);\n    o.connect(g).connect(master);\n    o.start(t);\n    o.stop(t + dur);\n  }\n\n  const el = (id) => document.getElementById(id);\n  function setState(next) {\n    state = next;\n    if (engine) engine.gain.gain.value = state === 'playing' ? 0.1 : 0;\n    el('menu').classList.toggle('hidden', state !== 'menu');\n    el('paused').classList.toggle('hidden', state !== 'paused');\n    el('over').classList.toggle('hidden', state !== 'over');\n    el('hud').classList.toggle('hidden', state === 'menu');\n  }\n  function reset() {\n    ({ x, z } = player.position);\n    yaw = player.rotation.y; speed = 0; score = 0;\n    vx = x; vz = z; vyaw = yaw; // visuals start ON the sim\n    el('score').textContent = '0';\n    for (const c of coins) {\n      c.taken = false;\n      spline.updateObject(c.id, { visible: true });\n    }\n    spline.updateObject(player.id, { position: { x, z }, rotation: { y: yaw } });\n    moveCamera(0); // 0 = snap to the chase point, no swoop-in\n  }\n  el('start').onclick = () => { audioInit(); reset(); setState('playing'); };\n  el('again').onclick = () => { audioInit(); reset(); setState('playing'); };\n  el('resume').onclick = () => { audioInit(); setState('playing'); };\n  // Focus loss = keys go dead (toolbar Play, tab switch, chrome clicks).\n  // Never stay 'playing' deaf: clear held keys and gate on a Resume click,\n  // which brings focus back into this document.\n  addEventListener('blur', () => {\n    keys.clear();\n    if (state === 'playing') setState('paused');\n  });\n\n  const GAME_KEYS = ['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown',\n    'ArrowLeft', 'ArrowRight', 'KeyR'];\n  addEventListener('keydown', (e) => {\n    if (GAME_KEYS.includes(e.code)) e.preventDefault();\n    keys.add(e.code);\n    if (e.code === 'KeyR' && state !== 'menu' && !e.repeat) {\n      reset(); setState('playing');\n    }\n  });\n  addEventListener('keyup', (e) => keys.delete(e.code));\n  const held = (...codes) => codes.some((c) => keys.has(c));\n\n  const wrap = (d) => ((d + 540) % 360) - 180;\n  const damp = (a, b, rate, dt) => a + (b - a) * (1 - Math.exp(-rate * dt));\n  const dampAngle = (a, b, rate, dt) =>\n    a + wrap(b - a) * (1 - Math.exp(-rate * dt));\n  function aimEuler(yawDeg, pitchDeg) { // see Layer 2 — exact XYZ aim\n    const yr = (yawDeg + 180) * Math.PI / 180;\n    const pr = pitchDeg * Math.PI / 180;\n    const cy = Math.cos(yr), sy = Math.sin(yr);\n    const cp = Math.cos(pr), sp = Math.sin(pr);\n    const r2d = 180 / Math.PI;\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  // Eased follow + aim-at-car (Layer 2): the car and the camera are two\n  // separate updateObject writes that can land a render frame apart — a\n  // camera bolted to \"car minus offset\" turns that skew into the car\n  // buzzing in place at speed. Everything here reads the VISUAL state.\n  const CAM_DIST = 720, CAM_HEIGHT = 320, CAM_AIM_UP = 120;\n  function moveCamera(dt) {\n    const pr = (vyaw * Math.PI) / 180;\n    const gx = vx - Math.sin(pr) * CAM_DIST;\n    const gz = vz - Math.cos(pr) * CAM_DIST;\n    if (dt) {\n      cx = damp(cx, gx, 6, dt); cy = damp(cy, CAM_HEIGHT, 6, dt);\n      cz = damp(cz, gz, 6, dt);\n    } else { cx = gx; cy = CAM_HEIGHT; cz = gz; } // reset: snap\n    const dx = vx - cx, dy = CAM_AIM_UP - cy, dz = vz - cz;\n    spline.updateObject(cam.id, {\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\n  const ACCEL = 2200, BRAKE = 3200, DRAG = 1.4;\n  const TOP = 2400, TOP_REV = -700, STEER = 150;\n  const STEP = 1 / 120; // FIXED sim step — raw-dt integration lurches on hitches\n  let acc = 0, hudT = 0, last = performance.now();\n\n  function simulate(dt) { // fixed-step slices only — never the frame delta\n    const throttle = held('KeyW', 'ArrowUp') ? 1 : 0;\n    const brake = held('KeyS', 'ArrowDown') ? 1 : 0;\n    speed += (throttle * ACCEL - brake * BRAKE) * dt;\n    speed = Math.max(TOP_REV, Math.min(TOP, speed * Math.exp(-DRAG * dt)));\n    const steer = (held('KeyA', 'ArrowLeft') ? 1 : 0) -\n      (held('KeyD', 'ArrowRight') ? 1 : 0);\n    yaw += steer * STEER * (speed / TOP) * dt; // no pivoting in place\n    const rad = (yaw * Math.PI) / 180;\n    x = Math.max(-4300, Math.min(4300, x + Math.sin(rad) * speed * dt));\n    z = Math.max(-4300, Math.min(4300, z + Math.cos(rad) * speed * dt));\n  }\n\n  (function tick(now) {\n    requestAnimationFrame(tick);\n    const dt = Math.min((now - last) / 1000, 0.05);\n    last = now;\n    if (state !== 'playing') return;\n\n    acc += dt;\n    while (acc >= STEP) { simulate(STEP); acc -= STEP; }\n\n    // what the scene shows is a low-passed copy — a dropped frame can't jump\n    vx = damp(vx, x, 25, dt); vz = damp(vz, z, 25, dt);\n    vyaw = dampAngle(vyaw, yaw, 25, dt);\n    spline.updateObject(player.id,\n      { position: { x: vx, z: vz }, rotation: { y: vyaw } });\n    moveCamera(dt);\n\n    // engine hum: pitch and brightness follow the speed\n    engine.osc.frequency.value = 40 + (Math.abs(speed) / TOP) * 140;\n    engine.filt.frequency.value = 300 + (Math.abs(speed) / TOP) * 900;\n\n    for (const c of coins) {\n      if (c.taken) continue;\n      if (Math.hypot(c.position.x - x, c.position.z - z) < 170) {\n        c.taken = true;\n        score++;\n        spline.updateObject(c.id, { visible: false });\n        el('score').textContent = String(score);\n        blip(880);\n        blip(1320, 0.12, 'square', 0.07);\n        if (score === coins.length) {\n          [523, 659, 784].forEach((f, i) => blip(f, 0.18, 'triangle', i * 0.12));\n          setState('over');\n        }\n      }\n    }\n    hudT += dt;\n    if (hudT >= 0.1) { // ~10 Hz — DOM text every frame causes hitches\n      hudT = 0;\n      el('kmh').textContent = String(Math.round((Math.abs(speed) / 200) * 3.6));\n    }\n  })(last);\n})();\n</script>\n</body>\n</html>\n```\n"},"changes":[],"summary":"First saved snapshot. No earlier version is available for comparison.","summary_kind":"deterministic","summary_metadata":{}}