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 +216 -995
- package/apps/arena/index.js +83 -0
- package/apps/box-static/index.js +24 -0
- package/apps/prop-static/index.js +15 -0
- package/apps/world/index.js +2 -0
- package/package.json +1 -1
package/SKILL.md
CHANGED
|
@@ -1,80 +1,76 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: spoint
|
|
3
|
-
description:
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
##
|
|
20
|
+
## Quick Start — Minimal Working Arena
|
|
52
21
|
|
|
53
|
-
|
|
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) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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.
|
|
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,
|
|
95
|
-
groundAccel: 10.0,
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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: [
|
|
112
|
+
spawnPoint: [0,2,0]
|
|
167
113
|
}
|
|
168
114
|
```
|
|
169
115
|
|
|
170
116
|
---
|
|
171
117
|
|
|
172
|
-
##
|
|
118
|
+
## Asset Loading Gate
|
|
173
119
|
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
+
## Remote Models — Verified Filenames
|
|
193
132
|
|
|
194
|
-
|
|
133
|
+
URL: `https://raw.githubusercontent.com/anEntrypoint/assets/main/FILENAME.glb`
|
|
195
134
|
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
ctx.
|
|
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
|
-
|
|
163
|
+
---
|
|
212
164
|
|
|
213
|
-
|
|
165
|
+
## Server ctx API
|
|
214
166
|
|
|
167
|
+
### ctx.entity
|
|
215
168
|
```js
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
//
|
|
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.
|
|
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.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
+
Read-only. Set in world: `{ id:'x', app:'y', config:{ radius:5 } }` → `ctx.config.radius`
|
|
242
189
|
|
|
243
|
-
### ctx.
|
|
190
|
+
### ctx.interactable
|
|
244
191
|
|
|
192
|
+
Engine handles proximity, E-key prompt, and cooldown. App only needs `onInteract`:
|
|
245
193
|
```js
|
|
246
|
-
ctx.
|
|
247
|
-
ctx.
|
|
248
|
-
|
|
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.
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
ctx.physics.
|
|
261
|
-
|
|
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)
|
|
284
|
-
ctx.world.query(filterFn)
|
|
285
|
-
ctx.world.nearby(pos, radius)
|
|
286
|
-
ctx.world.reparent(eid, parentId)
|
|
287
|
-
ctx.world.attach(entityId, appName)
|
|
288
|
-
ctx.world.
|
|
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
|
-
|
|
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
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
ctx.players.
|
|
315
|
-
//
|
|
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
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
369
|
-
ctx.time.
|
|
370
|
-
ctx.time.
|
|
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.
|
|
373
|
-
|
|
374
|
-
|
|
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
|
|
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
|
|
415
|
-
|
|
416
|
-
### render(ctx)
|
|
272
|
+
## Client API
|
|
417
273
|
|
|
274
|
+
### render(ctx) — return value
|
|
418
275
|
```js
|
|
419
|
-
|
|
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
|
|
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
|
|
444
|
-
engine.
|
|
445
|
-
engine.
|
|
446
|
-
engine.
|
|
447
|
-
engine.
|
|
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,
|
|
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
|
|
462
|
-
|
|
291
|
+
### ctx.h — hyperscript
|
|
463
292
|
```js
|
|
464
|
-
ctx.h(
|
|
465
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
322
|
+
## AppLoader — Blocked Strings
|
|
561
323
|
|
|
562
|
-
|
|
324
|
+
Any of these anywhere in source (including comments) silently prevents load, no throw:
|
|
563
325
|
|
|
564
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
332
|
+
**Physics only activates inside app setup().** `entity.bodyType = 'static'` does nothing without an app calling `ctx.physics.*`.
|
|
825
333
|
|
|
826
334
|
```js
|
|
827
|
-
//
|
|
828
|
-
|
|
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
|
-
//
|
|
845
|
-
|
|
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
|
-
- `
|
|
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
|
-
|
|
346
|
+
**Capsule parameter order.** `addCapsuleCollider(radius, fullHeight)` — full height, halved internally. Reversed from Jolt's direct API which takes (halfHeight, radius).
|
|
872
347
|
|
|
873
|
-
|
|
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
|
-
|
|
352
|
+
**Destroying parent destroys all children.** Reparent first to preserve: `ctx.world.reparent(childId, null)`
|
|
880
353
|
|
|
881
|
-
|
|
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
|
-
|
|
356
|
+
**App sphere collision is O(n²).** Keep interactive entity count under ~50.
|
|
904
357
|
|
|
905
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1156
|
-
Client:
|
|
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
|
```
|