spoint 0.1.49 → 0.1.51

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