← SplineCONTENT HISTORY

Update to Spline

Snapshot Oct 24, 2026 · 13:29 UTC · version 1.2.0

Collection source: downloaded plugin package.

WHAT CHANGED

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 JSON
Full technical diff · 0 changed fields
Full snapshot data
{
  "description": "The built-in Game Controls feature — load ONLY when the user explicitly asks for it (says 'game controls', wants the editor's built-in character controller, touch joysticks, or a navmesh): the GameControl event (keyAssignments key mapping, walk/fly, collider, follow camera, idle/move/jump/run action containers), pairing it with physics() so the character stands on and pushes the decor, and Collision/Trigger events for pickups, obstacles and goal zones. Code-driven games (the default) are the `games` skill.",
  "included_files": [],
  "name": "spline-3d-game-controls",
  "skill_md_contents": "---\nname: spline-3d-game-controls\ndescription: \"The built-in Game Controls feature — load ONLY when the user explicitly asks for it (says 'game controls', wants the editor's built-in character controller, touch joysticks, or a navmesh): the GameControl event (keyAssignments key mapping, walk/fly, collider, follow camera, idle/move/jump/run action containers), pairing it with physics() so the character stands on and pushes the decor, and Collision/Trigger events for pickups, obstacles and goal zones. Code-driven games (the default) are the `games` skill.\"\n---\n\n# Game Controls (the built-in GameControl event)\n\nOPT-IN ONLY: games are code-driven by default (the `games` skill — a JS\ngame loop + HTML UI). Use the built-in Game Controls feature ONLY when the\nuser explicitly asks for it — they say \"game controls\", want the editor's\nbuilt-in character controller, touch joysticks, or navmesh-constrained\nwalking. Never put both movement systems on one player. Scene/hero building\nguidance (generating the character with the AI mesh tool, budgets) is in\nthe `games` skill and applies here too.\n\nA GameControl-based scene is four ingredients wired together:\n\n1. a **player** carrying a `'GameControl'` event (keyboard/touch movement,\n   jumping, follow camera); normally create one controller per page;\n2. a **solid world** — `physics(...)` on every floor, platform, wall and prop\n   so the character can stand on them and bump into them;\n3. **gameplay reactions** — `'Collision'` events (physical touch) and\n   `'Trigger'` events (entering a zone) running actions on contact;\n4. **game state** — variables for score/lives, Conditional actions for win/lose.\n\nEverything uses the standard event/action API (`addEvent` / `updateEvent` /\n`addAction` / `updateAction`) — load the `interactivity` skill for that\nmachinery; this skill covers the game-specific parts.\n\n## The GameControl event — making an object playable\n\nSelect the player object, then `const gc = addEvent('GameControl');`. The\nalias special-cases it and seeds a complete third-person setup. Multiple objects\nmay carry GameControl events, but one page-level PRIMARY object supplies desktop\nand touch controls. The first valid controller remains primary; merely adding\nanother event does not transfer control. Normally create one. In an existing\nmulti-controller scene inspect first—`addEvent` cannot choose a different primary:\n\n- WASD to move, arrow keys to rotate, Space to jump, Shift to run;\n- `moveMode: 'walk'` — gravity, landing on physics objects, jumping;\n- a roughly auto-fitted capsule collider (replace it — see Collider below);\n- the play camera following the character with a soft delay.\n\nThe player is a normal object — a mesh, an imported character, or a group\n(attach the event to the top group so the whole character moves as one and its\nskeletal clips are available to the action containers below). For a GENERATED\nmodel, put the event on it directly or on its wrapping group, set the\n`collider` EXPLICITLY (the auto-fit is even rougher on generated/imported\nmeshes, and their pivot often sits at the feet — `position: [0, height / 2,\n0]`), give it `physics(...)` like any player, and confirm its facing (a\ngenerated character may not lead with its `+z` side — watch for the \"every\nkey inverted\" symptom and flip `forwardDirection`, see Facing).\n\n### Tuning — `updateEvent(gc, { ... })`\n\nMovement:\n- `moveMode`: `'walk'` (gravity + jump) or `'fly'` (free 3D flight; the\n  moveNegY/movePosY assignments become usable).\n- `speedTranslate` (default 1000), `runMultiplier` (default 2, applied while\n  the run key is held), `speedRotate` (default 100).\n- `delayPos` / `delayRot` — `[in, out]` smoothing for start/stop of\n  translation/rotation (0 = instant, 0.3 = soft).\n- `jumpPower` (default 100). Jumping requires walk mode, `collisionEnabled:\n  true`, and the character standing on something.\n- `resetYPosition` (default 3000) — how far the character may fall below its\n  starting height before auto-resetting to the start (\"fell off the level\").\n- `forwardDirection: '+z' | '-z'` — which LOCAL axis is the character's\n  front. It MUST match the side of the object that actually leads: `'+z'`\n  (default) is the side meshes and imports face when created (toward the\n  default camera); `'-z'` is a front that points INTO the screen — set it\n  for behind-the-back setups (a paddle facing the net, a runner heading away\n  from the camera). Wrong value = every movement key inverted (see Key\n  mapping). Movement slots stay character-relative either way (`moveNegZ`\n  is always \"walk forward\").\n- `autoOrientMove: true` — the character turns its front toward its movement\n  direction while moving (`orientWith: 'camera' | 'axis'`, `orientMode:\n  'radial' | 'cartesian'`); `false` — the character keeps a fixed heading\n  and strafes (set the heading yourself — see Facing).\n- `rotBy`: `'keys' | 'mouse' | 'both'` — mouse-look disables the rotation\n  key assignments.\n- `collisionEnabled` (default true) — false ghosts through everything.\n\nCollider — SET IT EXPLICITLY. The auto-fit is approximate (bounding-box\nbased; often off-center or mis-sized, especially on groups and imported\nmodels), so right after `addEvent` pass a complete collider matching the\nvisible mesh:\n\n```\nupdateEvent(gc, { collider: { type: 'capsule', height: 180, radius: 50,\n\tposition: [0, 0, 0], rotation: [0, 0, 0] } });\n```\n\n- `position` is relative to the object's origin — usually `[0, 0, 0]`\n  (all primitives are centered on their pivot); an import with its pivot at\n  the feet needs `[0, height / 2, 0]`.\n- `type: 'capsule'` (height, radius — height includes the end caps, so keep\n  `height >= 2 * radius`), `'box'` (width/height/depth), `'sphere'`\n  (radius). Sizes are world units; `rotation` is RADIANS.\n- Keep `colliderHelperVisible: true` while building and check a screenshot:\n  the green helper must wrap the mesh snugly, centered on it.\n\nCamera:\n- `camera`: a camera object id — that camera follows the character\n  (`delayPosCamera` / `delayRotCamera` smooth the follow). It keeps the\n  angle it has when play starts, so frame the shot first. The follow camera and\n  page Play Camera must be the same: `updateEvent(gc, { camera: playCam.id });`\n  then `setPlayCamera(playCam);`. `activateCamera` changes only the editor\n  viewport and is not a substitute. The first GameControl is seeded from the\n  current Play Camera when available.\n- `cameraXAxis` / `cameraYAxis`: `'Locked' | 'Limit' | 'Free'` — how much\n  the user can orbit around the character. Lock BOTH for a fixed-angle game\n  (side-scroller, top-down). `cameraRotXLimits` / `cameraRotYLimits`\n  (radians) bound the 'Limit' mode.\n\nTouch:\n- `touchControl: true` (default) shows joysticks + a jump button on touch\n  devices automatically; `joystickPosLoc` / `joystickRotLoc` /\n  `jumpTouchButtonLoc` pick their screen slots.\n\nNavmesh (optional): `navmesh: { enabled: true, zones: 'all' | 'custom',\nobjects: [...], ... }` bakes a navigation mesh and constrains walking to it —\na way to fence the walkable area without invisible walls.\n\nIMPORTANT: `updateEvent` shallow-merges top-level props. Nested values —\n`keyAssignments`, `collider`, `navmesh` — are replaced WHOLE, so always\npass every field of the nested object (read the current one with\n`get_objects` first when editing).\n\n### Key mapping — `keyAssignments`\n\nThe complete mapping is a list of `[movement, key]` pairs. This is the\ncanonical default layout and order:\n\n```\n[['moveNegZ', 'W'], ['moveNegX', 'A'], ['movePosZ', 'S'], ['movePosX', 'D'],\n ['rotPosX', '▲'], ['rotPosY', '◀'], ['rotNegX', '▼'], ['rotNegY', '▶'],\n ['jump', 'Space'], ['run', '⇧'], ['none', 'Ctrl']]\n```\n\nFor generated standard layouts, preserve all existing entries/order and change\nonly action codes unless the user explicitly requests different keys. The\nproduct supports re-keying: first read `keyAssignments` with `get_objects`,\nreplace the desired key string, and send the ENTIRE array because updates replace\nit whole. `'▲' '◀' '▼' '▶'` conventionally represent arrow keys and `'⇧'`\nShift, but other key strings are valid. Movements: `moveNegZ`\n(forward) / `movePosZ` (backward) / `moveNegX` (strafe left) /\n`movePosX` (strafe right), `moveNegY` / `movePosY` (fly-mode\ndescend/climb), `rotPosX` / `rotNegX` (look up/down), `rotPosY` /\n`rotNegY` (turn left/right), `jump`, `run`, and `'none'` (key does\nnothing). Several slots may share one movement (e.g. both 'D' and '▶' →\nforward). To restrict a control scheme, set every unused slot to `'none'`.\n\nTHE MOVEMENTS ARE CHARACTER-RELATIVE, NOT WORLD AXES. `moveNegZ` always\nmeans \"walk the way the character's front points\" — it is the seeded 'W' —\nregardless of the object's rotation or `forwardDirection` (which only picks\nwhich local mesh axis counts as the front). So to send the character in a\nworld/screen direction, do NOT hunt for the matching X/Z slot: rotate the\nCHARACTER to face that direction (see Facing) and bind the\nforward/backward slots. A side-scroller whose goal is to the right =\ncharacter rotated to face +X, then `moveNegZ` (forward) on ▶ and\n`movePosZ` (backward) on ◀. The strafe slots (`moveNegX`/`movePosX`)\nare for sidestepping in free-roam schemes, not for picking world directions.\nAfter wiring, `play()` and have the user confirm the directions.\n\nIf EVERY direction key feels inverted while the assignments read correctly,\nthe `forwardDirection` doesn't match the character's real front (the event\nkeeps the seeded `'+z'` while the object actually leads with its −Z side,\nor vice versa). Flip `forwardDirection` — don't rewire the keys.\n\n### Facing — which way the character looks\n\nA character's front is its local `forwardDirection` axis — `'+z'` by\ndefault, the side that faces the camera when a mesh or import is created. Do\nNOT assume the controller will turn a camera-facing model toward its movement:\n\n- Fixed-heading games (side-scroller, top-down): set `autoOrientMove: false`\n  and rotate the PLAYER OBJECT once so it faces the travel direction — e.g.\n  `rotation({ y: 90 })` turns a camera-facing model to face +X (screen right\n  on a front camera). The movement basis rotates WITH the heading: after this,\n  `moveNegZ` (forward) walks +X — which is exactly why you aim the character\n  first and then just bind forward/backward.\n- Behind-the-back games (camera behind the player, looking into the scene —\n  a ping-pong paddle facing the net, a runner heading into the screen): the\n  playable object's front is its local −Z, so set\n  `forwardDirection: '-z'`. Do NOT fix this by rotating the object 180°\n  (that would visually turn it around) and do NOT rewire the keys — leaving\n  `'+z'` here inverts EVERY movement key even though the keyAssignments\n  read correctly, which is the telltale symptom.\n- Free-roam third-person: keep `autoOrientMove: true` (the default) — the\n  character turns toward where it walks. Verify in play mode that the model\n  doesn't walk backwards or sideways; if it does, its geometry front doesn't\n  match `forwardDirection` — nest the mesh in a group (put the event on the\n  group) and pre-rotate the mesh inside, or flip `forwardDirection`.\n\n### The gameActions containers — idle / move / jump / run\n\nGameControl actions live in four containers keyed to what the character is\ndoing — pass the container name as `addAction`'s 3rd argument (default:\n`'move'`):\n\n- `'idle'` — on the ground, not moving;\n- `'move'` — moving on the ground;\n- `'run'` — moving with the run key held (replaces 'move' while held);\n- `'jump'` — from takeoff until landing.\n\nAllowed action types: Transition / Animation / TimelineAnimation / Audio /\nParticles / Create / Conditional. Lifecycle differs by action: Transition stops\nand returns its target to Base State; TimelineAnimation, Audio, and Particles\nstop; Create receives start/stop lifecycle. Skeletal Animation starts on entry\nbut GameControl does NOT explicitly stop it on exit, so do not rely on exit to\nstop imported clips—test them in Play mode. Conditional gates its nested actions\nand is not visible feedback itself. Use these for a walk cycle in 'move', a\nstretched pose or whoosh in 'jump', or dust particles in 'run':\n\n```\nconst walk = addAction(gc, 'Animation', 'move');\nupdateAction(gc, walk.actionId, { clipId: '<clip id>' }); // only if the object has imported clips\n```\n\n## Physics — making the world solid\n\nThe character only collides with objects that are IN the physics world.\n`physics(props)` on the SELECTED object(s) puts them there (and switches\nphysics on for the page the first time):\n\n- `physics({ type: 'positioned' })` — immovable collider: floors, platforms,\n  walls, ramps, obstacles. This is the default type.\n- `physics({ type: 'dynamic' })` — fully simulated: falls under gravity, gets\n  pushed by the character, can be knocked off ledges (crates, balls, dominoes).\n- Extras: `friction` (0..1), `bounce` (0..1), `density`, `gravityScale`\n  (0 = floats), `damping`, `colliderType: 'convex' (default) | 'trimesh'` —\n  use `'trimesh'` for concave shapes the character walks inside or through\n  (bowls, arches, tunnels); a convex hull seals openings shut.\n\nGive the PLAYER physics too — `physics({ friction: 0.5 })` is enough. An\nobject without physics does not exist to the physics world, including the\ncharacter itself. The character's body is kinematic: the GameControl event\nmoves it directly, it pushes 'dynamic' bodies around but is never pushed back,\nand its shape comes from the event's `collider`, not the mesh.\n\nAnything the character must stand on needs physics — a ground without it means\nthe character falls at play start until `resetYPosition` respawns it.\n\nThis is the game-facing summary. The full simulation reference — axis locks,\nfused vs separate group bodies, moving platforms/sweepers (kinematic movers),\nspawning dynamic bodies with the Create action, page gravity, units — is the\n`physics` skill.\n\n## Collision events — reacting to touch\n\nA `'Collision'` event goes on the DECOR object (the coin, the spike, the\ncrate) — NOT on the player. It fires once each time a contact STARTS (it\nre-arms after separation), and needs the object to have physics enabled:\n\n- `target: 'character'` (default) — fires when the game-controlled character\n  touches this object.\n- `target: 'scene'` — fires when any OTHER physics body collides with it\n  (a dynamic crate landing on a pressure plate).\n\nPickup recipe — \"touching the coin collects it\":\n\n```\nconst scoreId = setVariable('score', 0);\nselect(o => o.name === 'Coin');\nphysics({ type: 'positioned' });\nconst hit = addEvent('Collision'); // target defaults to 'character'\nconst s = addAction(hit, 'SetVariable');\nupdateAction(hit, s.actionId, {\n\tvariableId: scoreId,\n\texpression: 'score + 1', // the new value — score = score + 1\n});\nconst d = addAction(hit, 'Destroy');\n// coinId must be the real id read from get_scene/get_objects in this edit call.\nupdateAction(hit, d.actionId, { objects: [coinId] }); // the coin removes itself\n```\n\nAdd an `'Audio'` action for a pickup sound, or a `'Transition'` on the hit\nobject (flash red on damage, squash a bumper). Collision events are suppressed\nfor the first ~80ms of play so resting contacts (character already standing on\nthe floor) don't all fire at start.\n\n## Trigger events — invisible zones\n\n`addEvent('Trigger')` on an object defines an invisible box/sphere sensor\npositioned RELATIVE to that object. It fires when something ENTERS the zone —\nno physical contact needed, and the SENSOR never blocks movement. A mesh host\nwith ordinary positioned physics still has its own solid collider, however; keep\nthat collider outside the path:\n\n- `triggerZone: 'box' | 'sphere'`, `size: [w,h,d]` or `radius`,\n  `position`, `rotation` (degrees in this DSL), `helperVisible`.\n- `target: 'all'` (any object) or `'custom'` +\n  `triggeringObjects: ['<player id>']` — restrict to the player so a stray\n  crate can't finish the level.\n- When page physics is on, the object HOSTING the trigger needs physics\n  enabled too (the sensor is created as part of its physics body), and only\n  objects with physics colliders are detected entering it.\n\nCollision vs Trigger: a collision is a physical touch of the object's real\ncollider (it blocks the character); a trigger is a volume passed through. Use\ntriggers for goal/finish lines, checkpoints, kill zones under the level, doors\nthat open on approach; use collisions for pickups, obstacles and damage.\n\n## Game state — initialization, score, win, lose, restart\n\n`setVariable` declares authoring-time state. Use a Start event when runtime\ninitialization must run when the played/exported scene loads (and after a full\nscene restart):\n```\nconst scoreId = setVariable('score', 0);\nconst start = addEvent('Start');\nconst initScore = addAction(start, 'SetVariable');\nupdateAction(start, initScore.actionId, {\n  variableId: scoreId,\n  expression: '0',\n});\n```\nStart also supports current schema-approved intro actions such as Transition,\nAnimation, TimelineAnimation, Audio, Particles, Video, Create, Destroy,\nSwitchCamera, SceneTransition, Reset, SetVariable, DynamicVariablePlay,\nConditional, ClearLocalStorage, and UserAPI.\n\nAfter a Collision increments score, put VariableChange on a stable controller or\nUI object and gate the win behavior:\n```\nconst changed = addEvent('VariableChange');\nupdateEvent(changed, { variableId: scoreId });\nconst gate = addAction(changed, 'Conditional');\nupdateAction(changed, gate.actionId, { condition: 'score >= 3' });\nconst reveal = addAction(changed, 'Transition', {\n  parentActionId: gate.actionId,\n  branch: 'if',\n});\nupdateAction(changed, reveal.actionId, { object: winUiId });\n// Configure reveal.tweenId with the real target state id from get_objects.\n```\nVariableChange may fire after every increment; disable/destroy remaining pickups\nor guard with a `won`/phase variable if the win branch must run once.\n\nFor a live score/timer READOUT, skip events entirely: make a Text object and\nbind its content — `select('ScoreText'); bindVariable('text', 'score');` (a\ncountdown is a `dynamicVariable('timeLeft', 'timer', …)` bound the same way,\nwith a VariableChange + Conditional on it for the time-up branch).\n\nKeep three lose/restart patterns distinct:\n- Fall recovery: `resetYPosition` restores only the controlled character's\n  initial transform; score and world state persist.\n- Full restart: Reset means Reset Scene—the whole runtime restarts after an\n  optional delay: `const r = addAction(keyEvent, 'Reset');\n  updateAction(keyEvent, r.actionId, { delay: 1 });` (seconds).\n- Lives: decrement `lives` on Collision/Trigger, then VariableChange +\n  Conditional reveals lose UI; a separate KeyDown/MouseDown Reset restarts.\nA selective checkpoint teleport needs scene-specific transitions/state and is\nnot provided by generic Reset.\n\n## Worked example — platform side-scroller\n\nFixed side-on camera, platforms along X, ◀/▶ to move back/forward along the\nplatforms ONLY (all other movement unmapped), Space to jump, one collectible,\none goal zone:\n\n```\n// --- world: long ground + a floating platform ---\nmesh('Cube', { width: 6000, height: 100, depth: 400 });\nposition({ y: -50 }); rename('Ground');\nphysics({ type: 'positioned', friction: 0.8 });\nmesh('Cube', { width: 800, height: 60, depth: 400 });\nposition({ x: 900, y: 250 }); rename('Platform');\nphysics({ type: 'positioned', friction: 0.8 });\n\n// --- camera: create/frame it, then use the returned reference below ---\nconst playCam = add('PerspectiveCamera');\nposition({ x: 0, y: 900, z: 3000 });\n\n// --- player ---\nmesh('Cube', { width: 100, height: 180, depth: 100 });\nposition({ x: -2000, y: 90 }); color('#e0552b'); rename('Player');\nrotation({ y: 90 }); // face +X (screen right) — the travel direction\nphysics({ friction: 0.5 }); // register the player with the physics world\nconst gc = addEvent('GameControl');\nupdateEvent(gc, {\n\tkeyAssignments: [\n\t\t// Standard generated layout: preserve keys/order; change action codes.\n\t\t// The character faces +X (rotation above), so 'forward' (moveNegZ)\n\t\t// walks toward the goal on the right. Forward/backward only.\n\t\t['none', 'W'],\n\t\t['movePosZ', 'A'], // A = go back (screen left)\n\t\t['none', 'S'],\n\t\t['moveNegZ', 'D'], // D = go forward (screen right)\n\t\t['none', '▲'],\n\t\t['movePosZ', '◀'], // left arrow  = go back\n\t\t['none', '▼'],\n\t\t['moveNegZ', '▶'], // right arrow = go forward\n\t\t['jump', 'Space'],\n\t\t['run', '⇧'],\n\t\t['none', 'Ctrl'],\n\t],\n\tcollider: {\n\t\t// explicit — centered on the mesh, same size as it (see Collider)\n\t\ttype: 'capsule', height: 180, radius: 50,\n\t\tposition: [0, 0, 0], rotation: [0, 0, 0],\n\t},\n\tautoOrientMove: false, // hold the fixed heading set with rotation() above\n\tjumpPower: 150,\n\tcameraXAxis: 'Locked', // keep the follow camera fixed side-on\n\tcameraYAxis: 'Locked',\n\tcamera: playCam.id,\n});\nsetPlayCamera(playCam); // same camera for follow and initial Play/export view\n```\n\nBecause only forward/backward along the character's +X heading are mapped, it\ncan only run along the platforms and jump — depth is unreachable, which IS the\nside-scroller. Frame the play camera front-on (looking down -Z) before testing\nso \"right\" on screen is +X.\n\n```\n// Call 1 may create a visible Goal arch/flags OUTSIDE the travel lane and a\n// Coin at { x: 900, y: 330 }. Then inspect get_scene/get_objects for playerId,\n// coinId, groundId, winUiId and the Win UI state's id before Call 2.\n\n// Call 2: wire the Coin with the Collision recipe above using real coinId.\n// Put the non-blocking goal sensor on existing positioned Ground, not a cube\n// collider across the finish line. Ground's sensor position is host-relative.\nselect(o => o.id === groundId);\nconst win = addEvent('Trigger');\nupdateEvent(win, {\n\ttarget: 'custom',\n\ttriggeringObjects: [playerId],\n\ttriggerZone: 'box',\n\tsize: [300, 600, 600],\n\tposition: [2600, 300, 0],\n\trotation: [0, 0, 0],\n\thelperVisible: true,\n});\nconst goalReveal = addAction(win, 'Transition');\nupdateAction(win, goalReveal.actionId, { object: winUiId });\nupdateTween(win, goalReveal.actionId, goalReveal.tweenId, {\n\tstate: winUiStateId, duration: 300, easing: 'ease-out',\n});\n```\n\n## Verify\n\n`play()` starts the game (not undoable; `stop()` returns to editing). A\nscreenshot after `play()` shows the initial framing, the character's facing,\nand whether it is standing (not sunk into or fallen through the ground) — but\nyou cannot press keys, so for actual movement, jumping and pickups, tell the\nuser the controls and ask them to confirm, ESPECIALLY that the directions\naren't inverted and the character faces its travel direction (the two most\ncommon mistakes — see Key mapping and Facing).\n\nGotchas:\n- Multiple GameControl events may exist, but only the page-level primary gets\n  desktop/touch input; adding another does not transfer primary control.\n- Movement slots are character-relative, not world axes: `moveNegZ` =\n  forward along the character's heading. Aim the character at the goal, then\n  bind forward/backward — never pick X/Z slots by world direction.\n- Every key inverted despite a correct-looking mapping = wrong\n  `forwardDirection`. `'-z'` for objects that face into the screen\n  (paddle, behind-the-back camera); flip it instead of rewiring keys.\n- Never keep the auto-fitted collider — set `collider` explicitly, centered\n  on the mesh (`position: [0,0,0]`) and sized like it.\n- `keyAssignments` / `collider` / `navmesh` are replaced whole; send every\n  field. For a standard generated layout preserve all 11 entries/order/keys;\n  intentionally re-key only after reading the complete current array.\n- GameControl collider rotation and camera rot limits are radians; a Trigger\n  zone's `rotation` is degrees (the DSL converts it).\n- Collision and Trigger events only see objects that are in the physics world\n  (physics enabled — and visible, when `enabled: 'visibility'`).\n- The character pushes 'dynamic' props but stands on 'positioned' ones; a\n  'dynamic' platform sinks under the character unless that's the point.\n"
}

SHA-256 of public snapshot: 1a573c6f3aaa7bce465f927c33a4471fe8cc48d85174c76ff7be24fa574329a3