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