spoint 0.1.48 → 0.1.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -1,80 +1,76 @@
1
1
  ---
2
2
  name: spoint
3
- description: Work with spoint - a multiplayer physics game server SDK. Scaffolds apps locally, runs engine from npm package.
3
+ description: Build multiplayer physics games with the Spawnpoint engine. Use when asked to: create a game, add physics objects, spawn entities, build an arena, 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.
4
4
  ---
5
5
 
6
6
  # Spawnpoint App Development Reference
7
7
 
8
- Complete reference for building apps in a spawnpoint project. Engine source code is not required. Everything needed to build any app is documented here.
9
-
10
- ---
11
-
12
- ## Setup
13
-
14
- When no `apps/` directory exists in the working directory, scaffold it:
15
-
16
- ```bash
17
- bunx spoint scaffold
18
- bunx spoint
19
- ```
20
-
21
- This copies the default apps (world config, tps-game, environment, etc.) into `./apps/` and starts the server. The engine (src/, client/) always comes from the npm package - never from the user's local folder.
22
-
23
- ```bash
24
- bunx spoint # start server
25
- ```
26
-
27
- Open http://localhost:3001 in browser. Apps hot-reload on file save.
28
-
8
+ Setup:
29
9
  ```bash
10
+ bunx spoint scaffold # first time — copies default apps/ into cwd
11
+ bunx spoint # start server (localhost:3001)
30
12
  bunx spoint-create-app my-app
31
- bunx spoint-create-app --template physics my-physics-object
32
- bunx spoint-create-app --template interactive my-button
33
- bunx spoint-create-app --template spawner my-spawner
34
- ```
35
-
36
- ---
37
-
38
- ## Project Structure
39
-
40
- ```
41
- your-project/
42
- apps/
43
- world/index.js # World config (port, tickRate, gravity, entities, scene, camera)
44
- my-app/index.js # Your app (or apps/my-app.js)
13
+ bunx spoint-create-app --template physics my-crate
45
14
  ```
46
15
 
47
- Start: `node server.js` or `bunx spoint`. Port from world config (default 8080, world default 3001).
16
+ Project structure: `apps/world/index.js` (world config) + `apps/<name>/index.js` (apps). Engine is from npm never in user project.
48
17
 
49
18
  ---
50
19
 
51
- ## App Anatomy
20
+ ## Quick Start — Minimal Working Arena
52
21
 
53
- An app is an ES module with a default export containing a `server` object and optionally a `client` object.
22
+ ### `apps/world/index.js`
23
+ ```js
24
+ export default {
25
+ port: 3001, tickRate: 128, gravity: [0, -9.81, 0],
26
+ movement: { maxSpeed: 4.0, groundAccel: 10.0, airAccel: 1.0, friction: 6.0, stopSpeed: 2.0, jumpImpulse: 4.0 },
27
+ player: { health: 100, capsuleRadius: 0.4, capsuleHalfHeight: 0.9, modelScale: 1.323, feetOffset: 0.212 },
28
+ scene: { skyColor: 0x87ceeb, fogColor: 0x87ceeb, fogNear: 80, fogFar: 200, sunIntensity: 1.5, sunPosition: [20, 40, 20] },
29
+ entities: [{ id: 'arena', position: [0,0,0], app: 'arena' }],
30
+ spawnPoint: [0, 2, 0]
31
+ }
32
+ ```
54
33
 
34
+ ### `apps/arena/index.js`
55
35
  ```js
36
+ const HALF = 12, WH = 3, WT = 0.5
37
+ const WALLS = [
38
+ { id:'wn', x:0, y:WH/2, z:-HALF, hx:HALF, hy:WH/2, hz:WT/2 },
39
+ { id:'ws', x:0, y:WH/2, z: HALF, hx:HALF, hy:WH/2, hz:WT/2 },
40
+ { id:'we', x: HALF, y:WH/2, z:0, hx:WT/2, hy:WH/2, hz:HALF },
41
+ { id:'ww', x:-HALF, y:WH/2, z:0, hx:WT/2, hy:WH/2, hz:HALF },
42
+ ]
56
43
  export default {
57
44
  server: {
58
- setup(ctx) {}, // Called once when entity is attached
59
- update(ctx, dt) {}, // Called every tick, dt in seconds
60
- teardown(ctx) {}, // Called on entity destroy or before hot reload
61
- onMessage(ctx, msg) {}, // Called for player messages (including player_join, player_leave)
62
- onEvent(ctx, payload) {}, // Called via ctx.bus or fireEvent
63
- onCollision(ctx, other) {}, // other = { id, position, velocity }
64
- onInteract(ctx, player) {}, // Called by fireInteract
65
- onHandover(ctx, sourceEntityId, stateData) {} // Called via bus.handover
45
+ setup(ctx) {
46
+ ctx.state.ids = ctx.state.ids || []
47
+ if (ctx.state.ids.length > 0) return // hot-reload guard
48
+ ctx.entity.custom = { mesh:'box', color:0x5a7a4a, sx:HALF*2, sy:0.5, sz:HALF*2 }
49
+ ctx.physics.setStatic(true)
50
+ ctx.physics.addBoxCollider([HALF, 0.25, HALF])
51
+ for (const w of WALLS) {
52
+ const e = ctx.world.spawn(w.id, { position:[w.x,w.y,w.z], app:'box-static', config:{ hx:w.hx, hy:w.hy, hz:w.hz, color:0x7a6a5a } })
53
+ if (e) ctx.state.ids.push(w.id)
54
+ }
55
+ },
56
+ teardown(ctx) { for (const id of ctx.state.ids||[]) ctx.world.destroy(id); ctx.state.ids = [] }
66
57
  },
58
+ client: { render(ctx) { return { position:ctx.entity.position, rotation:ctx.entity.rotation, custom:ctx.entity.custom } } }
59
+ }
60
+ ```
67
61
 
68
- client: {
69
- setup(engine) {}, // Called once when app loads on client
70
- teardown(engine) {}, // Called before hot reload or disconnect
71
- onFrame(dt, engine) {}, // Called every animation frame
72
- onInput(input, engine) {}, // Called when input state is available
73
- onEvent(payload, engine) {}, // Called when server sends message to this client
74
- onMouseDown(e, engine) {},
75
- onMouseUp(e, engine) {},
76
- render(ctx) {} // Returns entity render state + optional UI
77
- }
62
+ ### `apps/box-static/index.js` — reusable static box primitive
63
+ ```js
64
+ export default {
65
+ server: {
66
+ setup(ctx) {
67
+ const c = ctx.config
68
+ ctx.entity.custom = { mesh:'box', color:c.color??0x888888, sx:(c.hx??1)*2, sy:(c.hy??1)*2, sz:(c.hz??1)*2 }
69
+ ctx.physics.setStatic(true)
70
+ ctx.physics.addBoxCollider([c.hx??1, c.hy??1, c.hz??1])
71
+ }
72
+ },
73
+ client: { render(ctx) { return { position:ctx.entity.position, rotation:ctx.entity.rotation, custom:ctx.entity.custom } } }
78
74
  }
79
75
  ```
80
76
 
@@ -82,1077 +78,302 @@ export default {
82
78
 
83
79
  ## World Config Schema
84
80
 
85
- `apps/world/index.js` exports a plain object. All fields optional.
81
+ All fields optional. `apps/world/index.js` exports a plain object.
86
82
 
87
83
  ```js
88
84
  export default {
89
- port: 3001,
90
- tickRate: 128,
91
- gravity: [0, -9.81, 0],
92
-
85
+ port: 3001, tickRate: 128, gravity: [0,-9.81,0],
93
86
  movement: {
94
- maxSpeed: 4.0, // Max horizontal speed (m/s). DEFAULT code value is 8.0 but world overrides it
95
- groundAccel: 10.0, // Ground acceleration
96
- airAccel: 1.0, // Air acceleration (no friction in air)
97
- friction: 6.0, // Ground friction coefficient
98
- stopSpeed: 2.0, // Speed threshold for minimum friction control
99
- jumpImpulse: 4.0, // Upward velocity set on jump
100
- collisionRestitution: 0.2,
101
- collisionDamping: 0.25,
102
- crouchSpeedMul: 0.4, // Speed multiplier when crouching
103
- sprintSpeed: null // null = maxSpeed * 1.75
87
+ maxSpeed: 4.0, // code default is 8.0 always override explicitly
88
+ groundAccel: 10.0, airAccel: 1.0, friction: 6.0, stopSpeed: 2.0,
89
+ jumpImpulse: 4.0, // velocity SET (not added) on jump
90
+ crouchSpeedMul: 0.4, sprintSpeed: null // null = maxSpeed * 1.75
104
91
  },
105
-
106
92
  player: {
107
- health: 100,
108
- capsuleRadius: 0.4,
109
- capsuleHalfHeight: 0.9,
110
- crouchHalfHeight: 0.45,
111
- mass: 120,
112
- modelScale: 1.323,
113
- feetOffset: 0.212 // Ratio: feetOffset * modelScale = negative Y offset on model
93
+ health: 100, capsuleRadius: 0.4, capsuleHalfHeight: 0.9, crouchHalfHeight: 0.45,
94
+ mass: 120, modelScale: 1.323,
95
+ feetOffset: 0.212 // feetOffset * modelScale = negative Y on model
114
96
  },
115
-
116
97
  scene: {
117
- skyColor: 0x87ceeb,
118
- fogColor: 0x87ceeb,
119
- fogNear: 80,
120
- fogFar: 200,
121
- ambientColor: 0xfff4d6,
122
- ambientIntensity: 0.3,
123
- sunColor: 0xffffff,
124
- sunIntensity: 1.5,
125
- sunPosition: [21, 50, 20],
126
- fillColor: 0x4488ff,
127
- fillIntensity: 0.4,
128
- fillPosition: [-20, 30, -10],
129
- shadowMapSize: 1024,
130
- shadowBias: 0.0038,
131
- shadowNormalBias: 0.6,
132
- shadowRadius: 12,
133
- shadowBlurSamples: 8
98
+ skyColor: 0x87ceeb, fogColor: 0x87ceeb, fogNear: 80, fogFar: 200,
99
+ ambientColor: 0xfff4d6, ambientIntensity: 0.3,
100
+ sunColor: 0xffffff, sunIntensity: 1.5, sunPosition: [21,50,20],
101
+ fillColor: 0x4488ff, fillIntensity: 0.4, fillPosition: [-20,30,-10],
102
+ shadowMapSize: 1024, shadowBias: 0.0038, shadowNormalBias: 0.6, shadowRadius: 12, shadowBlurSamples: 8
134
103
  },
135
-
136
104
  camera: {
137
- fov: 70,
138
- shoulderOffset: 0.35,
139
- headHeight: 0.4,
140
- zoomStages: [0, 1.5, 3, 5, 8],
141
- defaultZoomIndex: 2,
142
- followSpeed: 12.0,
143
- snapSpeed: 30.0,
144
- mouseSensitivity: 0.002,
145
- pitchRange: [-1.4, 1.4]
146
- },
147
-
148
- animation: {
149
- mixerTimeScale: 1.3,
150
- walkTimeScale: 2.0,
151
- sprintTimeScale: 0.56,
152
- fadeTime: 0.15
105
+ fov: 70, shoulderOffset: 0.35, headHeight: 0.4,
106
+ zoomStages: [0,1.5,3,5,8], defaultZoomIndex: 2,
107
+ followSpeed: 12.0, snapSpeed: 30.0, mouseSensitivity: 0.002, pitchRange: [-1.4,1.4]
153
108
  },
154
-
155
- entities: [
156
- {
157
- id: 'environment',
158
- model: './apps/tps-game/schwust.glb',
159
- position: [0, 0, 0],
160
- app: 'environment', // App name matching apps/ folder or file
161
- config: { myKey: 'val' } // Accessible as ctx.config.myKey in the app
162
- }
163
- ],
164
-
109
+ animation: { mixerTimeScale: 1.3, walkTimeScale: 2.0, sprintTimeScale: 0.56, fadeTime: 0.15 },
110
+ entities: [{ id:'env', model:'./apps/my-app/env.glb', position:[0,0,0], app:'environment', config:{} }],
165
111
  playerModel: './apps/tps-game/Cleetus.vrm',
166
- spawnPoint: [-35, 3, -65]
112
+ spawnPoint: [0,2,0]
167
113
  }
168
114
  ```
169
115
 
170
116
  ---
171
117
 
172
- ## Server-Side Context API (ctx)
118
+ ## Asset Loading Gate
173
119
 
174
- The `ctx` object is passed to every server lifecycle method.
120
+ Loading screen holds until ALL pass simultaneously:
121
+ 1. WebSocket connected
122
+ 2. Player VRM downloaded
123
+ 3. Any entity with `model` field (or entity creating a mesh) loaded
124
+ 4. First snapshot received
125
+ 5. All `world.entities` entries with `model` field loaded
175
126
 
176
- ### ctx.entity
127
+ Entities spawned via `ctx.world.spawn()` at runtime are NOT in the gate. Declare in `world.entities` to block loading screen on a model.
177
128
 
178
- ```js
179
- ctx.entity.id // string - unique ID (read-only)
180
- ctx.entity.model // string|null - GLB/VRM asset path
181
- ctx.entity.position // [x, y, z] array - read/write
182
- ctx.entity.rotation // [x, y, z, w] quaternion - read/write
183
- ctx.entity.scale // [x, y, z] array - read/write
184
- ctx.entity.velocity // [x, y, z] array - read/write
185
- ctx.entity.custom // any - arbitrary data sent to clients in snapshots (keep small)
186
- ctx.entity.parent // string|null - parent entity ID (read-only)
187
- ctx.entity.children // string[] - copy of child entity IDs (read-only)
188
- ctx.entity.worldTransform // { position, rotation, scale } - computed world space transform
189
- ctx.entity.destroy() // Destroy this entity and all children
190
- ```
129
+ ---
191
130
 
192
- ### ctx.state
131
+ ## Remote Models — Verified Filenames
193
132
 
194
- Persistent state object. Survives hot reload. Assign properties directly or merge.
133
+ URL: `https://raw.githubusercontent.com/anEntrypoint/assets/main/FILENAME.glb`
195
134
 
196
- ```js
197
- ctx.state.score = 0
198
- ctx.state.players = new Map()
199
- ctx.state = { key: 'value' } // Object.assign merge (does NOT replace the object)
200
- ```
135
+ **Never guess filenames** — wrong URLs silently 404, no error.
201
136
 
202
- Initialize with `||` to preserve values across hot reload:
137
+ ```
138
+ broken_car_b6d2e66d_v1.glb broken_car_b6d2e66d_v2.glb crashed_car_f2b577ae_v1.glb
139
+ crashed_pickup_truck_ae555020_v1.glb crashed_rusty_minivan_f872ff37_v1.glb Bus_junk_1.glb
140
+ blue_shipping_container_60b5ea93_v1.glb blue_shipping_container_63cc3905_v1.glb
141
+ dumpster_b076662a_v1.glb dumpster_b076662a_v2.glb garbage_can_6b3d052b_v1.glb
142
+ crushed_oil_barrel_e450f43f_v1.glb fire_hydrant_ba0175c1_v1.glb
143
+ fire_extinguisher_wall_mounted_bc0dddd4_v1.glb
144
+ break_room_chair_14a39c7b_v1.glb break_room_couch_444abf63_v1.glb
145
+ break_room_table_09b9fd0d_v1.glb filing_cabinet_0194476c_v1.glb
146
+ fancy_reception_desk_58fde71d_v1.glb cash_register_0c0dcad2_v1.glb
147
+ espresso_machine_e722ed8c_v1.glb Couch.glb Couch_2.glb 3chairs.glb
148
+ large_rock_051293c4_v1.glb Tin_Man_1.glb Tin_Man_2.glb Plants_3.glb Urinals.glb V_Machine_2.glb
149
+ ```
203
150
 
151
+ Remote models are NOT in the loading gate. Use `prop-static` app for physics:
204
152
  ```js
205
- setup(ctx) {
206
- ctx.state.score = ctx.state.score || 0
207
- ctx.state.data = ctx.state.data || new Map()
153
+ // apps/prop-static/index.js
154
+ export default {
155
+ server: { setup(ctx) { ctx.physics.setStatic(true); if (ctx.entity.model) ctx.physics.addConvexFromModel(0) } },
156
+ client: { render(ctx) { return { position:ctx.entity.position, rotation:ctx.entity.rotation, model:ctx.entity.model } } }
208
157
  }
158
+ // Spawn:
159
+ const BASE = 'https://raw.githubusercontent.com/anEntrypoint/assets/main'
160
+ ctx.world.spawn('dumpster-1', { model:`${BASE}/dumpster_b076662a_v1.glb`, position:[5,0,-3], app:'prop-static' })
209
161
  ```
210
162
 
211
- ### ctx.config
163
+ ---
212
164
 
213
- Read-only. Set in the world entities array under `config: {}`.
165
+ ## Server ctx API
214
166
 
167
+ ### ctx.entity
215
168
  ```js
216
- // world/index.js:
217
- { id: 'my-entity', app: 'my-app', config: { radius: 5 } }
218
- // In app:
219
- ctx.config.radius // 5
169
+ ctx.entity.id / model / position / rotation / scale / velocity / custom / parent / children / worldTransform
170
+ ctx.entity.destroy()
171
+ // position: [x,y,z] rotation: [x,y,z,w] quaternion custom: any (sent in every snapshot — keep small)
220
172
  ```
221
173
 
222
- ### ctx.interactable
223
-
224
- Declares this entity as interactable. The engine handles proximity detection, the E-key prompt UI, and cooldown automatically. The app only needs to implement `onInteract`.
174
+ ### ctx.state
225
175
 
176
+ Persists across hot reloads. Re-register timers and bus subscriptions in every setup:
226
177
  ```js
227
178
  setup(ctx) {
228
- ctx.interactable({ prompt: 'Press E to open', radius: 2, cooldown: 1000 })
229
- },
230
-
231
- onInteract(ctx, player) {
232
- ctx.players.send(player.id, { type: 'opened', message: 'Opened!' })
179
+ ctx.state.score = ctx.state.score || 0 // || preserves value on reload
180
+ ctx.state.data = ctx.state.data || new Map()
181
+ ctx.bus.on('event', handler) // always re-register
182
+ ctx.time.every(1, ticker) // always re-register
233
183
  }
234
184
  ```
235
185
 
236
- Options:
237
- - `prompt` — text shown in the HUD when player is within range (default: `'Press E'`)
238
- - `radius` — interaction distance in world units (default: `3`)
239
- - `cooldown` — milliseconds between allowed interactions per player (default: `500`)
186
+ ### ctx.config
240
187
 
241
- The engine client automatically shows and hides the prompt when the local player enters or leaves the radius. No client-side code is needed for basic interactables.
188
+ Read-only. Set in world: `{ id:'x', app:'y', config:{ radius:5 } }` `ctx.config.radius`
242
189
 
243
- ### ctx.physics
190
+ ### ctx.interactable
244
191
 
192
+ Engine handles proximity, E-key prompt, and cooldown. App only needs `onInteract`:
245
193
  ```js
246
- ctx.physics.setStatic(true) // Immovable
247
- ctx.physics.setDynamic(true) // Affected by physics
248
- ctx.physics.setKinematic(true) // Moved by code, pushes dynamic bodies
249
- ctx.physics.setMass(kg)
250
-
251
- ctx.physics.addBoxCollider(size)
252
- // size: number (uniform) or [hx, hy, hz] half-extents
253
- // Example: ctx.physics.addBoxCollider([0.75, 0.25, 0.75])
194
+ setup(ctx) { ctx.interactable({ prompt:'Press E', radius:2, cooldown:1000 }) },
195
+ onInteract(ctx, player) { ctx.players.send(player.id, { type:'opened' }) }
196
+ ```
254
197
 
198
+ ### ctx.physics
199
+ ```js
200
+ ctx.physics.setStatic(true) / setDynamic(true) / setKinematic(true)
201
+ ctx.physics.setMass(kg)
202
+ ctx.physics.addBoxCollider(size) // number or [hx,hy,hz] half-extents
255
203
  ctx.physics.addSphereCollider(radius)
256
-
257
- ctx.physics.addCapsuleCollider(radius, fullHeight)
258
- // fullHeight is the FULL height. Internally divided by 2 for Jolt.
259
-
260
- ctx.physics.addTrimeshCollider()
261
- // Builds static mesh from entity.model path. Static only.
262
-
263
- ctx.physics.addConvexCollider(points)
264
- // points: flat Float32Array or Array of [x,y,z,x,y,z,...] vertex positions
265
- // Builds a ConvexHullShape from the provided point cloud. Supports all motion types.
266
-
267
- ctx.physics.addConvexFromModel(meshIndex = 0)
268
- // Extracts vertex positions from entity.model GLB and builds ConvexHullShape.
269
- // Simpler and faster than trimesh for dynamic objects like vehicles/crates.
270
-
271
- ctx.physics.addForce([fx, fy, fz]) // Impulse: velocity += force / mass
272
- ctx.physics.setVelocity([vx, vy, vz]) // Set velocity directly
204
+ ctx.physics.addCapsuleCollider(radius, fullHeight) // fullHeight=total height, halved internally
205
+ ctx.physics.addTrimeshCollider() // static-only, uses entity.model
206
+ ctx.physics.addConvexCollider(points) // flat [x,y,z,...], all motion types
207
+ ctx.physics.addConvexFromModel(meshIndex=0) // extracts verts from entity.model GLB
208
+ ctx.physics.addForce([fx,fy,fz]) // velocity += force/mass
209
+ ctx.physics.setVelocity([vx,vy,vz])
273
210
  ```
274
211
 
275
212
  ### ctx.world
276
-
277
213
  ```js
278
- ctx.world.spawn(id, config)
279
- // id: string|null (null = auto-generate as 'entity_N')
280
- // Returns: entity object or null
281
-
214
+ ctx.world.spawn(id, config) // id: string|null (null=auto-generate). Returns entity|null.
282
215
  ctx.world.destroy(id)
283
- ctx.world.getEntity(id) // Returns entity object or null
284
- ctx.world.query(filterFn) // Returns entity[] matching filter
285
- ctx.world.nearby(pos, radius) // Returns entity IDs within radius
286
- ctx.world.reparent(eid, parentId) // Change parent (null = detach from parent)
287
- ctx.world.attach(entityId, appName)
288
- ctx.world.detach(entityId)
289
- ctx.world.gravity // [x, y, z] read-only
290
- ```
291
-
292
- Entity config for spawn:
216
+ ctx.world.getEntity(id) // entity|null
217
+ ctx.world.query(filterFn) // entity[]
218
+ ctx.world.nearby(pos, radius) // entity IDs within radius
219
+ ctx.world.reparent(eid, parentId) // parentId null = detach
220
+ ctx.world.attach(entityId, appName) / detach(entityId)
221
+ ctx.world.gravity // [x,y,z] read-only
293
222
 
294
- ```js
295
- ctx.world.spawn('my-id', {
296
- model: './path/to/model.glb',
297
- position: [x, y, z], // default [0,0,0]
298
- rotation: [x, y, z, w], // default [0,0,0,1]
299
- scale: [x, y, z], // default [1,1,1]
300
- parent: 'parent-entity-id',
301
- app: 'app-name', // Auto-attach app
302
- config: { ... }, // ctx.config in attached app
303
- autoTrimesh: true // Auto-add trimesh collider from model
304
- })
223
+ // spawn config keys: model, position, rotation, scale, parent, app, config, autoTrimesh
305
224
  ```
306
225
 
307
226
  ### ctx.players
308
-
309
227
  ```js
310
228
  ctx.players.getAll()
311
- // Returns Player[] where each player has:
312
- // { id: string, state: { position, velocity, health, onGround, crouch, lookPitch, lookYaw, interact } }
313
-
314
- ctx.players.getNearest([x, y, z], radius)
315
- // Returns nearest player within radius, or null
316
-
317
- ctx.players.send(playerId, { type: 'my_type', ...data })
318
- // Client receives in onEvent(payload, engine)
319
-
320
- ctx.players.broadcast({ type: 'my_type', ...data })
321
-
322
- ctx.players.setPosition(playerId, [x, y, z])
323
- // Teleports player - no collision check during teleport
324
- ```
325
-
326
- Player state fields:
327
-
328
- ```js
329
- player.state.position // [x, y, z]
330
- player.state.velocity // [x, y, z]
331
- player.state.health // number
332
- player.state.onGround // boolean
333
- player.state.crouch // 0 or 1
334
- player.state.lookPitch // radians
335
- player.state.lookYaw // radians
336
- player.state.interact // boolean - true if player pressed interact this tick
337
- ```
338
-
339
- You can directly mutate `player.state.health`, `player.state.velocity`, etc. and the change propagates in the next snapshot.
340
-
341
- ### ctx.network
342
-
343
- ```js
344
- ctx.network.broadcast(msg) // Same as ctx.players.broadcast
345
- ctx.network.sendTo(playerId, msg) // Same as ctx.players.send
229
+ // Player: { id, state: { position, velocity, health, onGround, crouch, lookPitch, lookYaw, interact } }
230
+ ctx.players.getNearest([x,y,z], radius) // Player|null
231
+ ctx.players.send(playerId, msg) // client receives in onEvent(payload, engine)
232
+ ctx.players.broadcast(msg)
233
+ ctx.players.setPosition(playerId, [x,y,z]) // teleport no collision check
346
234
  ```
235
+ Mutate `player.state.health` / `player.state.velocity` directly — propagates in next snapshot.
347
236
 
348
237
  ### ctx.bus
349
-
350
- Scoped EventBus. Auto-cleaned on teardown.
351
-
352
238
  ```js
353
- ctx.bus.on('channel.name', (event) => {
354
- event.channel // string
355
- event.data // your payload
356
- event.meta // { timestamp, sourceEntity }
357
- })
358
-
359
- ctx.bus.once('channel.name', handler)
360
- ctx.bus.emit('channel.name', data)
361
- ctx.bus.handover(targetEntityId, stateData)
362
- // Fires onHandover(ctx, sourceEntityId, stateData) on the target entity's app
239
+ ctx.bus.on('channel', (e) => { e.data; e.channel; e.meta })
240
+ ctx.bus.once('channel', handler)
241
+ ctx.bus.emit('channel', data) // meta.sourceEntity set automatically
242
+ ctx.bus.on('combat.*', handler) // wildcard prefix
243
+ ctx.bus.handover(targetEntityId, data) // fires onHandover on target
363
244
  ```
245
+ `system.*` prefix is reserved — do not emit on it.
364
246
 
365
247
  ### ctx.time
366
-
367
248
  ```js
368
- ctx.time.tick // Current tick number
369
- ctx.time.deltaTime // Same as dt in update()
370
- ctx.time.elapsed // Total seconds since runtime start
249
+ ctx.time.tick / deltaTime / elapsed
250
+ ctx.time.after(seconds, fn) // one-shot, cleared on teardown
251
+ ctx.time.every(seconds, fn) // repeating, cleared on teardown
252
+ ```
371
253
 
372
- ctx.time.after(seconds, fn) // One-shot timer
373
- ctx.time.every(seconds, fn) // Repeating timer
374
- // All timers are cleared on teardown automatically
254
+ ### ctx.raycast
255
+ ```js
256
+ const hit = ctx.raycast([x,y,z], [dx,dy,dz], maxDist)
257
+ // { hit:bool, distance:number, body:bodyId|null, position:[x,y,z]|null }
375
258
  ```
376
259
 
377
260
  ### ctx.storage
378
-
379
- Async key-value storage, namespaced to app name. Null if no adapter configured.
380
-
381
261
  ```js
382
262
  if (ctx.storage) {
383
263
  await ctx.storage.set('key', value)
384
264
  const val = await ctx.storage.get('key')
385
265
  await ctx.storage.delete('key')
386
- const exists = await ctx.storage.has('key')
387
- const keys = await ctx.storage.list('') // all keys in namespace
388
- }
389
- ```
390
-
391
- ### ctx.debug
392
-
393
- ```js
394
- ctx.debug.log('message', optionalData)
395
- ```
396
-
397
- ### ctx.raycast
398
-
399
- ```js
400
- const hit = ctx.raycast(origin, direction, maxDistance)
401
- // origin: [x, y, z]
402
- // direction: [x, y, z] normalized unit vector
403
- // maxDistance: number (default 1000)
404
- // Returns: { hit: boolean, distance: number, body: bodyId|null, position: [x,y,z]|null }
405
-
406
- const result = ctx.raycast([x, 20, z], [0, -1, 0], 30)
407
- if (result.hit) {
408
- const groundY = result.position[1]
266
+ const keys = await ctx.storage.list('')
409
267
  }
410
268
  ```
411
269
 
412
270
  ---
413
271
 
414
- ## Client-Side Context API
415
-
416
- ### render(ctx)
272
+ ## Client API
417
273
 
274
+ ### render(ctx) — return value
418
275
  ```js
419
- client: {
420
- render(ctx) {
421
- // ctx.entity - entity data from latest snapshot
422
- // ctx.players - array of all player states from snapshot
423
- // ctx.engine - reference to engineCtx
424
- // ctx.h - hyperscript function for UI
425
- // ctx.playerId - local player's ID
426
-
427
- return {
428
- position: ctx.entity.position, // Required
429
- rotation: ctx.entity.rotation, // Optional
430
- model: ctx.entity.model, // Optional - override model
431
- custom: ctx.entity.custom, // Optional - drives procedural mesh
432
- ui: ctx.h ? ctx.h('div', ...) : null // Optional - HTML overlay
433
- }
434
- }
435
- }
276
+ { position:[x,y,z], rotation:[x,y,z,w], model:'path.glb', custom:{...}, ui:ctx.h('div',...) }
436
277
  ```
437
278
 
438
- ### engine Object (client callbacks)
439
-
440
- Available in `setup(engine)`, `onFrame(dt, engine)`, `onInput(input, engine)`, `onEvent(payload, engine)`, `teardown(engine)`.
441
-
279
+ ### engine object
442
280
  ```js
443
- engine.THREE // THREE.js library
444
- engine.scene // THREE.Scene
445
- engine.camera // THREE.Camera
446
- engine.renderer // THREE.WebGLRenderer
447
- engine.playerId // Local player's string ID
448
- engine.client.state // { players: [...], entities: [...] } - latest snapshot
449
-
450
- engine.cam.getAimDirection(position) // Returns normalized [dx, dy, dz]
451
- engine.cam.punch(intensity) // Aim punch (visual recoil)
452
-
281
+ engine.THREE / scene / camera / renderer
282
+ engine.playerId // local player ID
283
+ engine.client.state // { players:[...], entities:[...] }
284
+ engine.cam.getAimDirection(position) // normalized [dx,dy,dz]
285
+ engine.cam.punch(intensity) // visual recoil
453
286
  engine.players.getAnimator(playerId)
454
- engine.players.setExpression(playerId, expressionName, weight)
287
+ engine.players.setExpression(playerId, name, weight)
455
288
  engine.players.setAiming(playerId, isAiming)
456
-
457
- engine.mobileControls?.registerInteractable(id, label)
458
- engine.mobileControls?.unregisterInteractable(id)
459
289
  ```
460
290
 
461
- ### ctx.h (hyperscript for UI)
462
-
291
+ ### ctx.h hyperscript
463
292
  ```js
464
- ctx.h(tagName, props, ...children)
465
- // tagName: 'div', 'span', 'button', etc.
466
- // props: object with HTML attributes and inline styles, or null
467
- // children: strings, numbers, h() calls, null (null ignored)
468
-
469
- ctx.h('div', { style: 'color:red;font-size:24px' }, 'Hello World')
470
- ctx.h('div', { class: 'hud' },
471
- ctx.h('span', null, `HP: ${hp}`),
472
- hp < 30 ? ctx.h('span', { style: 'color:red' }, 'LOW HP') : null
473
- )
293
+ ctx.h(tag, props, ...children) // props = attrs/inline styles or null; null children ignored
294
+ ctx.h('div', { style:'color:red' }, 'Hello')
474
295
  ```
296
+ Client apps cannot use `import` — all import statements stripped before evaluation. Use `engine.*` for deps.
475
297
 
476
- Client apps cannot use `import`. All dependencies come via `engine`.
477
-
478
- ### onInput(input, engine)
479
-
480
- ```js
481
- input.forward // boolean
482
- input.backward // boolean
483
- input.left // boolean
484
- input.right // boolean
485
- input.jump // boolean
486
- input.crouch // boolean
487
- input.sprint // boolean
488
- input.shoot // boolean
489
- input.reload // boolean
490
- input.interact // boolean
491
- input.yaw // number - camera yaw in radians
492
- input.pitch // number - camera pitch in radians
493
- ```
298
+ ### onInput fields
299
+ `forward backward left right jump crouch sprint shoot reload interact yaw pitch`
494
300
 
495
301
  ---
496
302
 
497
- ## App Lifecycle
303
+ ## Procedural Mesh (custom field)
498
304
 
499
- ```
500
- Server start:
501
- AppLoader.loadAll() -> registers all apps in apps/
502
- For each entity in world.entities:
503
- spawnEntity() -> _attachApp() -> server.setup(ctx)
504
-
505
- Each tick (128/sec):
506
- for each entity with app: server.update(ctx, dt)
507
- tick timers
508
- tick sphere collisions -> server.onCollision()
509
-
510
- On file save (hot reload):
511
- AppLoader detects change, queues reload
512
- End of current tick: drain queue
513
- server.teardown(ctx) [bus scope destroyed, timers cleared]
514
- new AppContext [ctx.state reference PRESERVED on entity]
515
- server.setup(ctx) [fresh context, same state data]
516
-
517
- On entity destroy:
518
- Cascade to all children
519
- server.teardown(ctx)
520
- entity removed from Map
521
-
522
- Client:
523
- Receives APP_MODULE message with app source
524
- client.setup(engine) called once
525
- Each frame: client.onFrame(dt, engine), client.render(ctx)
526
- On server message: client.onEvent(payload, engine)
527
- On hot reload: client.teardown(engine) -> location.reload()
528
- ```
529
-
530
- ---
531
-
532
- ## Entity System
533
-
534
- Entities are plain objects in a Map. Not class instances.
305
+ When no GLB set, `custom` drives geometry — primary way to create primitives without any GLB file.
535
306
 
536
307
  ```js
537
- {
538
- id: string,
539
- model: string|null,
540
- position: [x, y, z],
541
- rotation: [x, y, z, w], // quaternion
542
- scale: [x, y, z],
543
- velocity: [x, y, z],
544
- mass: 1,
545
- bodyType: 'static'|'dynamic'|'kinematic',
546
- collider: null | { type, ...params },
547
- parent: string|null,
548
- children: Set<string>,
549
- custom: any, // sent to clients in every snapshot - keep small
550
- _appState: object, // ctx.state - persists across hot reloads
551
- _appName: string|null,
552
- _config: object|null
553
- }
308
+ { mesh:'box', color:0xff8800, roughness:0.8, sx:2, sy:1, sz:2 } // sx/sy/sz = FULL dimensions
309
+ { mesh:'sphere', color:0x00ff00, r:1, seg:16 }
310
+ { mesh:'cylinder', r:0.4, h:0.1, seg:16, color:0xffd700, metalness:0.8,
311
+ emissive:0xffa000, emissiveIntensity:0.3,
312
+ light:0xffd700, lightIntensity:1, lightRange:4 }
313
+ { ..., hover:0.15, spin:1 } // Y oscillation amplitude (units), rotation (rad/sec)
314
+ { ..., glow:true, glowColor:0x00ff88, glowIntensity:0.5 }
315
+ { mesh:'box', label:'PRESS E' }
554
316
  ```
555
317
 
556
- Destroying a parent destroys all children. World transform is computed recursively up the parent chain.
318
+ **sx/sy/sz are FULL size. addBoxCollider takes HALF-extents.** `sx:4,sy:2` `addBoxCollider([2,1,...])`
557
319
 
558
320
  ---
559
321
 
560
- ## Physics API
322
+ ## AppLoader — Blocked Strings
561
323
 
562
- ### Shape Types and Methods
324
+ Any of these anywhere in source (including comments) silently prevents load, no throw:
563
325
 
564
- ```js
565
- ctx.physics.addBoxCollider(size)
566
- // size: number (uniform half-extent) or [hx, hy, hz]
567
-
568
- ctx.physics.addSphereCollider(radius)
569
-
570
- ctx.physics.addCapsuleCollider(radius, fullHeight)
571
- // fullHeight = total height. Halved internally before passing to Jolt.
572
-
573
- ctx.physics.addTrimeshCollider()
574
- // Static trimesh from entity.model. Only for static bodies.
575
-
576
- ctx.physics.addConvexCollider(points)
577
- // points: flat array [x,y,z,x,y,z,...]. Supports all motion types (dynamic/kinematic/static).
578
-
579
- ctx.physics.addConvexFromModel(meshIndex = 0)
580
- // Extracts vertices from entity.model GLB and builds ConvexHullShape. Good for dynamic vehicles/crates.
581
-
582
- ctx.physics.addForce([fx, fy, fz]) // velocity += force / mass
583
- ctx.physics.setVelocity([vx, vy, vz])
584
- ```
585
-
586
- ### Body Types
587
-
588
- - `static`: immovable, other bodies collide with it
589
- - `dynamic`: affected by gravity and forces
590
- - `kinematic`: moved by code, pushes dynamic bodies
591
-
592
- ### Jolt WASM Memory Rules
593
-
594
- Do NOT call Jolt methods directly from app code. Use `ctx.physics` only. The engine destroys all WASM heap objects internally. Every Jolt getter call and raycast creates temporary heap objects that must be destroyed - the engine handles this automatically via the ctx API.
326
+ `process.exit` `child_process` `require(` `__proto__` `Object.prototype` `globalThis` `eval(` `import(`
595
327
 
596
328
  ---
597
329
 
598
- ## EventBus
599
-
600
- Shared pub/sub system. Scoped per entity - all subscriptions auto-cleaned on teardown.
601
-
602
- ```js
603
- // Subscribe
604
- const unsub = ctx.bus.on('channel.name', (event) => {
605
- event.data // your payload
606
- event.channel // 'channel.name'
607
- event.meta // { timestamp, sourceEntity }
608
- })
609
- unsub() // manual unsubscribe if needed
610
-
611
- // One-time
612
- ctx.bus.once('channel.name', handler)
613
-
614
- // Emit (meta.sourceEntity = this entity's ID automatically)
615
- ctx.bus.emit('channel.name', { key: 'value' })
616
-
617
- // Wildcard - subscribe to prefix
618
- ctx.bus.on('combat.*', (event) => {
619
- // Receives: combat.fire, combat.hit, combat.death, etc.
620
- })
621
-
622
- // Handover - transfer state to another entity's app
623
- ctx.bus.handover(targetEntityId, stateData)
624
- // Fires: server.onHandover(ctx, sourceEntityId, stateData) on target
625
- ```
626
-
627
- ### Reserved Channels
628
-
629
- `system.*` prefix is reserved. Events on `system.*` do NOT trigger the `*` catch-all logger. Do not emit on `system.*` from app code.
630
-
631
- ### Cross-App Pattern
632
-
633
- ```js
634
- // App A (power-crate) emits:
635
- ctx.bus.emit('powerup.collected', { playerId, duration: 45, speedMultiplier: 1.2 })
636
-
637
- // App B (tps-game) subscribes:
638
- ctx.bus.on('powerup.collected', (event) => {
639
- const { playerId, duration } = event.data
640
- ctx.state.buffs.set(playerId, { expiresAt: Date.now() + duration * 1000 })
641
- })
642
- ```
643
-
644
- ---
645
-
646
- ## Message Types (Hex Reference)
647
-
648
- Apps do not use these directly. Listed for debugging and understanding the transport layer.
649
-
650
- | Name | Hex | Direction | Notes |
651
- |------|-----|-----------|-------|
652
- | HANDSHAKE | 0x01 | C→S | Initial connection |
653
- | HANDSHAKE_ACK | 0x02 | S→C | Connection accepted |
654
- | HEARTBEAT | 0x03 | C→S | Every 1000ms, 3s timeout |
655
- | HEARTBEAT_ACK | 0x04 | S→C | |
656
- | SNAPSHOT | 0x10 | S→C | Full world state |
657
- | INPUT | 0x11 | C→S | Player input |
658
- | STATE_CORRECTION | 0x12 | S→C | Physics correction |
659
- | DELTA_UPDATE | 0x13 | S→C | Delta snapshot |
660
- | PLAYER_JOIN | 0x20 | S→C | Player connected |
661
- | PLAYER_LEAVE | 0x21 | S→C | Player disconnected |
662
- | ENTITY_SPAWN | 0x30 | S→C | New entity |
663
- | ENTITY_DESTROY | 0x31 | S→C | Entity removed |
664
- | ENTITY_UPDATE | 0x32 | S→C | Entity state change |
665
- | APP_EVENT | 0x33 | S→C | ctx.players.send/broadcast payload |
666
- | HOT_RELOAD | 0x70 | S→C | Triggers location.reload() on client |
667
- | WORLD_DEF | 0x71 | S→C | World configuration |
668
- | APP_MODULE | 0x72 | S→C | Client app source code |
669
- | BUS_EVENT | 0x74 | S→C | Bus event forwarded to client |
670
-
671
- Heartbeat: any message from client resets the 3-second timeout. Client sends explicit heartbeat every 1000ms. 3s silence = disconnected.
672
-
673
- ---
674
-
675
- ## Snapshot Format
676
-
677
- Snapshots sent at tickRate (128/sec) only when players are connected.
678
-
679
- ### Player Array (positional - do not reorder)
680
-
681
- ```
682
- [0]id [1]px [2]py [3]pz [4]rx [5]ry [6]rz [7]rw [8]vx [9]vy [10]vz [11]onGround [12]health [13]inputSeq [14]crouch [15]lookPitch [16]lookYaw
683
- ```
684
-
685
- - Position precision: 2 decimal places (×100 quantization)
686
- - Rotation precision: 4 decimal places (×10000 quantization)
687
- - health: rounded integer
688
- - onGround: 1 or 0
689
- - lookPitch/lookYaw: 0-255 (8-bit encoded, full range)
690
-
691
- ### Entity Array (positional - do not reorder)
692
-
693
- ```
694
- [0]id [1]model [2]px [3]py [4]pz [5]rx [6]ry [7]rz [8]rw [9]bodyType [10]custom
695
- ```
696
-
697
- - Same position/rotation quantization as players
698
- - custom: any JSON-serializable value (null if not set)
699
-
700
- Changing field order or count breaks all clients silently (wrong positions, no error).
701
-
702
- ### Delta Snapshots
703
-
704
- Unchanged entities are omitted. Removed entities appear in a `removed` string array. Players are always fully encoded (no delta). When StageLoader is active, each player gets a different snapshot with only nearby entities (within relevanceRadius, default 200 units).
705
-
706
- ---
707
-
708
- ## Collision Detection
709
-
710
- Two separate systems run simultaneously.
711
-
712
- ### Jolt Physics (player-world, rigid bodies)
713
-
714
- Automatic. Players collide with static trimesh geometry. Dynamic bodies collide with everything. No app API needed.
715
-
716
- ### App Sphere Collisions (entity-entity)
717
-
718
- Runs every tick. For entities that have both a collider AND an attached app, sphere-overlap tests fire `onCollision`:
719
-
720
- ```js
721
- server: {
722
- setup(ctx) {
723
- ctx.physics.addSphereCollider(1.5) // Must set collider to receive events
724
- },
725
- onCollision(ctx, other) {
726
- // other: { id, position, velocity }
727
- }
728
- }
729
- ```
730
-
731
- Collision radius per shape type:
732
- - sphere: radius value
733
- - capsule: max(radius, height/2)
734
- - box: max of all half-extents
735
-
736
- This is sphere-vs-sphere approximation. Use for pickups, triggers, proximity - not precise physics.
737
-
738
- ### Player-Player Collision
739
-
740
- Custom capsule separation runs after the physics step. Engine-managed. No app API.
741
-
742
- ---
743
-
744
- ## Movement Config
745
-
746
- Quake-style movement. Defined in `world.movement`.
747
-
748
- ```js
749
- movement: {
750
- maxSpeed: 4.0, // IMPORTANT: code default is 8.0, world config overrides. Always set explicitly.
751
- groundAccel: 10.0, // Ground acceleration (applied with friction simultaneously)
752
- airAccel: 1.0, // Air acceleration (no friction in air = air strafing)
753
- friction: 6.0, // Ground friction
754
- stopSpeed: 2.0, // Min speed for friction calculation (prevents infinite decel)
755
- jumpImpulse: 4.0, // Upward velocity SET (not added) on jump
756
- crouchSpeedMul: 0.4,
757
- sprintSpeed: null // null = maxSpeed * 1.75
758
- }
759
- ```
760
-
761
- Key behavior: horizontal velocity (XZ) is wish-based. After physics step, the wish velocity overwrites the XZ physics result. Only Y velocity comes from physics (gravity/jumping). This is why `player.state.velocity[0]` and `[2]` reflect wish velocity, not physics result.
762
-
763
- ---
764
-
765
- ## Hot Reload
766
-
767
- ### What Survives
768
-
769
- `ctx.state` (stored on `entity._appState`) survives. The reference is kept on the entity across hot reloads.
770
-
771
- ### What Does NOT Survive
772
-
773
- - `ctx.time` timers (must re-register in setup)
774
- - `ctx.bus` subscriptions (must re-subscribe in setup)
775
- - Any closures
776
- - Client `this` properties (reset on location.reload)
777
-
778
- ### Hot Reload Safety Pattern
779
-
780
- ```js
781
- setup(ctx) {
782
- // Preserve existing values:
783
- ctx.state.score = ctx.state.score || 0
784
- ctx.state.data = ctx.state.data || new Map()
785
-
786
- // Always re-register (cleared on teardown):
787
- ctx.bus.on('some.event', handler)
788
- ctx.time.every(1, ticker)
789
- }
790
- ```
791
-
792
- ### Timing
793
-
794
- App reloads never happen mid-tick. Queue drains at end of each tick. After each reload, client heartbeat timers are reset for all connections to prevent disconnect during slow reloads. After 3 consecutive failures, AppLoader stops auto-reloading that module until server restart (exponential backoff: 100ms, 200ms, 400ms max).
795
-
796
- ---
797
-
798
- ## Client Rendering
799
-
800
- ### GLB Shader Stall Prevention
801
-
802
- The engine handles GPU shader warmup in two phases — no action is needed from app code:
803
-
804
- 1. **Initial load**: After the loading screen gates pass (assets, environment, first snapshot, first-snapshot entities all loaded), the loading screen hides and `warmupShaders()` runs asynchronously. It calls `renderer.compileAsync(scene, camera)`, disables frustum culling, renders twice to upload GPU data, then restores culling. This covers all entities present at startup.
805
-
806
- 2. **Post-load dynamic entities**: For GLBs added after the loading screen is hidden, `loadEntityModel` calls `renderer.compileAsync(scene, camera)` immediately after adding the mesh to the scene.
807
-
808
- VRM players use a separate one-time warmup (`_vrmWarmupDone`) that fires `renderer.compileAsync(scene, camera)` after the first player model loads.
809
-
810
- ### render(ctx) Return Value
811
-
812
- ```js
813
- return {
814
- position: [x, y, z], // Required
815
- rotation: [x, y, z, w], // Optional
816
- model: 'path.glb', // Optional - override model
817
- custom: { ... }, // Optional - drives procedural mesh rendering
818
- ui: h('div', ...) // Optional - HTML overlay
819
- }
820
- ```
821
-
822
- ### Entity custom Field - Procedural Mesh Conventions
330
+ ## Critical Caveats
823
331
 
824
- When no GLB model is set, `custom` drives procedural geometry:
332
+ **Physics only activates inside app setup().** `entity.bodyType = 'static'` does nothing without an app calling `ctx.physics.*`.
825
333
 
826
334
  ```js
827
- // Box
828
- custom: { mesh: 'box', color: 0xff8800, sx: 1, sy: 1, sz: 1 }
829
-
830
- // Sphere
831
- custom: { mesh: 'sphere', color: 0x00ff00, radius: 1 }
832
-
833
- // Cylinder
834
- custom: {
835
- mesh: 'cylinder',
836
- r: 0.4, h: 0.1, seg: 16,
837
- color: 0xffd700,
838
- roughness: 0.3, metalness: 0.8,
839
- emissive: 0xffa000, emissiveIntensity: 0.3,
840
- rotZ: Math.PI / 2,
841
- light: 0xffd700, lightIntensity: 1, lightRange: 4
842
- }
335
+ // WRONG — entity renders but players fall through:
336
+ const e = ctx.world.spawn('floor', {...}); e.bodyType = 'static' // ignored
843
337
 
844
- // Animation
845
- custom: { ..., hover: 0.15, spin: 1 }
846
- // hover: Y oscillation amplitude (units)
847
- // spin: rotation speed (radians/sec)
848
-
849
- // Glow (interaction feedback)
850
- custom: { ..., glow: true, glowColor: 0x00ff88, glowIntensity: 0.5 }
851
-
852
- // Label
853
- custom: { mesh: 'box', label: 'PRESS E' }
338
+ // CORRECT:
339
+ ctx.world.spawn('floor', { app:'box-static', config:{ hx:5, hy:0.25, hz:5 } })
854
340
  ```
855
341
 
856
- ---
857
-
858
- ## AppLoader Security Restrictions
859
-
860
- The following strings in app source (including in comments or string literals) cause silent load failure:
342
+ **maxSpeed default mismatch.** Code default is 8.0. Always set `movement.maxSpeed` explicitly.
861
343
 
862
- - `process.exit`
863
- - `child_process`
864
- - `require(`
865
- - `__proto__`
866
- - `Object.prototype`
867
- - `globalThis`
868
- - `eval(`
869
- - `import(`
344
+ **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.
870
345
 
871
- If blocked, AppLoader logs a console error and the app does not register. Entities using it spawn without a server app.
346
+ **Capsule parameter order.** `addCapsuleCollider(radius, fullHeight)` full height, halved internally. Reversed from Jolt's direct API which takes (halfHeight, radius).
872
347
 
873
- Static ES module `import` at the top of the file is fine - AppLoader uses dynamic import internally to load the file. Do NOT use dynamic `import(` inside app code.
348
+ **Trimesh is static-only.** Use `addConvexCollider` or `addConvexFromModel` for dynamic/kinematic.
874
349
 
875
- ---
876
-
877
- ## Common Patterns
350
+ **`setTimeout` not cleared on hot reload.** `ctx.time.*` IS cleared. Manage raw timers manually in teardown.
878
351
 
879
- ### Spawn entities on setup, destroy on teardown
352
+ **Destroying parent destroys all children.** Reparent first to preserve: `ctx.world.reparent(childId, null)`
880
353
 
881
- ```js
882
- server: {
883
- setup(ctx) {
884
- ctx.state.spawned = ctx.state.spawned || []
885
- if (ctx.state.spawned.length === 0) {
886
- for (let i = 0; i < 5; i++) {
887
- const id = `item_${Date.now()}_${i}`
888
- const e = ctx.world.spawn(id, { position: [i * 3, 1, 0] })
889
- if (e) {
890
- e.custom = { mesh: 'sphere', color: 0xffff00, radius: 0.5 }
891
- ctx.state.spawned.push(id)
892
- }
893
- }
894
- }
895
- },
896
- teardown(ctx) {
897
- for (const id of ctx.state.spawned || []) ctx.world.destroy(id)
898
- ctx.state.spawned = []
899
- }
900
- }
901
- ```
354
+ **setPosition teleports through walls** — physics pushes out next tick.
902
355
 
903
- ### Raycast to find ground spawn points
356
+ **App sphere collision is O(n²).** Keep interactive entity count under ~50.
904
357
 
905
- ```js
906
- function findSpawnPoints(ctx) {
907
- const points = []
908
- for (let x = -50; x <= 50; x += 10) {
909
- for (let z = -50; z <= 50; z += 10) {
910
- const hit = ctx.raycast([x, 30, z], [0, -1, 0], 40)
911
- if (hit.hit && hit.position[1] > -5) {
912
- points.push([x, hit.position[1] + 2, z])
913
- }
914
- }
915
- }
916
- if (points.length < 4) points.push([0, 5, 0], [10, 5, 10])
917
- return points
918
- }
919
- ```
358
+ **Snapshots only sent when players > 0.** Entity state still updates, nothing broadcast.
920
359
 
921
- ### Player join/leave handling
360
+ **TickSystem max 4 steps per loop.** >4 ticks behind (~31ms at 128TPS) = silent drop.
922
361
 
362
+ **Player join/leave arrive via onMessage:**
923
363
  ```js
924
364
  onMessage(ctx, msg) {
925
365
  if (!msg) return
926
366
  const pid = msg.playerId || msg.senderId
927
- if (msg.type === 'player_join') {
928
- ctx.state.scores = ctx.state.scores || new Map()
929
- ctx.state.scores.set(pid, 0)
930
- }
931
- if (msg.type === 'player_leave') {
932
- ctx.state.scores?.delete(pid)
933
- }
934
- }
935
- ```
936
-
937
- ### Interact detection with cooldown
938
-
939
- Use `ctx.interactable()` — the engine handles proximity, prompt UI, and cooldown automatically.
940
-
941
- ```js
942
- setup(ctx) {
943
- ctx.interactable({ prompt: 'Press E', radius: 4, cooldown: 500 })
944
- },
945
-
946
- onInteract(ctx, player) {
947
- ctx.players.send(player.id, { type: 'interact_response', message: 'Hello!' })
948
- ctx.network.broadcast({ type: 'interact_effect', position: ctx.entity.position })
949
- }
950
- ```
951
-
952
- ### Area damage hazard
953
-
954
- ```js
955
- update(ctx, dt) {
956
- ctx.state.damageTimer = (ctx.state.damageTimer || 0) - dt
957
- if (ctx.state.damageTimer > 0) return
958
- ctx.state.damageTimer = 0.5
959
- const radius = ctx.config.radius || 3
960
- for (const player of ctx.players.getAll()) {
961
- if (!player.state) continue
962
- const pp = player.state.position
963
- const dist = Math.hypot(
964
- pp[0] - ctx.entity.position[0],
965
- pp[1] - ctx.entity.position[1],
966
- pp[2] - ctx.entity.position[2]
967
- )
968
- if (dist < radius) {
969
- player.state.health = Math.max(0, (player.state.health || 100) - 10)
970
- ctx.players.send(player.id, { type: 'hazard_damage', damage: 10 })
971
- }
972
- }
973
- }
974
- ```
975
-
976
- ### Moving platform
977
-
978
- ```js
979
- update(ctx, dt) {
980
- const s = ctx.state
981
- if (!s.waypoints || s.waypoints.length < 2) return
982
- s.waitTimer = (s.waitTimer || 0) - dt
983
- if (s.waitTimer > 0) return
984
- const wp = s.waypoints[s.wpIndex || 0]
985
- const next = s.waypoints[((s.wpIndex || 0) + 1) % s.waypoints.length]
986
- const dx = next[0] - ctx.entity.position[0]
987
- const dy = next[1] - ctx.entity.position[1]
988
- const dz = next[2] - ctx.entity.position[2]
989
- const dist = Math.sqrt(dx*dx + dy*dy + dz*dz)
990
- if (dist < 0.1) {
991
- s.wpIndex = ((s.wpIndex || 0) + 1) % s.waypoints.length
992
- s.waitTimer = s.waitTime || 1
993
- return
994
- }
995
- const step = Math.min((s.speed || 5) * dt, dist)
996
- ctx.entity.position[0] += (dx/dist) * step
997
- ctx.entity.position[1] += (dy/dist) * step
998
- ctx.entity.position[2] += (dz/dist) * step
999
- }
1000
- ```
1001
-
1002
- ### Client UI with custom HUD
1003
-
1004
- For interaction prompts, use `ctx.interactable()` on the server — the engine renders the prompt automatically. For custom HUD elements (health bars, messages), use `render()`:
1005
-
1006
- ```js
1007
- client: {
1008
- onEvent(payload) {
1009
- if (payload.type === 'interact_response') { this._msg = payload.message; this._expire = Date.now() + 3000 }
1010
- },
1011
-
1012
- render(ctx) {
1013
- const h = ctx.h
1014
- if (!h) return { position: ctx.entity.position }
1015
- return {
1016
- position: ctx.entity.position,
1017
- custom: ctx.entity.custom,
1018
- ui: this._msg && Date.now() < this._expire
1019
- ? h('div', { style: 'position:fixed;top:30%;left:50%;transform:translate(-50%,-50%);padding:16px 32px;background:rgba(0,0,0,0.8);border-radius:12px;color:#0f0;font-weight:bold' }, this._msg)
1020
- : null
1021
- }
1022
- }
1023
- }
1024
- ```
1025
-
1026
- ### Client health HUD
1027
-
1028
- ```js
1029
- client: {
1030
- render(ctx) {
1031
- const h = ctx.h
1032
- if (!h) return { position: ctx.entity.position }
1033
- const local = ctx.players?.find(p => p.id === ctx.engine?.playerId)
1034
- const hp = local?.health ?? 100
1035
- return {
1036
- position: ctx.entity.position,
1037
- ui: h('div', { style: 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);width:200px;height:20px;background:#333;border-radius:4px;overflow:hidden' },
1038
- h('div', { style: `width:${hp}%;height:100%;background:${hp > 60 ? '#0f0' : hp > 30 ? '#ff0' : '#f00'};transition:width 0.2s` }),
1039
- h('span', { style: 'position:absolute;width:100%;text-align:center;color:#fff;font-size:12px;line-height:20px' }, String(hp))
1040
- )
1041
- }
1042
- }
367
+ if (msg.type === 'player_join') { /* ... */ }
368
+ if (msg.type === 'player_leave') { /* ... */ }
1043
369
  }
1044
370
  ```
1045
371
 
1046
- ### Cross-app EventBus communication
1047
-
1048
- ```js
1049
- // Emitter app setup:
1050
- ctx.bus.emit('combat.fire', { shooterId, origin, direction })
1051
-
1052
- // Listener app setup:
1053
- ctx.bus.on('combat.fire', (event) => {
1054
- const { shooterId, origin, direction } = event.data
1055
- // handle shot...
1056
- })
1057
- ctx.bus.on('combat.*', (event) => {
1058
- // catches combat.fire, combat.hit, combat.kill, etc.
1059
- })
1060
- ```
1061
-
1062
- ---
1063
-
1064
- ## Critical Caveats
1065
-
1066
- ### ctx.state survives hot reload; timers and bus subscriptions do not
1067
-
1068
- Re-register all timers and bus subscriptions in `setup`. Use `||` to preserve state:
1069
-
1070
- ```js
1071
- setup(ctx) {
1072
- ctx.state.counter = ctx.state.counter || 0 // preserved
1073
- ctx.bus.on('event', handler) // re-registered fresh
1074
- ctx.time.every(1, ticker) // re-registered fresh
1075
- }
1076
- ```
1077
-
1078
- ### Snapshot field order is fixed and positional
1079
-
1080
- Player arrays and entity arrays use positional indexing. Changing the order or count of fields breaks all clients silently (wrong positions, no error thrown).
1081
-
1082
- ### maxSpeed default mismatch
1083
-
1084
- `DEFAULT_MOVEMENT.maxSpeed` in the movement code is 8.0. World config overrides this. The example world config uses 4.0. Always set `movement.maxSpeed` explicitly in world config.
1085
-
1086
- ### Horizontal velocity is wish-based, not physics-based
1087
-
1088
- After the physics step, wish velocity overwrites XZ physics result. `player.state.velocity[0]` and `[2]` are the wish velocity. Only `velocity[1]` (Y) comes from physics. This means horizontal movement ignores physics forces.
1089
-
1090
- ### CharacterVirtual gravity must be applied manually
1091
-
1092
- The engine manually applies `gravity[1] * dt` to Y velocity. This is already handled. If you override `player.state.velocity[1]`, gravity still accumulates on top next tick.
1093
-
1094
- ### Capsule parameter order
1095
-
1096
- `addCapsuleCollider(radius, fullHeight)` - full height, not half height. The API divides by 2 internally. This differs from Jolt's direct API which takes (halfHeight, radius).
1097
-
1098
- ### Trimesh colliders are static only
1099
-
1100
- `addTrimeshCollider()` creates a static mesh. No dynamic or kinematic trimesh support.
1101
-
1102
- ### Convex hull for dynamic objects
1103
-
1104
- Use `addConvexCollider(points)` or `addConvexFromModel()` for dynamic/kinematic bodies that need shape-accurate physics (vehicles, crates). Convex hulls support all motion types unlike trimesh. `addConvexFromModel()` reads vertices from the entity's GLB at setup time - call it after setting `entity.model`.
1105
-
1106
- ### Animation library uses two-phase cache
1107
-
1108
- `preloadAnimationLibrary()` kicks off the `/anim-lib.glb` fetch and caches the promise (`_gltfPromise`). `loadAnimationLibrary(vrmVersion, vrmHumanoid)` awaits that fetch and caches the normalized clip result (`_normalizedCache`). The engine calls `preloadAnimationLibrary()` early during asset init so the GLB is already fetching while the VRM downloads. Subsequent calls to `loadAnimationLibrary()` return the normalized cache immediately. Both functions are idempotent and safe to call concurrently.
1109
-
1110
- ### Tick drops under load
1111
-
1112
- TickSystem processes max 4 ticks per loop. If the server falls more than 4 ticks behind (31ms at 128 TPS), those ticks are dropped silently.
1113
-
1114
- ### Snapshots not sent with zero players
1115
-
1116
- `if (players.length > 0)` guards snapshot creation. Entity state still updates when no players are connected but nothing is broadcast.
1117
-
1118
- ### Collision detection is O(n²)
1119
-
1120
- `_tickCollisions` runs sphere-vs-sphere for all entities with both a collider and an app. Keep interactive entity count under ~50 for this to be cheap.
1121
-
1122
- ### AppLoader blocks entire file on pattern match
1123
-
1124
- If any blocked string (including in comments) appears anywhere in the source, the app silently fails to load. No throw, only console error.
1125
-
1126
- ### Client apps cannot use import statements
1127
-
1128
- All `import` statements in client app source are stripped by regex before evaluation. Use `engine.THREE`, `engine.scene`, etc. for all dependencies.
1129
-
1130
- ### GLB/VRM assets are cached in IndexedDB
1131
-
1132
- On repeat page loads, `fetchCached()` in `client/ModelCache.js` validates cached GLB/VRM ArrayBuffers against the server ETag via a HEAD request. If the ETag matches, the cached bytes are returned without a network fetch. Cache misses or stale entries trigger a full fetch and re-store. Cache failures (quota, unavailable) fall back to normal fetch transparently. This is fully automatic — no app code needed.
1133
-
1134
- ### Loading screen hides before shader warmup completes
1135
-
1136
- After the four gate conditions pass, the loading screen hides immediately. `warmupShaders()` then runs asynchronously in the background. The very first rendered frame after the loading screen hides may have a brief GPU stall if shader compilation is not yet complete. This is a deliberate tradeoff to avoid the loading screen adding warmup time on top of actual asset loading.
1137
-
1138
- ### setTimeout not cleared on hot reload
1139
-
1140
- `ctx.time.after/every` timers are cleared on teardown. `setTimeout` and `setInterval` are NOT. Use `ctx.time` for game logic. Use `setTimeout` only for external timing (e.g., reload cooldown) and manage cleanup in teardown manually.
1141
-
1142
- ### Entity children destroyed with parent
1143
-
1144
- `destroyEntity` recursively destroys all children. Reparent first if you need to preserve a child: `ctx.world.reparent(childId, null)`.
1145
-
1146
- ### setPosition teleports through walls
1147
-
1148
- `ctx.players.setPosition` directly sets physics body position with no collision check. The physics solver pushes out on the next tick, which may look jarring.
1149
-
1150
372
  ---
1151
373
 
1152
374
  ## Debug Globals
1153
375
 
1154
376
  ```
1155
- Server: globalThis.__DEBUG__.server (Node REPL)
1156
- Client: window.debug (browser console)
1157
- window.debug.scene, camera, renderer, client, players, input
377
+ Server (Node REPL): globalThis.__DEBUG__.server
378
+ Client (browser): window.debug → scene, camera, renderer, client, players, input
1158
379
  ```