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, camera orbit, and spawn logic already wired, add the 3d-world starter instead.
Quick Start
add name=3d-engine title="<App Name>"
The player sees a lit ground plane with multiplayer connected. There's no character yet - you decide the control model.
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. CallssetConfig,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 viatranslations.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/realtimekit (presence + world-state channels).network/adapter-3d.js- the only 3D-aware networking code: bridges@gipity/realtimeto Three.js / Rapier.packages/realtime/- the engine-agnostic@gipity/realtimekit (transport, channels, host election, sync). Self-describing: see itsREADME.md,contracts/, andexamples/. Reusable by any app, not just games.player.js- optional character controller (callplayer.initPlayer(...)when you want one). 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_DEFAULTSat the top lists every Part property with its exact default.constraints.js- joint/constraint system.assets.js- geometry builders andMATERIAL_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 byplayer.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:
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.
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
setConfig(config); // set once at import time
onInit(async () => { ... }); // runs after engine boots
onUpdate((dt) => { ... }); // runs every frame
The starter game.js already wires these up - edit its bodies.
Before you build: three 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 - three defaults actively fight you until you change them. Decide on each before writing your scene:
- Auto-weld snap is ON.
workspace.snapEnableddefaults totrue(primitives.js), so any two dynamic Parts that come withinworkspace.snapDistance(0.15 units) get welded into one rigid body every few frames. A tower you build by stacking Parts will fuse solid and cannot topple. For any "stack it and knock it over" / free-tumbling-physics goal, turn it off:workspace.snapEnabled = false;(or set it inonInit). Leave it on only for Roblox-style snap-together building. - Multiplayer is ON.
config.jsshipsfeatures: { multiplayer: {...} }enabled, which opens a Gipity Realtime connection and adds arealtimedeploy phase. For a solo app that's dead weight - remove themultiplayerentry fromconfig.jsfeaturesand drop therealtimephase fromgipity.yaml. - Part
massis 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.5really 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 Partmassat or below ~1 - or shove viaplayer.applyKnockback/ an impulse (see Physics below) rather than relying on body-to-body contact alone.
Adding a player
The template ships without a character. To add one, import player and call initPlayer in onInit:
import { player } from './core.js';
onInit(async () => {
player.initPlayer({
speed: 12,
jumpForce: 18,
gravity: -40,
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.
initPlayer config (all optional): speed, jumpForce, gravity, 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 (call after initPlayer):
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); // physics hit under the crosshair within reach OF THE PLAYER, or null - use for place/pick-block interactions
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).
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:
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):
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:
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
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.
// 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 } or null. 4th arg excludes a body
// (pass the player's own body 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) { /* hit.point, hit.distance, hit.collider.parent() -> the body */ }
// 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 });
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:
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
workspace.gravity = { x: 0, y: -30, z: 0 }; // default y: -20
workspace.snapEnabled = false; // OFF for free-tumbling physics (default true - see warning above)
workspace.snapDistance = 0.15; // weld radius when snap is on
workspace.lighting.timeOfDay = 18; // 0-24; 18 = sunset
workspace.lighting.fogEnabled = true; workspace.lighting.fogNear = 40;
workspace.onSnap((a, b) => { /* two Parts just welded */ });
UI: HUD, panels, messages
Import ui from ./core.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:
- Create
js/features/my-feature.jsexportingDEFAULTSandcreate({...settings}, deps). - Register it in
featureLoadersinfeatures.js. - 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 starter
The 3d-world starter uses this same engine, plus:
- A player controller wired with orbit + aim camera
- A demo scene with voxel structures in two rings
- A
rocket-launcherfeature 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:
project_deploy target=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.