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.
- package/SKILL.md +187 -1214
- package/package.json +1 -1
package/SKILL.md
CHANGED
|
@@ -1,1406 +1,379 @@
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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,
|
|
36
|
+
const HALF = 12, WH = 3, WT = 0.5
|
|
39
37
|
const WALLS = [
|
|
40
|
-
{ id:
|
|
41
|
-
{ id:
|
|
42
|
-
{ id:
|
|
43
|
-
{ id:
|
|
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
|
-
###
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
##
|
|
234
|
-
|
|
235
|
-
When no `apps/` directory exists in the working directory, scaffold it:
|
|
118
|
+
## Asset Loading Gate
|
|
236
119
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
bunx spoint # start server
|
|
246
|
-
```
|
|
247
|
-
|
|
248
|
-
Open http://localhost:3001 in browser. Apps hot-reload on file save.
|
|
129
|
+
---
|
|
249
130
|
|
|
250
|
-
|
|
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
|
-
|
|
135
|
+
**Never guess filenames** — wrong URLs silently 404, no error.
|
|
260
136
|
|
|
261
137
|
```
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
401
|
-
ctx.entity.
|
|
402
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
446
|
-
|
|
192
|
+
Engine handles proximity, E-key prompt, and cooldown. App only needs `onInteract`:
|
|
447
193
|
```js
|
|
448
|
-
setup(ctx) {
|
|
449
|
-
|
|
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)
|
|
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.
|
|
479
|
-
//
|
|
480
|
-
|
|
481
|
-
ctx.physics.
|
|
482
|
-
|
|
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)
|
|
505
|
-
ctx.world.query(filterFn)
|
|
506
|
-
ctx.world.nearby(pos, radius)
|
|
507
|
-
ctx.world.reparent(eid, parentId)
|
|
508
|
-
ctx.world.attach(entityId, appName)
|
|
509
|
-
ctx.world.
|
|
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
|
-
|
|
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
|
-
//
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
ctx.players.
|
|
536
|
-
//
|
|
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
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
|
590
|
-
ctx.time.
|
|
591
|
-
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
|
+
```
|
|
592
253
|
|
|
593
|
-
ctx.
|
|
594
|
-
|
|
595
|
-
|
|
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
|
|
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
|
|
636
|
-
|
|
637
|
-
### render(ctx)
|
|
272
|
+
## Client API
|
|
638
273
|
|
|
274
|
+
### render(ctx) — return value
|
|
639
275
|
```js
|
|
640
|
-
|
|
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
|
|
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
|
|
665
|
-
engine.
|
|
666
|
-
engine.
|
|
667
|
-
engine.
|
|
668
|
-
engine.
|
|
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,
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
---
|
|
780
|
-
|
|
781
|
-
## Physics API
|
|
782
|
-
|
|
783
|
-
### Shape Types and Methods
|
|
784
|
-
|
|
291
|
+
### ctx.h — hyperscript
|
|
785
292
|
```js
|
|
786
|
-
ctx.
|
|
787
|
-
|
|
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
|
-
###
|
|
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
|
-
##
|
|
303
|
+
## Procedural Mesh (custom field)
|
|
820
304
|
|
|
821
|
-
|
|
305
|
+
When no GLB set, `custom` drives geometry — primary way to create primitives without any GLB file.
|
|
822
306
|
|
|
823
307
|
```js
|
|
824
|
-
//
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
})
|
|
830
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
322
|
+
## AppLoader — Blocked Strings
|
|
897
323
|
|
|
898
|
-
|
|
324
|
+
Any of these anywhere in source (including comments) silently prevents load, no throw:
|
|
899
325
|
|
|
900
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
332
|
+
**Physics only activates inside app setup().** `entity.bodyType = 'static'` does nothing without an app calling `ctx.physics.*`.
|
|
1046
333
|
|
|
1047
334
|
```js
|
|
1048
|
-
//
|
|
1049
|
-
|
|
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
|
+
// WRONG — entity renders but players fall through:
|
|
336
|
+
const e = ctx.world.spawn('floor', {...}); e.bodyType = 'static' // ignored
|
|
1069
337
|
|
|
1070
|
-
//
|
|
1071
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
346
|
+
**Capsule parameter order.** `addCapsuleCollider(radius, fullHeight)` — full height, halved internally. Reversed from Jolt's direct API which takes (halfHeight, radius).
|
|
1084
347
|
|
|
1085
|
-
|
|
348
|
+
**Trimesh is static-only.** Use `addConvexCollider` or `addConvexFromModel` for dynamic/kinematic.
|
|
1086
349
|
|
|
1087
|
-
|
|
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
|
-
|
|
352
|
+
**Destroying parent destroys all children.** Reparent first to preserve: `ctx.world.reparent(childId, null)`
|
|
1097
353
|
|
|
1098
|
-
|
|
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
|
-
|
|
358
|
+
**Snapshots only sent when players > 0.** Entity state still updates, nothing broadcast.
|
|
1103
359
|
|
|
1104
|
-
|
|
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
|
-
|
|
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
|
|
1404
|
-
Client:
|
|
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
|
```
|