{
  "name": "3d-engine",
  "title": "3D Engine - Minimal Three.js + Rapier + Gipity Realtime Template",
  "description": "Minimal 3D multiplayer template - Three.js rendering, Rapier physics, Gipity Realtime multiplayer, player controller, voxel primitives. No gameplay; start here to build something new.",
  "guid": "sk_plat_3deng",
  "category": "Templates",
  "requiredTools": [
    "add",
    "realtime_room",
    "file_write",
    "project_deploy"
  ],
  "content": "# 3D Engine - Minimal 3D Multiplayer Template\n\n**3D Engine** is the blank-slate 3D multiplayer template on Gipity. It boots a Three.js scene, a Rapier physics world, a Gipity Realtime multiplayer client, and exposes the engine API - with no gameplay. Use it when the user wants to build a fresh 3D app without inheriting a demo's logic.\n\n**When to use this:** When the user describes a new 3D or multiplayer app (e.g. \"3D Boggle\", \"voxel painting\", \"3D chat room\") and you want a clean starting point. If they want a playable reference with rockets, a demo scene, and spawn logic already wired, add the `3d-world` starter instead.\n\n## Quick Start\n\n```\nadd name=3d-engine title=\"<App Name>\"\n```\n\nOut of the box: a controllable character on a lit ground plane, multiplayer connected. No gameplay - that's yours.\n\n**Naming:** Use the user's name verbatim if given. If they didn't specify, blend \"Gip\" or \"Gipity\" into the name.\n\n## Project Structure\n\nAfter adding the template, all files are in `src/` and fully editable. Two-layer split:\n\n**Your code (edit these):**\n- `game.js` - entry point. Calls `setConfig`, `onInit`, `onUpdate`. Start here.\n- `config.js` - project metadata and feature flags.\n- `settings.js` - tunable values (world size, colors). Add your own.\n- `strings.js` - user-facing text. Localized via `translations.js`.\n- `scene.js` - scene builder. Currently creates one ground plane. Replace with your world.\n- `css/game.css` - game-specific styles. Empty stub.\n\n**Engine (leave as-is unless you know what you're doing):**\n- `core.js` - boot sequence and render loop. Re-exports every engine module.\n- `world.js` - Three.js scene, camera, renderer, lighting.\n- `physics.js` - Rapier physics world, raycasting, triggers.\n- `network.js` - thin 3D facade over the `@gipity/realtime` kit (presence + world-state channels).\n- `network/adapter-3d.js` - the only 3D-aware networking code: bridges `@gipity/realtime` to Three.js / Rapier.\n- `packages/realtime/` - the engine-agnostic `@gipity/realtime` kit (transport, channels, host election, sync). Self-describing: see its `README.md`, `contracts/`, and `examples/`. Reusable by any app, not just games.\n- `player.js` - character controller, booted by `game.js` via `player.initPlayer()`. A dynamic rigid body (added mass 2, rotation locked): it knocks over dynamic Parts on contact and never topples.\n- `primitives.js` - Part system and workspace. `PART_DEFAULTS` at the top lists every Part property with its exact default.\n- `constraints.js` - joint/constraint system.\n- `assets.js` - geometry builders and `MATERIAL_PRESETS` (per-material friction/elasticity + visual settings).\n- `shapes.js` - voxel shape library (cubes, spheres, arches, towers).\n- `utils.js` - helpers (randInt, onCircle, placeVoxels).\n- `features.js` - opt-in feature registry. Empty by default.\n- `ui.js` - HUD, loading screen, message overlay.\n- `mobile.js` - touch controls overlay (floating joystick, Jump/Action buttons, camera drag, fullscreen toggle). Auto-shown on touch devices; wired by `player.js`.\n- `css/engine.css` - engine styles.\n\nEach file's top comment explains what it does; read one only when you need a detail this skill doesn't already give you. The full engine API surface - every call, its arguments, and its return shape - is documented below, so you should be able to build without grepping the source.\n\n## Engine API\n\nAll engine modules are available via a single import:\n\n```js\nimport { world, assets, physics, player, network, ui, THREE, onInit, onUpdate, setConfig, primitives, constraints, workspace, features, advance, whenReady } from './core.js';\n```\n\n### Verifying it headlessly\n\n`advance(seconds)` steps the world at a fixed timestep with rendering skipped, and `whenReady()` resolves once boot finishes. Drive them from `gipity page eval` instead of waiting on real time: the headless browser paints at ~2-3 fps and the loop caps its frame delta, so a wall-clock wait advances the simulation ~12x slower than it looks and reports collisions that never happened.\n\n```js\nconst core = await import('./js/core.js');\nawait core.whenReady();\ncore.advance(3);   // 3s of world time, ~110ms real, bit-identical every run\n```\n\nBoth are exported from `core.js`. See the `app-debugging` skill.\n\n### Lifecycle\n\n```js\nsetConfig(config);               // set once at import time\nonInit(async () => { ... });     // runs after engine boots\nonUpdate((dt) => { ... });       // runs every frame\n```\n\nThe template's `game.js` already wires these up - edit its bodies.\n\n### Before you build: two defaults that will surprise you\n\nThe template ships wired for a multiplayer building toy. If you're building something else - especially a solo physics sandbox - decide on each of these before writing your scene:\n\n1. **Multiplayer is ON.** `config.js` ships `features: { multiplayer: {...} }` enabled, which opens a Gipity Realtime connection. Going solo is a **one-line edit**: set `features: {}` in `config.js` and no network client loads. **Leave `gipity.yaml` alone** - its `realtime` phase just registers a room name (no server, no cost, no plan limit), and an unconnected room does nothing. Don't rewrite the manifest to strip it.\n2. **Part `mass` is the body's TOTAL mass in kg.** Leave it unset and a Part weighs its volume at density 1 (a 2x2x2 crate = 8 kg); set it and the body weighs exactly that (`mass: 0.5` really is 0.5 kg). The player is a dynamic body with locked rotation and ~2 added mass (`player.js`), so the mass ratio decides contact physics: light Parts scatter when run into, a wall of heavy Parts barely budges. If the headline interaction is \"run into stuff and scatter it\", set Part `mass` at or below ~1 - or shove via `player.applyKnockback` / an impulse (see Physics below) rather than relying on body-to-body contact alone.\n\n### The player\n\n`game.js` already calls `player.initPlayer()` in `onInit` - WASD/arrows, space to jump, mouse look, scroll zoom, E / left-click for action. Pass options to tune it:\n\n```js\nplayer.initPlayer({\n  speed: 12,\n  jumpForce: 18,\n  color: 0xf26522,\n  camera: { distance: 20, sensitivity: 0.003, invertY: false },\n});\n```\n\n**Camera comfort:** the player module bakes in **Invert-Y** and adjustable **sensitivity**, persisted per-browser in `localStorage` (`player.setInvertY`/`getInvertY`, `player.setSensitivity`/`getSensitivity`; players can toggle invert live with the **I** key). For any first-person app, wire a **visible** toggle button + sensitivity slider to these — bad mouse-look is the top \"controls feel wrong\" complaint, so treat it as a default, not an extra.\n\n**Mobile is automatic:** on touch devices `initPlayer` shows the standard mobile layout via `mobile.js` — floating joystick (left half) for movement, drag-anywhere camera, Jump/Action buttons bottom-right (Action = `inputState.action`, same as the E key), and a fullscreen toggle top-right. Nothing renders on mouse-only desktops. To stay mobile-compatible, read input from `player.inputState` instead of raw key events, and trigger game actions from `inputState.action`/`inputState.jump`.\n\n**Typing into form fields is already shielded:** the template's own key handlers (movement, panel hotkeys) ignore key events targeting an `input`/`textarea`/`select`/contenteditable and clear held keys when a field gains focus, so players can type W/A/S/D/Space into a name-entry or chat box without the avatar moving. If you add your OWN `window`/`document` key listener, keep that property: start it with `if (e.target.closest('input, textarea, select') || e.target.isContentEditable) return;` (note `freezePlayer()` does NOT do this for you - it freezes movement integration, not key handling).\n\n**`initPlayer` config** (all optional): `speed`, `jumpForce`, `color`, `crosshair` (bool), `x`/`y`/`z` spawn (a `createSpawnPoint`, if one exists, wins over these), and a `camera` object: `{ distance, minDistance, maxDistance, heightOffset, sensitivity, invertY, scrollSpeed, mode, topDownHeight }`.\n\n**Player runtime API:**\n\n```js\nplayer.getPosition();                     // -> { x, y, z }\nplayer.setPosition(x, y, z);              // teleport\nplayer.getAimDirection();                 // -> normalized { x, y, z } the camera faces\nplayer.getAimOrigin();                    // -> { x, y, z } CAMERA position - in third-person up to ~25 units BEHIND the player\nplayer.aimRaycast(reach);                 // -> { point, distance, collider, body, part } under the crosshair within reach OF THE PLAYER, or null. part is null for ground/scenery\nplayer.grab(part);                        // carry a Part in front of the crosshair; false for anchored/scenery. opts: { distance }\nplayer.release({ throwSpeed: 40 });       // drop (omit throwSpeed) or throw; -> the released Part or null\nplayer.getHeldPart();                     // -> the carried Part or null\nplayer.inputState;                        // live { forward, right, jump, action, mouseDown, sprint }\nplayer.velocity;                          // live velocity vector\nplayer.isGrounded();                      // bool\nplayer.applyKnockback(dir, strength);     // shove the player: direction { x, y, z } + strength scalar (capped at 40)\nplayer.cameraControl.mode = 'firstPerson';// 'orbit' (default) | 'firstPerson' | 'topDown' | 'fixed'\nplayer.cameraControl.setFixedPosition({ x, y, z });  // for mode 'fixed'\nplayer.cameraControl.setFixedLookAt({ x, y, z });\nplayer.freezePlayer(); player.unfreezePlayer(); player.isFrozen();\nplayer.setPlayerColor(0xff0000); player.getPlayerColor();\n```\n\n### Building a scene: Parts\n\n`primitives.createPart(...)` is the workhorse - the universal 3D primitive (3D World's equivalent of a Roblox BasePart). **Parts are dynamic (gravity-on) by default.** Set `anchored: true` to fix one in place (floors, walls, static structures). `createPart` returns the Part object; its internal Rapier body is on `part._body` (needed for the impulse calls below).\n\n```js\nimport { primitives } from './core.js';\n\n// Dynamic block - falls, collides, can be knocked around\nconst crate = primitives.createPart({\n  position: { x: 0, y: 5, z: 0 },\n  size: { x: 3, y: 3, z: 3 },\n  color: 0x2196F3,\n  material: 'wood',\n});\n\n// Anchored floor - no gravity, immovable\nprimitives.createPart({ position: { x: 0, y: 0, z: 0 }, size: { x: 40, y: 1, z: 40 }, anchored: true, material: 'concrete' });\n\n// Sub-voxel shape (each Part is a 3x3x3 sub-voxel grid)\nprimitives.createPart({ position: { x: 3, y: 1, z: 0 }, shape: primitives.SHAPES.STAIR, color: 0x4CAF50 });\n```\n\n**Part properties** (pass any subset to `createPart`; defaults from `PART_DEFAULTS` in `primitives.js`):\n\n| Property | Default | Notes |\n|----------|---------|-------|\n| `position` | `{0,0,0}` | world position |\n| `rotation` | `{0,0,0,1}` | quaternion |\n| `size` | `{1,1,1}` | world units |\n| `anchored` | `false` | `true` = static, no gravity |\n| `canCollide` | `true` | |\n| `mass` | auto | TOTAL kg; unset = volume at density 1 - see the mass note above |\n| `friction` | `0.5` | overridden by `material` preset |\n| `elasticity` | `0.3` | overridden by `material` preset |\n| `linearDamping` | `0.1` | |\n| `angularDamping` | `0.5` | |\n| `color` | `0x888888` | |\n| `material` | `'plastic'` | sets friction+elasticity+look |\n| `transparency` | `0.0` | 0 opaque … 1 invisible |\n| `shape` | `SHAPES.FULL` | sub-voxel shape mask |\n| `castShadow` / `receiveShadow` | `true` | |\n\n**Materials** (`material:` name, from `MATERIAL_PRESETS` in `assets.js`; each sets friction + elasticity + visual style):\n\n| Material | friction | elasticity | Feel |\n|----------|----------|-----------|------|\n| `plastic` (default) | 0.5 | 0.3 | neutral |\n| `metal` | 0.4 | 0.2 | shiny, heavy-looking |\n| `wood` | 0.6 | 0.2 | matte |\n| `glass` | 0.2 | 0.1 | translucent |\n| `neon` | 0.3 | 0.2 | emissive |\n| `ice` | 0.05 | 0.1 | slippery |\n| `grass` | 0.7 | 0.1 | high-grip |\n| `sand` | 0.8 | 0.05 | high-grip, dead bounce |\n| `concrete` | 0.8 | 0.1 | high-grip |\n\nAn unknown material name warns in the console and falls back to `plastic`.\n\n**Sub-voxel shapes** (`primitives.SHAPES`): `FULL`, `SLAB`, `HALF`, `STAIR`, `SLOPE`, `CORNER`, `PILLAR`, `ARCH`.\n\n**Runtime changes, queries, spawn points:**\n\n```js\nprimitives.setProperty(crate, 'anchored', true);   // freeze in place\nprimitives.setProperty(crate, 'material', 'ice');  // updates physics + visual\nprimitives.setProperty(crate, 'color', 0xff0000);\n\nprimitives.queryParts({ color: 0xff0000, anchored: false }); // -> matching Parts\nprimitives.getPart(id); primitives.getParts();               // lookup / all\nprimitives.removePart(crate);\n\nprimitives.createSpawnPoint({ position: { x: 0, y: 2, z: 0 }, teamColor: 0xff0000 });\n```\n\n**Compound blocks** - a grid of welded sub-Parts that shatter on impact (destructible crates, breakable walls):\n\n```js\nconst block = primitives.createCompoundBlock({\n  position: { x: 5, y: 1.5, z: 0 },\n  color: 0xff0000,\n  breakForce: 8,        // velocity-delta threshold; higher = harder to break\n  gridSize: 3,          // sub-blocks per axis (3 -> 27)\n  blockSize: 1, material: 'wood', colorVariation: true,\n});\nblock.break(block.parts[0]);  // free one sub-block\nblock.breakAll();             // shatter everything\nblock.isIntact();             // any welds left?\n```\n\nFor quick voxel structures there's also a shapes library:\n\n```js\nimport { placeVoxels } from './utils.js';\nimport { solidCube, voxelSphere, arch } from './shapes.js';\n\nplaceVoxels(arch(5, 6), { x: 0, y: 0, z: 10 }, 0x9C27B0);\n```\n\n### Assets: geometry, models, sounds\n\n```js\nassets.createVoxelGround(width, depth, color);  // instanced ground plane - ONE draw call, use it for terrain\nassets.createVoxelBox(color, size);             // simple colored cube\nassets.spawn(name, { x, y, z }, scale);         // load a model and add it to the scene\nassets.despawn(model); assets.loadModel(name);  // remove / load (returns a clone)\nassets.playSound(name, { volume });             // sound effect\nassets.getTexture(name);                        // texture\n```\n\nModels, sounds, and textures load by name from the shared CDN. For anything the pack doesn't have, build geometry with `primitives.createPart` or THREE.js directly.\n\n### Physics: moving, ray-testing, and detecting\n\nImport `physics` from `./core.js`. The impulse calls take a Rapier **body** - use `part._body` from a Part, or `player`-side helpers.\n\n```js\n// Push a body. applyImpulseAtPoint adds torque from the offset, so edge hits spin.\nphysics.applyImpulse(crate._body, { x: 0, y: 0, z: 40 });                     // pure shove, no spin\nphysics.applyImpulseAtPoint(crate._body, { x: 0, y: 2, z: 40 }, hitPoint);    // shove + realistic spin\n\n// Raycast. Returns { point, distance, collider, body, part } or null; hit.part is\n// the Part that was hit (null for ground/scenery), already resolved for you.\n// 4th arg excludes a body (pass the player's own so a shot doesn't hit the\n// shooter). For a reach-limited crosshair ray use player.aimRaycast(reach).\nconst hit = physics.castRay(player.getAimOrigin(), player.getAimDirection(), 100, /* excludeBody */ null);\nif (hit?.part) primitives.removePart(hit.part);\n\n// Broadphase proximity query. Returns [{ collider, body }] within radius (explosions, triggers).\nconst nearby = physics.queryNearby({ x: 0, y: 0, z: 0 }, 5);\nfor (const { body } of nearby) physics.applyImpulse(body, { x: 0, y: 10, z: 0 });\n```\n\n**Carry / stack / throw** - `grab`/`release` are the whole loop; the engine pins the held Part in front of the crosshair each frame:\n\n```js\n// on action (player.inputState.action / mouseDown): toggle carry, else place a block\nif (player.getHeldPart()) player.release({ throwSpeed: 40 });\nelse {\n  const hit = player.aimRaycast(10);\n  if (hit && !player.grab(hit.part))   // grab() is false for the ground / anchored scenery\n    primitives.createPart({ position: hit.point, size: { x: 3, y: 3, z: 3 } });\n}\n```\n\nLower-level colliders (most apps use Parts instead, but these exist): `physics.addStaticBox(pos, halfExtents)`, `physics.addDynamicBox(pos, halfExtents, mass)`, `physics.addKinematicBody(pos, halfExtents)`, `physics.addTrigger(pos, halfExtents, { onEnter, onExit })` (sensor zone), `physics.removeBody(body)`, and the fully-optioned `physics.addBody(pos, halfExtents, { type, rotation, mass, friction, restitution, linearDamping, angularDamping, isSensor, convexHullPoints })`.\n\n### Constraints: joints between Parts\n\nImport `constraints` from `./core.js`:\n\n```js\nconstraints.weld(partA, partB);                                  // rigid lock\nconstraints.hinge(frame, door, { axis: { x:0,y:1,z:0 }, limits: [-90, 90] }); // one-axis rotation\nconstraints.spring(partA, partB, { stiffness: 100, damping: 10 });           // elastic\nconstraints.getAll(part); constraints.remove(c); constraints.removeAll(part);\n```\n\n### Workspace: gravity, snap, lighting\n\n```js\nworkspace.gravity = { x: 0, y: -20, z: 0 };  // default { x: 0, y: -40, z: 0 }; live - takes effect immediately and wakes sleeping bodies. Applies to everything dynamic, including the player.\nworkspace.snapEnabled = true;                // opt in to Roblox-style snap-together building: dynamic Parts within snapDistance weld into one rigid body (default false)\nworkspace.snapDistance = 0.15;               // weld radius when snap is on\nworkspace.onSnap((a, b) => { /* two Parts just welded */ });\n```\n\n`workspace.lighting` is a live proxy - assigning any field updates the Three.js lights immediately. The complete set, with defaults:\n\n```js\nworkspace.lighting.timeOfDay = 18;        // 12; 0-24 (0 midnight, 6 sunrise, 12 noon, 18 sunset) - drives sun angle + color\nworkspace.lighting.sunIntensity = 2.0;    // 2.0\nworkspace.lighting.sunColor = 0xfff5e6;   // 0xfff5e6\nworkspace.lighting.ambientIntensity = 0.55;      // 0.55 - raise to lift shadow detail\nworkspace.lighting.hemisphereIntensity = 0.85;   // 0.85 - sky/ground bounce\nworkspace.lighting.skyColor = 0x87ceeb;   // also sets background + fog color\nworkspace.lighting.groundColor = 0x556b2f;\nworkspace.lighting.fogEnabled = true;     // true\nworkspace.lighting.fogNear = 40; workspace.lighting.fogFar = 120;\n```\n\n### UI: HUD, panels, messages\n\nImport `ui` from `./core.js`:\n\n```js\nui.setHud('top-left', '<b>Score: 0</b>');    // slots: top-left|top-right|bottom-left|bottom-right|center\nui.clearHud('top-left');\nui.showMessage('Nice tower!', 3000);         // transient centered toast\n// A self-placing stats panel with labeled rows (auto-appends + positions itself; optional toggle key):\nconst panel = new ui.InfoPanel({ title: 'Stats', position: 'top-right', toggleKey: 'KeyP' });\npanel.addRow('Blocks', '0');\npanel.setRow('Blocks', String(count));       // update a row in place\npanel.removeRow('Blocks'); panel.destroy();\n```\n\n### Multiplayer\n\nMultiplayer runs on the engine-agnostic `@gipity/realtime` kit (`packages/realtime/`); `network.js` is a thin 3D facade over it. The `multiplayer` feature connects to the Gipity Realtime room named in `config.js`, broadcasts the local player on the `avatars` presence channel, and renders remote peers. The room is declared in the template's `gipity.yaml` (a `realtime` deploy phase), so `gipity deploy` provisions it automatically. For game events, open a channel: `network.channel('events', { sync: 'messages' })` → `.send(type, data)` / `.on(type, cb)`. For host-authoritative shared world state, enable `sync.worldState`. Multiplayer patterns in depth - custom event channels, remote-player joins/leaves, host-authoritative world state: the `3d-world` skill.\n\n### Features\n\n`features.js` ships empty. To add a feature module:\n\n1. Create `js/features/my-feature.js` exporting `DEFAULTS` and `create({...settings}, deps)`.\n2. Register it in `featureLoaders` in `features.js`.\n3. Enable it in `config.js`: `features: { 'my-feature': true }`.\n\nEach feature receives the full engine deps: `world, scene, camera, renderer, physics, player, network, ui, assets, primitives, constraints, THREE`.\n\n## Reference: the 3d-world app\n\nThe `3d-world` app uses this same engine, plus:\n- A demo scene with voxel structures in two rings\n- A `rocket-launcher` feature with projectile + explosion + audio\n- Block-collision tick sounds\n\nAdd one in a throwaway project if you want to see a fully-wired implementation:\n\n```\nadd name=3d-world title=\"Rocket Demo\"\n```\n\n## Deploy\n\nSame flow as every Gipity app:\n\n```\ngipity deploy dev\n```\n\n## Deploy Verification\n\nVerify a deploy when it matters - the first deploy, structural changes (new pages, new frameworks, changed imports), or anything that might have broken. Skip it for trivial changes (copy tweaks, style values).\n\n`gipity deploy dev --inspect` deploys and reports the live page in one step: console errors, failed resources, timing, layout overflow. A clean console is necessary but NOT sufficient for Canvas/WebGL - also capture `gipity page screenshot <url>` and look at it, because render failures are silent. A blank page, black canvas, or wrong-looking UI with a clean console is a real failure, not a pass.\n\nFull loop - reading function logs, calling a function directly, driving the page: the `app-debugging` skill.\n"
}
