Spawnpoint App Development Reference Setup: bunx spoint scaffold
first time — copies default apps/ into cwd
bunx spoint
start server (localhost:3001)
bunx spoint-create-app my-app
bunx spoint-create-app
--template
physics my-crate
Project structure:
apps/world/index.js
(world config) +
apps/${
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
(
)
// position: [x,y,z] rotation: [x,y,z,w] quaternion custom: any (sent in every snapshot — keep small)
ctx.state
Persists across hot reloads. Re-register timers and bus subscriptions in every setup:
setup
(
ctx
)
{
ctx
.
state
.
score
=
ctx
.
state
.
score
||
0
// || preserves value on reload
ctx
.
state
.
data
=
ctx
.
state
.
data
||
new
Map
(
)
ctx
.
bus
.
on
(
'event'
,
handler
)
// always re-register
ctx
.
time
.
every
(
1
,
ticker
)
// always re-register
}
ctx.config
Read-only. Set in world:
{ id:'x', app:'y', config:{ radius:5 } }
→
ctx.config.radius
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
.
setStatic
(
true
)
/
setDynamic
(
true
)
/
setKinematic
(
true
)
ctx
.
physics
.
setMass
(
kg
)
ctx
.
physics
.
addBoxCollider
(
size
)
// number or [hx,hy,hz] half-extents
ctx
.
physics
.
addSphereCollider
(
radius
)
ctx
.
physics
.
addCapsuleCollider
(
radius
,
fullHeight
)
// fullHeight=total height, halved internally
ctx
.
physics
.
addTrimeshCollider
(
)
// static-only, uses entity.model
ctx
.
physics
.
addConvexCollider
(
points
)
// flat [x,y,z,...], all motion types
ctx
.
physics
.
addConvexFromModel
(
meshIndex
=
0
)
// extracts verts from entity.model GLB
ctx
.
physics
.
addForce
(
[
fx
,
fy
,
fz
]
)
// velocity += force/mass
ctx
.
physics
.
setVelocity
(
[
vx
,
vy
,
vz
]
)
ctx.world
ctx
.
world
.
spawn
(
id
,
config
)
// id: string|null (null=auto-generate). Returns entity|null.
ctx
.
world
.
destroy
(
id
)
ctx
.
world
.
getEntity
(
id
)
// entity|null
ctx
.
world
.
query
(
filterFn
)
// entity[]
ctx
.
world
.
nearby
(
pos
,
radius
)
// entity IDs within radius
ctx
.
world
.
reparent
(
eid
,
parentId
)
// parentId null = detach
ctx
.
world
.
attach
(
entityId
,
appName
)
/
detach
(
entityId
)
ctx
.
world
.
gravity
// [x,y,z] read-only
// spawn config keys: model, position, rotation, scale, parent, app, config, autoTrimesh
ctx.players
ctx
.
players
.
getAll
(
)
// Player: { id, state: { position, velocity, health, onGround, crouch, lookPitch, lookYaw, interact } }
ctx
.
players
.
getNearest
(
[
x
,
y
,
z
]
,
radius
)
// Player|null
ctx
.
players
.
send
(
playerId
,
msg
)
// client receives in onEvent(payload, engine)
ctx
.
players
.
broadcast
(
msg
)
ctx
.
players
.
setPosition
(
playerId
,
[
x
,
y
,
z
]
)
// teleport — no collision check
Mutate
player.state.health
/
player.state.velocity
directly — propagates in next snapshot.
ctx.bus
ctx
.
bus
.
on
(
'channel'
,
(
e
)
=>
{
e
.
data
;
e
.
channel
;
e
.
meta
}
)
ctx
.
bus
.
once
(
'channel'
,
handler
)
ctx
.
bus
.
emit
(
'channel'
,
data
)
// meta.sourceEntity set automatically
ctx
.
bus
.
on
(
'combat.'
,
handler
)
// wildcard prefix
ctx
.
bus
.
handover
(
targetEntityId
,
data
)
// fires onHandover on target
system.
prefix is reserved — do not emit on it.
ctx.time
ctx
.
time
.
tick
/
deltaTime
/
elapsed
ctx
.
time
.
after
(
seconds
,
fn
)
// one-shot, cleared on teardown
ctx
.
time
.
every
(
seconds
,
fn
)
// repeating, cleared on teardown
ctx.raycast
const
hit
=
ctx
.
raycast
(
[
x
,
y
,
z
]
,
[
dx
,
dy
,
dz
]
,
maxDist
)
// { hit:bool, distance:number, body:bodyId|null, position:[x,y,z]|null }
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
// { id, position, rotation, custom, model }
ctx
.
state
// alias for ctx.entity.custom
ctx
.
players
// array of all player states
ctx
.
h
// hyperscript createElement
ctx
.
network
.
send
(
msg
)
// send message to server (entityId auto-attached)
ctx
.
engine
// full engineCtx (see below)
// Three.js shortcuts — same as ctx.engine. but directly accessible:
ctx
.
THREE
// THREE namespace (BoxGeometry, MeshStandardMaterial, etc.)
ctx
.
scene
// THREE.Scene — ctx.scene.add(mesh)
ctx
.
camera
// THREE.PerspectiveCamera
ctx
.
renderer
// THREE.WebGLRenderer
ctx
.
playerId
// local player ID string
ctx
.
clock
// { elapsed: seconds since page load }
engine object (setup/onFrame/onInput/onEvent/onKeyDown/onKeyUp second arg)
engine
.
THREE
/
scene
/
camera
/
renderer
engine
.
playerId
// local player ID
engine
.
network
.
send
(
msg
)
// send message to server from any hook
engine
.
client
.
state
// { players:[...], entities:[...] }
engine
.
cam
.
getAimDirection
(
position
)
// normalized [dx,dy,dz]
engine
.
cam
.
punch
(
intensity
)
// visual recoil
engine
.
players
.
getAnimator
(
playerId
)
engine
.
players
.
setExpression
(
playerId
,
name
,
weight
)
engine
.
players
.
setAiming
(
playerId
,
isAiming
)
App hooks — full list
setup
(
engine
)
// called once on module load
render
(
ctx
)
// called every 250ms, returns render state + ui
onInput
(
input
,
engine
)
// called every input tick (60Hz)
onEvent
(
payload
,
engine
)
// called on server broadcast
onFrame
(
dt
,
engine
)
// called every animation frame
onMouseDown
(
e
,
engine
)
// mouse button press on canvas
onMouseUp
(
e
,
engine
)
// mouse button release on canvas
onKeyDown
(
e
,
engine
)
// keyboard key press (document-level)
onKeyUp
(
e
,
engine
)
// keyboard key release (document-level)
ctx.h — hyperscript + Ripple UI
ctx
.
h
(
tag
,
props
,
...
children
)
// props = attrs/inline styles or null; null children ignored
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:
// In setup(engine) or onKeyDown — only loaded on demand
if
(
!
window
.
enableWebcamAFAN
)
await
import
(
'/webcam-afan.js'
)
const
tracker
=
await
window
.
enableWebcamAFAN
(
(
data
)
=>
{
// data: Uint8Array(52) — ARKit blendshape weights, each byte = weight * 255
engine
.
network
.
send
(
{
type
:
'afan_frame'
,
data
:
Array
.
from
(
data
)
}
)
}
)
// tracker.stop() to release camera
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
}
// sx/sy/sz = FULL dimensions
{
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
}
// Y oscillation amplitude (units), rotation (rad/sec)
{
...
,
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.
.
// WRONG — entity renders but players fall through:
const
e
=
ctx
.
world
.
spawn
(
'floor'
,
{
...
}
)
;
e
.
bodyType
=
'static'
// ignored
// CORRECT:
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