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. 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- character controller, booted bygame.jsviaplayer.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_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: 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:
- Multiplayer is ON.
config.jsshipsfeatures: { multiplayer: {...} }enabled, which opens a Gipity Realtime connection. Going solo is a one-line edit: setfeatures: {}inconfig.jsand no network client loads. Leavegipity.yamlalone - itsrealtimephase 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. - 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.
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:
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:
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).
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, 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:
// 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:
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: -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:
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:
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 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.