| name | spoint |
| description | Build multiplayer physics games with the Spoint engine. Use when asked to: create a game, add physics objects, spawn entities, build a map/level, handle player interaction, add weapons, respawn, scoring, create moving platforms, manage world config, load 3D models, add HUD/UI, work with the EventBus, or develop any app inside an apps/ directory. |
Spoint App Development Reference
Setup:
npx spoint scaffold
npx spoint
npx spoint-create-app my-app
npx spoint-create-app --template physics my-crate
Project structure: apps/world/index.js (world config) + apps/<name>/index.js (apps). Engine is from npm — never in user project.
App design principle: apps are config, engine is code.
- No
client block unless your app renders custom UI (ui: field in render return). The snapshot carries position/rotation/custom automatically.
- No
onEditorUpdate — the engine applies position/rotation/scale/custom changes from the editor automatically.
- Use
ctx.physics.addColliderFromConfig(cfg) instead of separate setStatic/setDynamic + addBoxCollider calls.
- Use
ctx.world.spawnChild(id, cfg) instead of spawn + manual teardown loop — children are auto-destroyed.
- Keep helper functions outside the
export default {} block (hoisted by evaluateAppModule).
Quick Start — Minimal Working Arena
apps/world/index.js
export default {
port: 3001, tickRate: 64, gravity: [0, -9.81, 0],
movement: { maxSpeed: 4.0, groundAccel: 10.0, airAccel: 1.0, friction: 6.0, stopSpeed: 2.0, jumpImpulse: 4.0 },
player: { health: 100, capsuleRadius: 0.4, capsuleHalfHeight: 0.9, modelScale: 1.323, feetOffset: 0.212 },
scene: { skyColor: 0x87ceeb, fogColor: 0x87ceeb, fogNear: 80, fogFar: 200, sunIntensity: 1.5, sunPosition: [20, 40, 20] },
terrain: { seed: 0, octaves: 10, frequency: 0.07, amplitude: 0.5, renderDistance: 4, chunkSize: 10, resolution: 96 },
entities: [{ id: 'map', model: './apps/game/map.glb', position: [0,0,0], app: 'placed-model', config: { collider: 'trimesh' }, custom: { _interior: true } }],
spawnPoint: [0, 2, 0]
}
apps/placed-model — drop in a map/interior model with a real collider (static meshes collide by default)
A dropped-in map or interior model needs no dedicated app: use the built-in placed-model app with
config.collider (box/sphere/capsule/convex/trimesh/none, trimesh-with-box-fallback baked from the GLB)
and custom._interior: true for DoubleSide interior-face rendering + always-relevant snapshot inclusion
(the map/arena is always visible to every player in it, not distance-culled like a normal prop).
apps/box-static/index.js — reusable static box primitive
export default {
server: {
setup(ctx) {
const c = ctx.config
const hx = c.hx ?? 1, hy = c.hy ?? 1, hz = c.hz ?? 1
ctx.entity.custom = { mesh:'box', color:c.color??0x888888, roughness:c.roughness??0.9, sx:hx*2, sy:hy*2, sz:hz*2 }
ctx.physics.addColliderFromConfig({ type:'box', size:[hx,hy,hz] })
}
}
}
World Config Schema
All fields optional. apps/world/index.js exports a plain object.
export default {
port: 3001, tickRate: 64, gravity: [0,-9.81,0],
entityTickRate: 16,
physicsRadius: 0,
movement: {
maxSpeed: 4.0,
groundAccel: 10.0, airAccel: 1.0, friction: 6.0, stopSpeed: 2.0,
jumpImpulse: 4.0,
crouchSpeedMul: 0.4, sprintSpeed: null
},
player: {
health: 100, capsuleRadius: 0.4, capsuleHalfHeight: 0.9, crouchHalfHeight: 0.45,
mass: 120, modelScale: 1.323,
feetOffset: 0.212
},
scene: {
skyColor: 0x87ceeb, fogColor: 0x87ceeb, fogNear: 80, fogFar: 200,
ambientColor: 0xfff4d6, ambientIntensity: 0.3,
sunColor: 0xffffff, sunIntensity: 1.5, sunPosition: [21,50,20],
fillColor: 0x4488ff, fillIntensity: 0.4, fillPosition: [-20,30,-10],
shadowMapSize: 1024, shadowBias: 0.0038, shadowNormalBias: 0.6, shadowRadius: 12, shadowBlurSamples: 8
},
camera: {
fov: 70, shoulderOffset: 0.35, headHeight: 0.4,
zoomStages: [0,1.5,3,5,8], defaultZoomIndex: 2,
followSpeed: 12.0, snapSpeed: 30.0, mouseSensitivity: 0.002, pitchRange: [-1.4,1.4]
},
animation: { mixerTimeScale: 1.3, walkTimeScale: 2.0, sprintTimeScale: 0.56, fadeTime: 0.15 },
entities: [{ id:'env', model:'./apps/my-app/env.glb', position:[0,0,0], app:'placed-model', config:{collider:'trimesh'}, custom:{_interior:true} }],
playerModel: './apps/tps-game/Cleetus.vrm',
spawnPoint: [0,2,0],
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'turn:your-turn-server.example.com:3478', username: 'user', credential: 'pass' }
]
}
Asset Loading Gate
Loading screen holds until ALL pass simultaneously:
- WebSocket connected
- Player VRM downloaded
- Any entity with
model field (or entity creating a mesh) loaded
- First snapshot received
- All
world.entities entries with model field loaded
Entities spawned via ctx.world.spawn() at runtime are NOT in the gate. Declare in world.entities to block loading screen on a model.
Remote Models — Verified Filenames
URL: https://raw.githubusercontent.com/anEntrypoint/assets/main/FILENAME.glb
Never guess filenames — wrong URLs silently 404, no error.
broken_car_b6d2e66d_v1.glb broken_car_b6d2e66d_v2.glb crashed_car_f2b577ae_v1.glb
crashed_pickup_truck_ae555020_v1.glb crashed_rusty_minivan_f872ff37_v1.glb Bus_junk_1.glb
blue_shipping_container_60b5ea93_v1.glb blue_shipping_container_63cc3905_v1.glb
dumpster_b076662a_v1.glb dumpster_b076662a_v2.glb garbage_can_6b3d052b_v1.glb
crushed_oil_barrel_e450f43f_v1.glb fire_hydrant_ba0175c1_v1.glb
fire_extinguisher_wall_mounted_bc0dddd4_v1.glb
break_room_chair_14a39c7b_v1.glb break_room_couch_444abf63_v1.glb
break_room_table_09b9fd0d_v1.glb filing_cabinet_0194476c_v1.glb
fancy_reception_desk_58fde71d_v1.glb cash_register_0c0dcad2_v1.glb
espresso_machine_e722ed8c_v1.glb Couch.glb Couch_2.glb 3chairs.glb
large_rock_051293c4_v1.glb Tin_Man_1.glb Tin_Man_2.glb Plants_3.glb Urinals.glb V_Machine_2.glb
Remote models are NOT in the loading gate. Use prop-static app for physics:
const BASE = 'https://raw.githubusercontent.com/anEntrypoint/assets/main'
ctx.world.spawn('dumpster-1', { model:`${BASE}/dumpster_b076662a_v1.glb`, position:[5,0,-3], app:'prop-static' })
Server ctx API
ctx.entity
ctx.entity.id / model / position / rotation / scale / velocity / custom / parent / children / worldTransform
ctx.entity.destroy()
ctx.state
Persists across hot reloads. Re-register timers and bus subscriptions in every setup:
setup(ctx) {
ctx.state.score = ctx.state.score || 0
ctx.state.data = ctx.state.data || new Map()
ctx.bus.on('event', handler)
ctx.time.every(1, ticker)
}
ctx.config
A live overlay of the entity's editor-facing state: ctx.config reads the
world-def config:{...} PLUS every non-_-prefixed key of ctx.entity.custom
(so an editorProps field a maker edits in the inspector shows up here). Set in
world: { id:'x', app:'y', config:{ radius:5 } } → ctx.config.radius. When the
editor edits a prop live, ctx.config updates and any ctx.onConfigChange(cb)
listeners fire — no re-setup:
setup(ctx) {
ctx.onConfigChange(cfg => { })
}
ctx.interactable
Engine handles proximity, E-key prompt, and cooldown. App only needs onInteract:
setup(ctx) { ctx.interactable({ prompt:'Press E', radius:2, cooldown:1000 }) },
onInteract(ctx, player) { ctx.players.send(player.id, { type:'opened' }) }
ctx.physics
ctx.physics.addColliderFromConfig({
type: 'box'|'sphere'|'capsule'|'convex'|'trimesh'|'none',
size: [hx,hy,hz],
radius: 0.5,
height: 1.8,
meshIndex: 0,
mass: 10,
dynamic: true,
kinematic: true,
linearDamping: 1.5,
angularDamping: 4.0,
})
ctx.physics.setStatic(true) / setDynamic(true) / setKinematic(true)
ctx.physics.setMass(kg)
ctx.physics.addBoxCollider([hx,hy,hz])
ctx.physics.addSphereCollider(radius)
ctx.physics.addCapsuleCollider(radius, fullHeight)
ctx.physics.addTrimeshCollider()
ctx.physics.addConvexCollider(points)
ctx.physics.addConvexFromModel(meshIndex=0)
ctx.physics.addForce([fx,fy,fz])
ctx.physics.setVelocity([vx,vy,vz])
ctx.physics.getVelocity() / getAngularVelocity() / getRotation()
ctx.physics.isAtRest(eps=0.05)
ctx.physics.tiltFromUpright()
ctx.physics.setFriction(f) / setRestitution(b)
Cross-entity + off-centre physics (via ctx.world / ctx.physics):
ctx.world.applyImpulse(entityId, [ix,iy,iz], worldPoint?)
ctx.world.setVelocity(entityId, [vx,vy,vz])
ctx.world.setPosition(entityId, [x,y,z], rotation?)
ctx.world.setGravityFactor(entityId, factor)
ctx.world.weld(entityA, entityB, opts?)
ctx.world.joint(entityA, entityB, { type:'point'|'distance'|'hinge', ... })
ctx.world.removeConstraint(constraintId)
ctx.world
ctx.world.spawn(id, config)
ctx.world.spawnChild(id, config)
ctx.world.destroy(id)
ctx.world.getEntity(id)
ctx.world.query(filterFn)
ctx.world.nearby(pos, radius)
ctx.world.reparent(eid, parentId)
ctx.world.attach(entityId, appName) / detach(entityId)
ctx.world.gravity
ctx.players
ctx.players.getAll()
ctx.players.getById(id)
ctx.players.getNearest([x,y,z], radius)
ctx.players.send(playerId, msg)
ctx.players.broadcast(msg)
ctx.players.broadcastNearby(pos, radius, msg)
ctx.players.setPosition(playerId, [x,y,z])
ctx.players.setName(playerId, name)
ctx.players.setAppearance(playerId, { tint, nameTag })
ctx.players.setModel(playerId, url)
ctx.players.setMovementOverride(playerId, { maxSpeed, jumpImpulse, ... })
ctx.players.onPlayerContact(radius, cb)
ctx.players.nearestOtherPlayer(playerId, radius)
ctx.players.setLifecycle(playerId, 'alive'|'frozen'|'spectator', { spectateTarget })
ctx.players.playAnimation(playerId, clip, { loop, fade })
ctx.players.attachEntity(playerId, entityId, [ox,oy,oz])
ctx.players.detachEntity(entityId)
Mutate player.state.health / player.state.velocity directly — propagates in next snapshot.
Client-side follow-cam: engine.spectate(targetPlayerId) (pass null to return to own view).
ctx.bus
ctx.bus.on('channel', (e) => { e.data; e.channel; e.meta })
ctx.bus.once('channel', handler)
ctx.bus.emit('channel', data)
ctx.bus.on('combat.*', handler)
ctx.bus.handover(targetEntityId, data)
system.* prefix is reserved — do not emit on it.
ctx.time
ctx.time.tick / deltaTime / elapsed
ctx.time.after(seconds, fn)
ctx.time.every(seconds, fn)
ctx.raycast
const hit = ctx.raycast([x,y,z], [dx,dy,dz], maxDist, excludeBodyId?)
ctx.define* — reusable game-mechanic factories
Prefer these over hand-rolling common mechanics in update() (all take a spec,
return a stateful handle you drive from update/onInteract):
ctx.defineGameFSM(spec)
ctx.defineGameMode(spec)
ctx.defineHealth(spec)
ctx.defineSteering(spec)
ctx.defineCheckpoint(spec)
ctx.defineBuffStack(spec)
ctx.defineShrinkingZone(spec)
ctx.definePickup(spec)
ctx.defineDestructible(spec)
ctx.defineTeams(spec)
ctx.defineWeapon(spec)
ctx.definePlayerInventory(spec)
ctx.definePath(points)
Placeable objective apps (in tps-game placeableApps, drop + configure in the editor):
capture-zone (KotH/domination progress), waypoint (ordered path marker — read via
collectWaypoints(ctx)), shrinking-zone (battle-royale storm circle).
See apps/_lib/README.md for each spec's fields.
ctx.onPlayerProximity / ctx.onConfigChange
ctx.onPlayerProximity(radius, cb)
ctx.onConfigChange(cb)
ctx.canSee — line-of-sight
ctx.canSee(fromPos, toPos, { excludeBodyId?, targetEntityId?, maxDistance?, tolerance? })
ctx.storage
if (ctx.storage) {
await ctx.storage.set('key', value)
const val = await ctx.storage.get('key')
await ctx.storage.delete('key')
const keys = await ctx.storage.list('')
}
Client API
render(ctx) — return value
{ position:[x,y,z], rotation:[x,y,z,w], model:'path.glb', custom:{...}, ui:ctx.h('div',...) }
render(ctx) — available fields
ctx.entity
ctx.state
ctx.players
ctx.h
ctx.network.send(msg)
ctx.engine
ctx.THREE
ctx.scene
ctx.camera
ctx.renderer
ctx.playerId
ctx.clock
engine object (setup/onFrame/onInput/onEvent/onKeyDown/onKeyUp second arg)
engine.THREE / scene / camera / renderer
engine.playerId
engine.network.send(msg)
engine.client.state
engine.cam.getAimDirection(position)
engine.cam.punch(intensity)
engine.players.getAnimator(playerId)
engine.players.setExpression(playerId, name, weight)
engine.players.setAiming(playerId, isAiming)
engine.pickGround(clientX, clientY)
engine.followEntity(entityId, { distance, height })
engine.spectate(targetPlayerId)
App hooks — full list
setup(engine)
render(ctx)
onInput(input, engine)
onEvent(payload, engine)
onFrame(dt, engine)
onMouseDown(e, engine)
onMouseUp(e, engine)
onKeyDown(e, engine)
onKeyUp(e, engine)
ctx.h — hyperscript + Ripple UI
ctx.h(tag, props, ...children)
ctx.h('div', { style:'color:red' }, 'Hello')
Ripple UI CSS is loaded — use DaisyUI-compatible class names directly:
ctx.h('button', { class: 'btn btn-primary' }, 'Click me')
ctx.h('div', { class: 'card bg-base-200 shadow-xl' },
ctx.h('div', { class: 'card-body' },
ctx.h('h2', { class: 'card-title' }, 'Hello'),
ctx.h('button', { class: 'btn btn-sm btn-error' }, 'Delete')
)
)
Client apps cannot use import — all import statements stripped before evaluation. Use engine.* for deps.
onInput fields
forward backward left right jump crouch sprint shoot reload interact yaw pitch
Webcam AFAN — lazy-loaded face tracking
Opt-in only. Not loaded at startup. Enable via the webcam-avatar app or manually:
if (!window.enableWebcamAFAN) await import('/webcam-afan.js')
const tracker = await window.enableWebcamAFAN((data) => {
engine.network.send({ type: 'afan_frame', data: Array.from(data) })
})
On the server, forward afan_frame to nearby players. Engine auto-applies received frames to target VRM morph targets when payload { type: 'afan_frame', playerId, data } arrives via onAppEvent.
Procedural Mesh (custom field)
When no GLB set, custom drives geometry — primary way to create primitives without any GLB file.
{ mesh:'box', color:0xff8800, roughness:0.8, sx:2, sy:1, sz:2 }
{ mesh:'sphere', color:0x00ff00, r:1, seg:16 }
{ mesh:'cylinder', r:0.4, h:0.1, seg:16, color:0xffd700, metalness:0.8,
emissive:0xffa000, emissiveIntensity:0.3,
light:0xffd700, lightIntensity:1, lightRange:4 }
{ ..., hover:0.15, spin:1 }
{ ..., glow:true, glowColor:0x00ff88, glowIntensity:0.5 }
{ mesh:'box', label:'PRESS E' }
sx/sy/sz are FULL size. addBoxCollider takes HALF-extents. sx:4,sy:2 → addBoxCollider([2,1,...])
AppLoader — Blocked Strings
Any of these anywhere in source (including comments) silently prevents load, no throw:
process.exit child_process require( __proto__ Object.prototype globalThis eval( import(
Critical Caveats
Physics only activates inside app setup(). entity.bodyType = 'static' does nothing without an app calling ctx.physics.*.
const e = ctx.world.spawn('floor', {...}); e.bodyType = 'static'
ctx.world.spawn('floor', { app:'box-static', config:{ hx:5, hy:0.25, hz:5 } })
maxSpeed default mismatch. Code default is 8.0. Always set movement.maxSpeed explicitly.
Horizontal velocity is wish-based. After physics step, wish velocity overwrites XZ physics result. player.state.velocity[0/2] = wish velocity. Only velocity[1] (Y) comes from physics.
Capsule parameter order. addCapsuleCollider(radius, fullHeight) — full height, halved internally. Reversed from Jolt's direct API which takes (halfHeight, radius).
Trimesh is static-only. Use addConvexCollider or addConvexFromModel for dynamic/kinematic.
setTimeout not cleared on hot reload. ctx.time.* IS cleared. Manage raw timers manually in teardown.
Destroying parent destroys all children. Reparent first to preserve: ctx.world.reparent(childId, null)
setPosition teleports through walls — physics pushes out next tick.
App sphere collision is O(n²). Keep interactive entity count under ~50.
Snapshots only sent when players > 0. Entity state still updates, nothing broadcast.
TickSystem max 4 steps per loop. >4 ticks behind (~62ms at 64TPS) = silent drop.
Player join/leave arrive via onMessage:
onMessage(ctx, msg) {
if (!msg) return
const pid = msg.playerId || msg.senderId
if (msg.type === 'player_join') { }
if (msg.type === 'player_leave') { }
}
Debug Globals
Server (Node REPL): globalThis.__DEBUG__.server
Client (browser): window.debug → scene, camera, renderer, client, players, input
RTC (wireweave)
Spoint's WebRTC stack is wireweave (npm i wireweave nostr-tools). Use for: in-page voice chat by URL hash, host/join p2p game frames, self-hosted server player↔player side channels. No signaling server needed — discovery is on public nostr relays.
import { createWireweaveClient, getRoomFromHash } from 'spoint/transport/WireweaveClient.js'
const ww = await createWireweaveClient({
namespace: 'mygame',
room: getRoomFromHash() || 'lobby',
displayName: 'Player',
voice: true
})
ww.data.addEventListener('peer-open', e => console.log('peer up', e.detail.peerPubkey))
ww.data.addEventListener('data', e => onFrame(e.detail.peerPubkey, e.detail.data))
ww.data.broadcast(new Uint8Array(payload))
Per-peer TransportWrapper (drop-in for code that consumes RTCDataChannelTransport):
import { WireweaveTransport } from 'spoint/transport/WireweaveTransport.js'
const t = new WireweaveTransport({ session: ww.data, peerPubkey })
t.on('message', buf => ...); t.send(bytes)