# 3D Engine - Minimal 3D Multiplayer Template

**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.

**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.

## Quick Start

```
add name=3d-engine title="<App Name>"
```

Out of the box: a controllable character on a lit ground plane, multiplayer connected. No gameplay - that's yours.

**Naming:** Use the user's name verbatim if given. If they didn't specify, blend "Gip" or "Gipity" into the name.

## Project Structure

After adding the template, all files are in `src/` and fully editable. Two-layer split:

**Your code (edit these):**
- `game.js` - entry point. Calls `setConfig`, `onInit`, `onUpdate`. Start here.
- `config.js` - project metadata and feature flags.
- `settings.js` - tunable values (world size, colors). Add your own.
- `strings.js` - user-facing text. Localized via `translations.js`.
- `scene.js` - scene builder. Currently creates one ground plane. Replace with your world.
- `css/game.css` - game-specific styles. Empty stub.

**Engine (leave as-is unless you know what you're doing):**
- `core.js` - boot sequence and render loop. Re-exports every engine module.
- `world.js` - Three.js scene, camera, renderer, lighting.
- `physics.js` - Rapier physics world, raycasting, triggers.
- `network.js` - thin 3D facade over the `@gipity/realtime` kit (presence + world-state channels).
- `network/adapter-3d.js` - the only 3D-aware networking code: bridges `@gipity/realtime` to Three.js / Rapier.
- `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.
- `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.
- `primitives.js` - Part system and workspace. `PART_DEFAULTS` at the top lists every Part property with its exact default.
- `constraints.js` - joint/constraint system.
- `assets.js` - geometry builders and `MATERIAL_PRESETS` (per-material friction/elasticity + visual settings).
- `shapes.js` - voxel shape library (cubes, spheres, arches, towers).
- `utils.js` - helpers (randInt, onCircle, placeVoxels).
- `features.js` - opt-in feature registry. Empty by default.
- `ui.js` - HUD, loading screen, message overlay.
- `mobile.js` - touch controls overlay (floating joystick, Jump/Action buttons, camera drag, fullscreen toggle). Auto-shown on touch devices; wired by `player.js`.
- `css/engine.css` - engine styles.

Each 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.

## Engine API

All engine modules are available via a single import:

```js
import { world, assets, physics, player, network, ui, THREE, onInit, onUpdate, setConfig, primitives, constraints, workspace, features, advance, whenReady } from './core.js';
```

### Verifying it headlessly

`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.

```js
const core = await import('./js/core.js');
await core.whenReady();
core.advance(3);   // 3s of world time, ~110ms real, bit-identical every run
```

Both are exported from `core.js`. See the `app-debugging` skill.

### Lifecycle

```js
setConfig(config);               // set once at import time
onInit(async () => { ... });     // runs after engine boots
onUpdate((dt) => { ... });       // runs every frame
```

The template's `game.js` already wires these up - edit its bodies.

### Before you build: two defaults that will surprise you

The 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:

1. **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.
2. **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.

### The player

`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:

```js
player.initPlayer({
  speed: 12,
  jumpForce: 18,
  color: 0xf26522,
  camera: { distance: 20, sensitivity: 0.003, invertY: false },
});
```

**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.

**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`.

**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).

**`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 }`.

**Player runtime API:**

```js
player.getPosition();                     // -> { x, y, z }
player.setPosition(x, y, z);              // teleport
player.getAimDirection();                 // -> normalized { x, y, z } the camera faces
player.getAimOrigin();                    // -> { x, y, z } CAMERA position - in third-person up to ~25 units BEHIND the player
player.aimRaycast(reach);                 // -> { point, distance, collider, body, part } under the crosshair within reach OF THE PLAYER, or null. part is null for ground/scenery
player.grab(part);                        // carry a Part in front of the crosshair; false for anchored/scenery. opts: { distance }
player.release({ throwSpeed: 40 });       // drop (omit throwSpeed) or throw; -> the released Part or null
player.getHeldPart();                     // -> the carried Part or null
player.inputState;                        // live { forward, right, jump, action, mouseDown, sprint }
player.velocity;                          // live velocity vector
player.isGrounded();                      // bool
player.applyKnockback(dir, strength);     // shove the player: direction { x, y, z } + strength scalar (capped at 40)
player.cameraControl.mode = 'firstPerson';// 'orbit' (default) | 'firstPerson' | 'topDown' | 'fixed'
player.cameraControl.setFixedPosition({ x, y, z });  // for mode 'fixed'
player.cameraControl.setFixedLookAt({ x, y, z });
player.freezePlayer(); player.unfreezePlayer(); player.isFrozen();
player.setPlayerColor(0xff0000); player.getPlayerColor();
```

### Building a scene: Parts

`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).

```js
import { primitives } from './core.js';

// Dynamic block - falls, collides, can be knocked around
const crate = primitives.createPart({
  position: { x: 0, y: 5, z: 0 },
  size: { x: 3, y: 3, z: 3 },
  color: 0x2196F3,
  material: 'wood',
});

// Anchored floor - no gravity, immovable
primitives.createPart({ position: { x: 0, y: 0, z: 0 }, size: { x: 40, y: 1, z: 40 }, anchored: true, material: 'concrete' });

// Sub-voxel shape (each Part is a 3x3x3 sub-voxel grid)
primitives.createPart({ position: { x: 3, y: 1, z: 0 }, shape: primitives.SHAPES.STAIR, color: 0x4CAF50 });
```

**Part properties** (pass any subset to `createPart`; defaults from `PART_DEFAULTS` in `primitives.js`):

| Property | Default | Notes |
|----------|---------|-------|
| `position` | `{0,0,0}` | world position |
| `rotation` | `{0,0,0,1}` | quaternion |
| `size` | `{1,1,1}` | world units |
| `anchored` | `false` | `true` = static, no gravity |
| `canCollide` | `true` | |
| `mass` | auto | TOTAL kg; unset = volume at density 1 - see the mass note above |
| `friction` | `0.5` | overridden by `material` preset |
| `elasticity` | `0.3` | overridden by `material` preset |
| `linearDamping` | `0.1` | |
| `angularDamping` | `0.5` | |
| `color` | `0x888888` | |
| `material` | `'plastic'` | sets friction+elasticity+look |
| `transparency` | `0.0` | 0 opaque … 1 invisible |
| `shape` | `SHAPES.FULL` | sub-voxel shape mask |
| `castShadow` / `receiveShadow` | `true` | |

**Materials** (`material:` name, from `MATERIAL_PRESETS` in `assets.js`; each sets friction + elasticity + visual style):

| Material | friction | elasticity | Feel |
|----------|----------|-----------|------|
| `plastic` (default) | 0.5 | 0.3 | neutral |
| `metal` | 0.4 | 0.2 | shiny, heavy-looking |
| `wood` | 0.6 | 0.2 | matte |
| `glass` | 0.2 | 0.1 | translucent |
| `neon` | 0.3 | 0.2 | emissive |
| `ice` | 0.05 | 0.1 | slippery |
| `grass` | 0.7 | 0.1 | high-grip |
| `sand` | 0.8 | 0.05 | high-grip, dead bounce |
| `concrete` | 0.8 | 0.1 | high-grip |

An unknown material name warns in the console and falls back to `plastic`.

**Sub-voxel shapes** (`primitives.SHAPES`): `FULL`, `SLAB`, `HALF`, `STAIR`, `SLOPE`, `CORNER`, `PILLAR`, `ARCH`.

**Runtime changes, queries, spawn points:**

```js
primitives.setProperty(crate, 'anchored', true);   // freeze in place
primitives.setProperty(crate, 'material', 'ice');  // updates physics + visual
primitives.setProperty(crate, 'color', 0xff0000);

primitives.queryParts({ color: 0xff0000, anchored: false }); // -> matching Parts
primitives.getPart(id); primitives.getParts();               // lookup / all
primitives.removePart(crate);

primitives.createSpawnPoint({ position: { x: 0, y: 2, z: 0 }, teamColor: 0xff0000 });
```

**Compound blocks** - a grid of welded sub-Parts that shatter on impact (destructible crates, breakable walls):

```js
const block = primitives.createCompoundBlock({
  position: { x: 5, y: 1.5, z: 0 },
  color: 0xff0000,
  breakForce: 8,        // velocity-delta threshold; higher = harder to break
  gridSize: 3,          // sub-blocks per axis (3 -> 27)
  blockSize: 1, material: 'wood', colorVariation: true,
});
block.break(block.parts[0]);  // free one sub-block
block.breakAll();             // shatter everything
block.isIntact();             // any welds left?
```

For quick voxel structures there's also a shapes library:

```js
import { placeVoxels } from './utils.js';
import { solidCube, voxelSphere, arch } from './shapes.js';

placeVoxels(arch(5, 6), { x: 0, y: 0, z: 10 }, 0x9C27B0);
```

### Assets: geometry, models, sounds

```js
assets.createVoxelGround(width, depth, color);  // instanced ground plane - ONE draw call, use it for terrain
assets.createVoxelBox(color, size);             // simple colored cube
assets.spawn(name, { x, y, z }, scale);         // load a model and add it to the scene
assets.despawn(model); assets.loadModel(name);  // remove / load (returns a clone)
assets.playSound(name, { volume });             // sound effect
assets.getTexture(name);                        // texture
```

Models, 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.

### Physics: moving, ray-testing, and detecting

Import `physics` from `./core.js`. The impulse calls take a Rapier **body** - use `part._body` from a Part, or `player`-side helpers.

```js
// Push a body. applyImpulseAtPoint adds torque from the offset, so edge hits spin.
physics.applyImpulse(crate._body, { x: 0, y: 0, z: 40 });                     // pure shove, no spin
physics.applyImpulseAtPoint(crate._body, { x: 0, y: 2, z: 40 }, hitPoint);    // shove + realistic spin

// Raycast. Returns { point, distance, collider, body, part } or null; hit.part is
// the Part that was hit (null for ground/scenery), already resolved for you.
// 4th arg excludes a body (pass the player's own so a shot doesn't hit the
// shooter). For a reach-limited crosshair ray use player.aimRaycast(reach).
const hit = physics.castRay(player.getAimOrigin(), player.getAimDirection(), 100, /* excludeBody */ null);
if (hit?.part) primitives.removePart(hit.part);

// Broadphase proximity query. Returns [{ collider, body }] within radius (explosions, triggers).
const nearby = physics.queryNearby({ x: 0, y: 0, z: 0 }, 5);
for (const { body } of nearby) physics.applyImpulse(body, { x: 0, y: 10, z: 0 });
```

**Carry / stack / throw** - `grab`/`release` are the whole loop; the engine pins the held Part in front of the crosshair each frame:

```js
// on action (player.inputState.action / mouseDown): toggle carry, else place a block
if (player.getHeldPart()) player.release({ throwSpeed: 40 });
else {
  const hit = player.aimRaycast(10);
  if (hit && !player.grab(hit.part))   // grab() is false for the ground / anchored scenery
    primitives.createPart({ position: hit.point, size: { x: 3, y: 3, z: 3 } });
}
```

Lower-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 })`.

### Constraints: joints between Parts

Import `constraints` from `./core.js`:

```js
constraints.weld(partA, partB);                                  // rigid lock
constraints.hinge(frame, door, { axis: { x:0,y:1,z:0 }, limits: [-90, 90] }); // one-axis rotation
constraints.spring(partA, partB, { stiffness: 100, damping: 10 });           // elastic
constraints.getAll(part); constraints.remove(c); constraints.removeAll(part);
```

### Workspace: gravity, snap, lighting

```js
workspace.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.
workspace.snapEnabled = true;                // opt in to Roblox-style snap-together building: dynamic Parts within snapDistance weld into one rigid body (default false)
workspace.snapDistance = 0.15;               // weld radius when snap is on
workspace.onSnap((a, b) => { /* two Parts just welded */ });
```

`workspace.lighting` is a live proxy - assigning any field updates the Three.js lights immediately. The complete set, with defaults:

```js
workspace.lighting.timeOfDay = 18;        // 12; 0-24 (0 midnight, 6 sunrise, 12 noon, 18 sunset) - drives sun angle + color
workspace.lighting.sunIntensity = 2.0;    // 2.0
workspace.lighting.sunColor = 0xfff5e6;   // 0xfff5e6
workspace.lighting.ambientIntensity = 0.55;      // 0.55 - raise to lift shadow detail
workspace.lighting.hemisphereIntensity = 0.85;   // 0.85 - sky/ground bounce
workspace.lighting.skyColor = 0x87ceeb;   // also sets background + fog color
workspace.lighting.groundColor = 0x556b2f;
workspace.lighting.fogEnabled = true;     // true
workspace.lighting.fogNear = 40; workspace.lighting.fogFar = 120;
```

### UI: HUD, panels, messages

Import `ui` from `./core.js`:

```js
ui.setHud('top-left', '<b>Score: 0</b>');    // slots: top-left|top-right|bottom-left|bottom-right|center
ui.clearHud('top-left');
ui.showMessage('Nice tower!', 3000);         // transient centered toast
// A self-placing stats panel with labeled rows (auto-appends + positions itself; optional toggle key):
const panel = new ui.InfoPanel({ title: 'Stats', position: 'top-right', toggleKey: 'KeyP' });
panel.addRow('Blocks', '0');
panel.setRow('Blocks', String(count));       // update a row in place
panel.removeRow('Blocks'); panel.destroy();
```

### Multiplayer

Multiplayer 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.

### Features

`features.js` ships empty. To add a feature module:

1. Create `js/features/my-feature.js` exporting `DEFAULTS` and `create({...settings}, deps)`.
2. Register it in `featureLoaders` in `features.js`.
3. Enable it in `config.js`: `features: { 'my-feature': true }`.

Each feature receives the full engine deps: `world, scene, camera, renderer, physics, player, network, ui, assets, primitives, constraints, THREE`.

## Reference: the 3d-world app

The `3d-world` app uses this same engine, plus:
- A demo scene with voxel structures in two rings
- A `rocket-launcher` feature with projectile + explosion + audio
- Block-collision tick sounds

Add one in a throwaway project if you want to see a fully-wired implementation:

```
add name=3d-world title="Rocket Demo"
```

## Deploy

Same flow as every Gipity app:

```
gipity deploy dev
```

## Deploy Verification

Verify 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).

`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.

Full loop - reading function logs, calling a function directly, driving the page: the `app-debugging` skill.

