← SplineCONTENT HISTORY
WHAT CHANGED
Update to Spline
Snapshot Oct 24, 2026 · 13:29 UTC · version 1.2.0
Collection source: downloaded plugin package.
First saved snapshot
No earlier snapshot is available to establish a change.
Save Spline and check future changes in Following.
Up to 10 plugins free · no card required.
Compare saved observations
Download comparison JSONFull technical diff · 0 changed fields
Full snapshot data
{
"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"
}SHA-256 of public snapshot: 9f8518b798019ff8d61d7389354712840af70a0d83f28df4aea83e481c6596e4