spoint 0.1.15 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/SKILL.md +1052 -40
  2. package/package.json +1 -1
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
- # spoint
6
+ # Spawnpoint App Development Reference
7
7
 
8
- You are helping a developer work with the spoint multiplayer game server SDK.
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
- ## Setup (First Time)
10
+ ---
11
+
12
+ ## Setup
11
13
 
12
- When no `apps/` directory exists in the current working directory, scaffold it:
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 (port 3001, 128 TPS)
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
- ## App Structure
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
- Apps live in `apps/<name>/index.js` and export a default object:
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
- ctx.entity.custom = { mesh: 'box', color: 0x00ff00 }
47
- ctx.physics.setStatic(true)
48
- ctx.physics.addBoxCollider([0.5, 0.5, 0.5])
49
- },
50
- update(ctx, dt) {},
51
- teardown(ctx) {}
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
- render(ctx) {
55
- return { position: ctx.entity.position, rotation: ctx.entity.rotation, custom: ctx.entity.custom }
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
- ## World Config
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
- Edit `apps/world/index.js` to configure port, tickRate, gravity, movement, entities, and scene. The `entities` array auto-spawns apps on start.
449
+ ### onInput(input, engine)
64
450
 
65
- ## Key Facts
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
- - Engine files (src/, client/) come from the npm package - never edit them
68
- - Only `apps/` is local to the user's project (their CWD)
69
- - `ctx.state` survives hot reload; timers and bus subscriptions do not
70
- - 128 TPS server, 60Hz client input, exponential lerp interpolation
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
- ## Physics
533
+ ### Shape Types and Methods
73
534
 
74
535
  ```js
75
- // Static
76
- ctx.physics.setStatic(true)
77
- ctx.physics.addBoxCollider([halfX, halfY, halfZ])
536
+ ctx.physics.addBoxCollider(size)
537
+ // size: number (uniform half-extent) or [hx, hy, hz]
538
+
539
+ ctx.physics.addSphereCollider(radius)
78
540
 
79
- // Dynamic
80
- ctx.physics.setDynamic(true)
81
- ctx.physics.setMass(5)
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.bus.on('combat.hit', (data) => {})
88
- ctx.bus.emit('combat.hit', { damage: 10 })
89
- // Wildcard: 'combat.*' catches all combat.* events
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
- ## Debugging
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
- - Server: `globalThis.__DEBUG__.server` in Node REPL
95
- - Client: `window.debug` in browser console (exposes scene, camera, renderer, client)
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
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "main": "src/index.js",