@crowdedkingdomstudios/crowdyjs 2.1.2 → 4.0.0

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 (72) hide show
  1. package/MIGRATION.md +26 -0
  2. package/README.md +155 -749
  3. package/dist/auth-state.d.ts +6 -16
  4. package/dist/auth-state.d.ts.map +1 -1
  5. package/dist/auth-state.js +9 -26
  6. package/dist/client.d.ts +14 -5
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +24 -17
  9. package/dist/crowdy-client.d.ts +62 -16
  10. package/dist/crowdy-client.d.ts.map +1 -1
  11. package/dist/crowdy-client.js +70 -28
  12. package/dist/domains/apps.d.ts +48 -20
  13. package/dist/domains/apps.d.ts.map +1 -1
  14. package/dist/domains/apps.js +58 -35
  15. package/dist/domains/auth.d.ts +33 -22
  16. package/dist/domains/auth.d.ts.map +1 -1
  17. package/dist/domains/auth.js +51 -33
  18. package/dist/domains/serverStatus.d.ts +2 -1
  19. package/dist/domains/serverStatus.d.ts.map +1 -1
  20. package/dist/domains/serverStatus.js +5 -1
  21. package/dist/domains/udp.d.ts +28 -3
  22. package/dist/domains/udp.d.ts.map +1 -1
  23. package/dist/domains/udp.js +52 -1
  24. package/dist/domains/users.d.ts +19 -16
  25. package/dist/domains/users.d.ts.map +1 -1
  26. package/dist/domains/users.js +21 -39
  27. package/dist/errors.d.ts +42 -0
  28. package/dist/errors.d.ts.map +1 -0
  29. package/dist/errors.js +42 -0
  30. package/dist/generated/graphql.d.ts +1473 -11
  31. package/dist/generated/graphql.d.ts.map +1 -1
  32. package/dist/generated/graphql.js +17 -8
  33. package/dist/index.d.ts +37 -18
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +37 -20
  36. package/dist/logger.d.ts +8 -0
  37. package/dist/logger.d.ts.map +1 -0
  38. package/dist/logger.js +1 -0
  39. package/dist/realtime.d.ts +89 -0
  40. package/dist/realtime.d.ts.map +1 -0
  41. package/dist/realtime.js +273 -0
  42. package/dist/session.d.ts +27 -0
  43. package/dist/session.d.ts.map +1 -0
  44. package/dist/session.js +61 -0
  45. package/dist/subscriptions.d.ts +1 -48
  46. package/dist/subscriptions.d.ts.map +1 -1
  47. package/dist/subscriptions.js +1 -192
  48. package/dist/types.d.ts +2 -31
  49. package/dist/types.d.ts.map +1 -1
  50. package/dist/types.js +5 -33
  51. package/dist/utils.d.ts +12 -0
  52. package/dist/utils.d.ts.map +1 -0
  53. package/dist/utils.js +50 -0
  54. package/dist/world.d.ts +44 -0
  55. package/dist/world.d.ts.map +1 -0
  56. package/dist/world.js +105 -0
  57. package/package.json +12 -3
  58. package/dist/domains/appAccess.d.ts +0 -23
  59. package/dist/domains/appAccess.d.ts.map +0 -1
  60. package/dist/domains/appAccess.js +0 -42
  61. package/dist/domains/billing.d.ts +0 -17
  62. package/dist/domains/billing.d.ts.map +0 -1
  63. package/dist/domains/billing.js +0 -31
  64. package/dist/domains/organizations.d.ts +0 -33
  65. package/dist/domains/organizations.d.ts.map +0 -1
  66. package/dist/domains/organizations.js +0 -90
  67. package/dist/domains/payments.d.ts +0 -20
  68. package/dist/domains/payments.d.ts.map +0 -1
  69. package/dist/domains/payments.js +0 -28
  70. package/dist/domains/quotas.d.ts +0 -20
  71. package/dist/domains/quotas.d.ts.map +0 -1
  72. package/dist/domains/quotas.js +0 -34
package/README.md CHANGED
@@ -1,818 +1,224 @@
1
- # CrowdyJS SDK
1
+ # CrowdyJS
2
2
 
3
- Client SDK for the Crowded Kingdoms GraphQL API with UDP proxy support.
4
- Handles authentication, real-time subscriptions, and all replication-server
5
- communication through a single `CrowdyClient` instance.
3
+ The official browser-first TypeScript SDK for **Crowded Kingdoms**. CrowdyJS gives you one typed client that handles auth, the world/replication GraphQL API, and the UDP proxy subscription stream behind a single shared session.
6
4
 
7
- ## Links
8
-
9
- - [NPM](https://www.npmjs.com/package/@crowdedkingdomstudios/crowdyjs)
10
- - [GitHub](https://github.com/CrowdedKingdoms/CrowdyJS)
11
- - [Discord](https://discord.gg/crowdedkingdoms)
12
- - [YouTube](https://www.youtube.com/@CrowdedKingdoms)
13
-
14
-
15
- ## Installation
5
+ ## Install
16
6
 
17
7
  ```bash
18
8
  npm install @crowdedkingdomstudios/crowdyjs
19
9
  ```
20
10
 
21
- ### Node.js
22
-
23
- The SDK uses the browser-native `WebSocket` API. In Node.js you need a
24
- polyfill such as the `ws` package:
25
-
26
- ```bash
27
- npm install ws
28
- ```
29
-
30
- ```javascript
31
- import WebSocket from 'ws';
32
- globalThis.WebSocket = WebSocket;
33
- ```
34
-
35
- Place this **before** importing `CrowdyClient`.
36
-
37
- ### Browser
38
-
39
- No extra setup needed -- the SDK uses the built-in `WebSocket`.
40
-
41
- ## Quick Start
42
-
43
- ```javascript
44
- import { CrowdyClient } from '@crowdedkingdomstudios/crowdyjs';
45
-
46
- const client = new CrowdyClient({
47
- graphqlEndpoint: 'https://your-server.com/graphql',
48
- wsEndpoint: 'wss://your-server.com/graphql',
49
- });
50
- ```
51
-
52
- If omitted, both endpoints default to `localhost:3000/graphql`.
53
-
54
- ## Connection Lifecycle
55
-
56
- The SDK follows a four-step lifecycle that matches the server protocol:
57
-
58
- ```
59
- 1. Login --> obtain a game token
60
- 2. Subscribe --> auto-opens UDP proxy session
61
- 3. Register --> tell the game server your chunk position
62
- 4. Send updates --> replicated to other clients in range
63
- ```
64
-
65
- ### 1. Login
66
-
67
- ```javascript
68
- const auth = await client.login('user@example.com', 'password');
69
- // auth.token -- 64-char hex game token (set automatically)
70
- // auth.user.email -- logged-in user
71
- ```
72
-
73
- Or register a new account:
74
-
75
- ```javascript
76
- const auth = await client.register('user@example.com', 'password', 'MyGamertag');
77
- ```
78
-
79
- ### 2. Subscribe to Notifications
80
-
81
- Register one or more notification handlers. The first handler automatically
82
- opens a WebSocket subscription and a UDP proxy session to the game server --
83
- no explicit `connectUdpProxy()` call is needed.
84
-
85
- ```javascript
86
- const unsub = client.onActorUpdate((notification) => {
87
- console.log('Actor:', notification.uuid);
88
- console.log('State:', notification.state);
89
- console.log('Chunk:', notification.chunkX, notification.chunkY, notification.chunkZ);
90
- console.log('Time:', notification.epochMillis);
91
- });
92
- ```
93
-
94
- Handlers are unsubscribed by calling the returned function:
95
-
96
- ```javascript
97
- unsub(); // stop receiving ActorUpdateNotification
98
- ```
99
-
100
- When all handlers are removed the WebSocket is closed automatically.
101
-
102
- ### 3. Register in a Chunk
103
-
104
- Before other clients can see you, send an initial actor update so the game
105
- server knows which chunk you occupy. Use a minimal base64 payload (the
106
- server requires a non-empty `state`).
107
-
108
- #### Generating a UUID
109
-
110
- Every client must create its own UUID as a **random 32-byte UTF-8 string**.
111
- This ensures each client produces a globally unique identifier without
112
- relying on a central registry.
113
-
114
- ```javascript
115
- // Node.js
116
- const MY_UUID = crypto.randomBytes(32).toString('hex').slice(0, 32);
117
-
118
- // Browser
119
- const MY_UUID = Array.from(crypto.getRandomValues(new Uint8Array(16)))
120
- .map((b) => b.toString(16).padStart(2, '0'))
121
- .join('');
122
- ```
123
-
124
- ```javascript
125
- await client.sendActorUpdate({
126
- appId: 0,
127
- chunk: { x: 0, y: 0, z: 0 },
128
- distance: 8,
129
- uuid: MY_UUID,
130
- state: 'AA==', // minimal base64 payload for registration
131
- sequenceNumber: 1,
132
- });
133
- ```
134
-
135
- Every actor in every client must do this. After registration, the game
136
- server fans out subsequent updates to all registered clients in range.
137
-
138
- ### 4. Send Actor Updates
139
-
140
- ```javascript
141
- // Build your binary state and base64-encode it
142
- const stateBuffer = new ArrayBuffer(96);
143
- const view = new DataView(stateBuffer);
144
- view.setFloat32(0, posX, true);
145
- view.setFloat32(4, posY, true);
146
- view.setFloat32(8, posZ, true);
147
- // ... fill remaining fields
148
-
149
- const base64State = btoa(String.fromCharCode(...new Uint8Array(stateBuffer)));
150
-
151
- await client.sendActorUpdate({
152
- appId: 0,
153
- chunk: { x: 0, y: 0, z: 0 },
154
- distance: 8,
155
- decayRate: 0,
156
- uuid: MY_UUID,
157
- state: base64State,
158
- sequenceNumber: 2,
159
- });
160
- ```
161
-
162
- ### 5. Disconnect
163
-
164
- ```javascript
165
- await client.disconnectUdpProxy(); // release the UDP session
166
- client.close(); // close WebSocket + clear state
167
- ```
168
-
169
- Unsubscribing from notifications stops delivery but does **not** release
170
- the UDP session. Call `disconnectUdpProxy()` explicitly, or the server
171
- will release it after 30 seconds of inactivity.
172
-
173
- ## Request / Notification Mapping
174
-
175
- Every accepted spatial request is fanned out by the game server as a
176
- notification to all clients in range, **including the sender**. Treat
177
- your own arrival as the round-trip echo for that send (correlate by
178
- `uuid` + `sequenceNumber`).
179
-
180
- | Mutation (you send) | Notification (everyone in range receives) |
181
- |-----------------------------|-------------------------------------------|
182
- | `sendActorUpdate(...)` | `ActorUpdateNotification` |
183
- | `sendVoxelUpdate(...)` | `VoxelUpdateNotification` |
184
- | `sendAudioPacket(...)` | `ClientAudioNotification` |
185
- | `sendTextPacket(...)` | `ClientTextNotification` |
186
- | `sendClientEvent(...)` | `ClientEventNotification` |
187
- | *(generic spatial)* | *(generic spatial)* |
188
-
189
- `ServerEventNotification` is server-originated (no matching client
190
- mutation). `GenericErrorResponse` carries failures for any of the above
191
- — correlate to the offending send via `sequenceNumber`. The legacy
192
- `ActorUpdateResponse` and `VoxelUpdateResponse` ack types are retired
193
- on the wire; rely on the self-fanout notification (and
194
- `GenericErrorResponse` for failures) instead.
195
-
196
- > *Generic spatial* refers to wire type `GENERIC_SPATIAL_1` (an arbitrary
197
- > client-defined payload routed through the same spatial fan-out as the
198
- > typed messages above). It is not currently exposed by this SDK.
199
-
200
- ## Subscription Handlers
201
-
202
- All spatial notification types share a uniform header:
203
-
204
- | Field | Type | Description |
205
- |-------|------|-------------|
206
- | `appId` | `string` | App ID (tenant / world identifier) |
207
- | `chunkX` | `string` | Chunk X coordinate |
208
- | `chunkY` | `string` | Chunk Y coordinate |
209
- | `chunkZ` | `string` | Chunk Z coordinate |
210
- | `distance` | `number` | Replication distance (0-8) |
211
- | `decayRate` | `number` | Delivery decay (0-5) |
212
- | `uuid` | `string` | Random 32-byte UTF-8 sender UUID |
213
- | `sequenceNumber` | `number` | uint8 (0-255), wraps |
214
- | `epochMillis` | `string` | Server UTC timestamp in ms |
215
-
216
- Each handler receives a fully-typed notification object:
217
-
218
- ```javascript
219
- client.onActorUpdate((n) => { /* n: ActorUpdateNotification -- adds: state */ });
220
- client.onActorUpdateResponse((n) => { /* n: ActorUpdateResponse */ });
221
- client.onVoxelUpdate((n) => { /* n: VoxelUpdateNotification -- adds: voxelX/Y/Z, voxelType, voxelState */ });
222
- client.onVoxelUpdateResponse((n) => { /* n: VoxelUpdateResponse */ });
223
- client.onClientAudio((n) => { /* n: ClientAudioNotification -- adds: audioData */ });
224
- client.onClientText((n) => { /* n: ClientTextNotification -- adds: text */ });
225
- client.onClientEvent((n) => { /* n: ClientEventNotification -- adds: eventType, state */ });
226
- client.onServerEvent((n) => { /* n: ServerEventNotification -- adds: eventType, state */ });
227
- client.onGenericError((e) => { /* e: GenericErrorResponse -- sequenceNumber, errorCode only */ });
228
- ```
229
-
230
- `GenericErrorResponse` is the only type without the spatial header; it has
231
- just `sequenceNumber` and `errorCode`.
232
-
233
- ## Input Parameters
234
-
235
- ### Common fields
236
-
237
- All mutation inputs share these fields:
238
-
239
- | Field | Type | Required | Default | Description |
240
- |-------|------|----------|---------|-------------|
241
- | `appId` | `number` | yes | -- | App ID (tenant / world identifier) |
242
- | `chunk` | `{ x, y, z }` | yes | -- | Chunk coordinates (numbers) |
243
- | `uuid` | `string` | yes | -- | Random 32-byte UTF-8 string (see [Generating a UUID](#generating-a-uuid)) |
244
- | `distance` | `number` | no | `8` | Replication range (0-8 chunks, Chebyshev) |
245
- | `decayRate` | `number` | no | `0` | Delivery decay (see table below) |
246
- | `sequenceNumber` | `number` | no | `0` | uint8 (0-255) for correlation |
247
-
248
- ### `decayRate` values
249
-
250
- | Value | Name | Behavior |
251
- |-------|------|----------|
252
- | 0 | None | All clients within `distance` receive every message |
253
- | 1 | Exponential | Each ring receives half the messages of the previous ring |
254
- | 2 | Linear 50% | Furthest ring receives 50% of messages |
255
- | 3 | Linear 25% | Furthest ring receives 25% of messages |
256
- | 4 | Linear 10% | Furthest ring receives 10% of messages |
257
- | 5 | Linear 5% | Furthest ring receives 5% of messages |
258
-
259
- ### `sendActorUpdate`
260
-
261
- | Field | Type | Description |
262
- |-------|------|-------------|
263
- | `state` | `string` | Base64-encoded binary state (must be non-empty) |
264
-
265
- ### `sendVoxelUpdate`
266
-
267
- | Field | Type | Description |
268
- |-------|------|-------------|
269
- | `voxel` | `{ x, y, z }` | Voxel position within the chunk |
270
- | `voxelType` | `number` | Voxel type ID |
271
- | `voxelState` | `string` | Base64-encoded voxel state |
272
-
273
- ### `sendAudioPacket`
274
-
275
- | Field | Type | Description |
276
- |-------|------|-------------|
277
- | `audioData` | `string` | Base64-encoded compressed audio |
278
-
279
- ### `sendTextPacket`
280
-
281
- | Field | Type | Description |
282
- |-------|------|-------------|
283
- | `text` | `string` | Chat message text |
284
-
285
- ### `sendClientEvent`
286
-
287
- | Field | Type | Description |
288
- |-------|------|-------------|
289
- | `eventType` | `number` | Custom event type ID |
290
- | `state` | `string` | Base64-encoded event state |
11
+ CrowdyJS v4 targets browsers by default and uses native `fetch`, `WebSocket`, `crypto`, `btoa`, and `atob`. Node tools can still use the SDK, but must provide browser-compatible globals when opening realtime connections.
291
12
 
292
- ## Complete Example
13
+ ## Quick start
293
14
 
294
- ```javascript
295
- import WebSocket from 'ws';
296
- globalThis.WebSocket = WebSocket;
15
+ ```ts
16
+ import {
17
+ BrowserLocalStorageTokenStore,
18
+ createCrowdyClient,
19
+ } from '@crowdedkingdomstudios/crowdyjs';
297
20
 
298
- import { CrowdyClient } from '@crowdedkingdomstudios/crowdyjs';
299
-
300
- const client = new CrowdyClient({
301
- graphqlEndpoint: 'https://your-server.com/graphql',
302
- wsEndpoint: 'wss://your-server.com/graphql',
303
- });
304
-
305
- const MY_UUID = crypto.randomBytes(32).toString('hex').slice(0, 32);
306
-
307
- // 1. Login
308
- await client.login('user@example.com', 'password');
309
-
310
- // 2. Subscribe (auto-opens UDP proxy session)
311
- const unsubActors = client.onActorUpdate((n) => {
312
- console.log(`Actor ${n.uuid} at chunk (${n.chunkX},${n.chunkY},${n.chunkZ})`);
313
- console.log(` state=${n.state} seq=${n.sequenceNumber} t=${n.epochMillis}`);
314
- });
315
-
316
- const unsubErrors = client.onGenericError((e) => {
317
- console.error(`Error: ${e.errorCode} (seq ${e.sequenceNumber})`);
318
- });
319
-
320
- // 3. Register in chunk
321
- await client.sendActorUpdate({
322
- appId: 0,
323
- chunk: { x: 0, y: 0, z: 0 },
324
- distance: 8,
325
- uuid: MY_UUID,
326
- state: 'AA==',
327
- sequenceNumber: 1,
21
+ const client = createCrowdyClient({
22
+ // Game API (world data + UDP proxy)
23
+ httpUrl: 'https://game.example.com',
24
+ wsUrl: 'wss://game.example.com',
25
+ // Management API (login, register, profile)
26
+ managementUrl: 'https://management.example.com',
27
+ tokenStore: new BrowserLocalStorageTokenStore(),
28
+ realtime: {
29
+ retryAttempts: 8,
30
+ waitTimeoutMs: 5000,
31
+ },
328
32
  });
329
33
 
330
- // 4. Send updates in a loop
331
- let seq = 2;
332
- const interval = setInterval(async () => {
333
- const buf = new Uint8Array(96);
334
- crypto.getRandomValues(buf);
335
- const state = btoa(String.fromCharCode(...buf));
336
-
337
- await client.sendActorUpdate({
338
- appId: 0,
339
- chunk: { x: 0, y: 0, z: 0 },
340
- distance: 8,
341
- uuid: MY_UUID,
342
- state,
343
- sequenceNumber: seq++ % 256,
344
- });
345
- }, 100);
346
-
347
- // 5. Cleanup
348
- clearInterval(interval);
349
- unsubActors();
350
- unsubErrors();
351
- await client.disconnectUdpProxy();
352
- client.close();
353
- ```
354
-
355
- ## Building a Real-Time Game
356
-
357
- The Quick Start above is enough to send and receive packets. Once you wire
358
- the SDK into an actual game these patterns will save you a lot of debugging
359
- time.
360
-
361
- ### Decouple the network from the render loop
362
-
363
- Browsers throttle `requestAnimationFrame` aggressively when the tab is
364
- backgrounded — often to ~1 fps or fully paused. If your `sendActorUpdate`
365
- cadence is driven by the render loop, your character will appear frozen
366
- to other players the moment a user switches tabs. Drive sends with
367
- `setInterval` (which keeps firing in background tabs):
368
-
369
- ```javascript
370
- const SEND_INTERVAL_MS = 100; // 10 Hz
371
- const sendId = setInterval(async () => {
372
- await client.sendActorUpdate({
373
- appId, chunk: getCurrentChunk(),
374
- uuid: MY_UUID,
375
- state: encodeLocalPose(),
376
- sequenceNumber: nextSeq(),
377
- });
378
- }, SEND_INTERVAL_MS);
379
- ```
380
-
381
- Apply incoming notifications **inside the WS callback**, not buffered for
382
- the next frame. That keeps your data model in sync with real network
383
- arrivals even while the render loop is paused:
384
-
385
- ```javascript
386
- client.onActorUpdate((notification) => {
387
- registry.applyRemote(notification, performance.now()); // synchronous
388
- });
389
- ```
390
-
391
- The renderer should treat the registry as a *view source* — sprites
392
- interpolate toward registry-held targets each frame, but the registry
393
- itself is mutated only by the network layer.
394
-
395
- ### Verify the round-trip with self-fanout
396
-
397
- The game server fans out every accepted actor update to all clients in
398
- the chunk, **including the sender**. Use your own arrival as the WS
399
- round-trip echo (correlate by `uuid` + `sequenceNumber`):
400
-
401
- ```javascript
402
- let lastSendAt = null, lastSendSeq = null;
403
- let lastEchoAt = null, lastEchoSeq = null;
404
-
405
- async function sendOnce() {
406
- const seq = nextSeq();
407
- const ok = await client.sendActorUpdate({ /* ... */, sequenceNumber: seq });
408
- if (ok) { lastSendAt = performance.now(); lastSendSeq = seq; }
34
+ // Restore a previous session if there is one, otherwise log in.
35
+ await client.session.restore();
36
+ if (!client.session.getToken()) {
37
+ await client.auth.login({ email: 'player@example.com', password: 'secret' });
409
38
  }
410
39
 
411
- client.onActorUpdate((n) => {
412
- if (n.uuid === MY_UUID) {
413
- lastEchoAt = performance.now();
414
- lastEchoSeq = n.sequenceNumber;
415
- return; // don't render ourselves as a remote
416
- }
417
- registry.applyRemote(n);
418
- });
40
+ // Fetch the per-app bootstrap (version requirements, UDP availability, spatial limits).
41
+ const bootstrap = await client.serverStatus.gameClientBootstrap('1');
42
+ console.log(bootstrap.versionInfo.minimumClientVersion);
419
43
  ```
420
44
 
421
- `ActorUpdateResponse` is retired on the wire the self-uuid notification
422
- is the canonical ack. `GenericErrorResponse` carries failures (correlate
423
- via `sequenceNumber`).
45
+ Both endpoints share a single `AuthState`, so once `client.auth.login()` returns, every subsequent SDK call (against either endpoint) carries the bearer token automatically.
424
46
 
425
- ### Keep the UDP session alive
47
+ If `managementUrl` is omitted, the SDK falls back to `httpUrl` for backwards-compat with the single-endpoint deployment.
426
48
 
427
- The server times the UDP proxy session out after **30 s** of inactivity in
428
- either direction. A 10 Hz send loop trivially keeps it warm. If your
429
- sender pauses (e.g. no local input → no diff to send), the next mutation
430
- will lazily re-establish via `ensureUdpProxyConnection` — `connectUdpProxy()`
431
- is idempotent. There is no need for a separate keepalive.
49
+ ## Sub-clients at a glance
432
50
 
433
- ### Detect and recover from connection drops
51
+ | Sub-client | What it does |
52
+ |---|---|
53
+ | `client.auth` | Register, log in, log out, password reset, email confirmation. |
54
+ | `client.users` | `me`, `updateGamertag`, profile reads. |
55
+ | `client.session` | Token store, `restore()`, `getToken()`, manual `setToken()`. |
56
+ | `client.serverStatus` | `gameClientBootstrap(appId)` — per-app version info, UDP status, spatial limits. |
57
+ | `client.chunks`, `client.voxels`, `client.actors`, `client.avatars`, `client.state` | World data reads + writes. |
58
+ | `client.teleport` | Teleport requests. |
59
+ | `client.udp` | UDP proxy subscriptions + spatial mutations (`sendActorUpdate`, `sendVoxelUpdate`, `sendAudioPacket`, `sendTextPacket`, `sendClientEvent`). |
60
+ | `client.realtime` | Connection status, manual `connect()` / `disconnect()`, `onStatus()` listener. |
61
+ | `client.world(appId)` | Higher-level helpers for browser games (`actor.join`, `actor.sendState`, `actor.sendText`). |
434
62
 
435
- The SDK does not auto-reconnect a dropped WebSocket: `onclose` only nulls
436
- the internal client. Track wall-clock arrival time of *any* notification
437
- and force a fresh subscription if it goes silent:
63
+ Auth and user reads always target `managementUrl`. Everything else targets `httpUrl` / `wsUrl`.
438
64
 
439
- ```javascript
440
- let lastNotificationAt = performance.now();
441
- function bumpActivity() { lastNotificationAt = performance.now(); }
65
+ ## Game-loop lifecycle
442
66
 
443
- // Wire bumpActivity into every handler you register (actor, voxel, text,
444
- // audio, event, server event, error). Or wrap your subscribe call.
445
- client.onActorUpdate((n) => { bumpActivity(); /* ... */ });
446
- client.onGenericError((e) => { bumpActivity(); /* ... */ });
67
+ 1. Authenticate with `client.auth.login()` or restore a previous token through `client.session.restore()`.
68
+ 2. Subscribe to UDP proxy notifications with `client.udp.subscribe()` (the SDK will open the realtime socket on demand).
69
+ 3. Join a chunk by sending an initial actor update.
70
+ 4. Send actor, voxel, text, audio, and client-event updates through `client.udp` or the higher-level `client.world(appId)` helpers.
71
+ 5. Call `client.udp.disconnect()` when leaving the world.
72
+ 6. Call `client.close()` when disposing the SDK instance.
447
73
 
448
- setInterval(() => {
449
- if (performance.now() - lastNotificationAt > 8000) {
450
- forceResubscribe(); // see below
451
- }
452
- }, 1000);
453
- ```
74
+ ## Per-app routing
454
75
 
455
- To force a fresh subscription, call your unsubscribe handles and
456
- `subscribe()` again — the SDK opens a new WebSocket on a fresh subscribe.
457
- Then send one `sendActorUpdate` to re-register your chunk position so
458
- others see you immediately.
76
+ When a player is about to join an app, query its routing fields on the management API first:
459
77
 
460
- ```javascript
461
- function forceResubscribe() {
462
- unsubAll();
463
- unsubAll = installSubscriptions(); // re-registers handlers
464
- void client.sendActorUpdate({ /* re-register */ });
78
+ ```graphql
79
+ query AppForRouting($id: BigInt!) {
80
+ app(id: $id) {
81
+ appId
82
+ splitMode
83
+ gameApiUrl
84
+ }
465
85
  }
466
86
  ```
467
87
 
468
- ### Visibility-driven heal
469
-
470
- Browsers sometimes kill the WebSocket while a tab is hidden. Listen for
471
- `visibilitychange` and trigger a heal on return. Pause your stale-actor
472
- reaper (next section) while hidden so you don't drop remotes during a
473
- period when you couldn't have heard from them anyway.
474
-
475
- ```javascript
476
- document.addEventListener('visibilitychange', () => {
477
- if (document.hidden) {
478
- pauseReaper('tab_hidden');
479
- return;
480
- }
481
- forceResubscribe();
482
- resumeReaperWithGrace('tab_hidden', 5000);
88
+ If `splitMode && gameApiUrl`, the app lives behind its own Game API deployment. Build a **second** `CrowdyClient` with `httpUrl: gameApiUrl` (and the matching `wsUrl`) **sharing the same `tokenStore` as the first client**, then drive gameplay through that client. Apps without `splitMode` keep working against the default `httpUrl` you configured.
89
+
90
+ ## Realtime notifications
91
+
92
+ ```ts
93
+ const unsubscribe = client.udp.subscribe({
94
+ actorUpdate: (event) => {
95
+ console.log(event.uuid, event.state);
96
+ },
97
+ voxelUpdate: (event) => { /* ... */ },
98
+ text: (event) => { /* ... */ },
99
+ audio: (event) => { /* ... */ },
100
+ clientEvent: (event) => { /* ... */ },
101
+ serverEvent: (event) => { /* ... */ },
102
+ singleActorMessage: (event) => {
103
+ // A direct actor-to-actor message addressed to you.
104
+ console.log(event.uuid, event.payload); // payload is base64
105
+ },
106
+ genericError: (event) => {
107
+ console.warn(event.sequenceNumber, event.errorCode);
108
+ },
109
+ connectionEvent: (event) => {
110
+ console.warn(event.code, event.message);
111
+ },
112
+ error: (error) => {
113
+ console.error(error.code, error.message);
114
+ },
483
115
  });
484
- ```
485
-
486
- ### Track presence with timestamps
487
-
488
- There is no live "who is in this chunk" query — `client.actors.list({ chunk })`
489
- returns the persisted DB roster, not the live UDP set. Each remote actor's
490
- presence is inferred from the last notification you saw from them:
491
116
 
492
- ```javascript
493
- const remotes = new Map(); // uuid -> { firstSeenAt, lastUpdateAt, pose }
494
-
495
- client.onActorUpdate((n) => {
496
- if (n.uuid === MY_UUID) return;
497
- const now = performance.now();
498
- const existing = remotes.get(n.uuid);
499
- if (!existing) {
500
- remotes.set(n.uuid, { firstSeenAt: now, lastUpdateAt: now, pose: decode(n.state) });
501
- } else {
502
- existing.lastUpdateAt = now;
503
- existing.pose = decode(n.state);
504
- }
117
+ client.realtime.onStatus((status) => {
118
+ console.log('realtime:', status);
505
119
  });
506
- ```
507
120
 
508
- Periodically reap remotes that stopped sending. **Suspend reaping while
509
- the tab is hidden or the WS is unhealthy** — otherwise you will evict
510
- remotes simply because you couldn't have heard from them.
511
-
512
- ```javascript
513
- const STALE_TIMEOUT_MS = 10_000;
514
- setInterval(() => {
515
- if (document.hidden || isWsStale()) return;
516
- const now = performance.now();
517
- for (const [uuid, r] of remotes) {
518
- if (now - r.lastUpdateAt > STALE_TIMEOUT_MS) remotes.delete(uuid);
519
- }
520
- }, 3000);
121
+ // Later:
122
+ unsubscribe();
521
123
  ```
522
124
 
523
- Grant a grace window after every heal (visibility return, force-resubscribe)
524
- before reaping resumes — that lets remotes re-broadcast on their own
525
- cadence first.
526
-
527
- ### Suggested module layout
125
+ The SDK uses the `graphql-transport-ws` protocol through `graphql-ws`, reconnects with backoff, re-reads the current token before reconnecting, and resubscribes automatically.
528
126
 
529
- A clean separation makes the rest much easier:
127
+ ## Spatial sends
530
128
 
531
- ```
532
- GameSession (singleton, lives above your scenes / views)
533
- ├── ActorRegistry data + emitter; mutated only in WS callbacks
534
- ├── ActorSender setInterval sends; reads pose from your game
535
- ├── NetworkHealth visibility / WS-stale monitor + heal
536
- └── (uses) CrowdyClient
537
- ```
538
-
539
- Sprites and HUD become *views* over `ActorRegistry`. Scene shutdown only
540
- destroys sprites; the singleton session and registry persist, so revisiting
541
- the scene (or returning from a hidden tab) does not lose presence state.
542
- Recreating sprites on scene re-entry walks the existing registry entries.
543
-
544
- ### Diagnostics worth logging
545
-
546
- When something goes wrong, these are the values you'll wish you had:
547
-
548
- - `sendCount`, `lastSendAt`, `lastSendSeq` — your local sender's history.
549
- - `echoCount`, `lastEchoAt`, `lastEchoSeq`, `msSinceLastEcho` — the WS
550
- round-trip via self-fanout. If `echoCount` flatlines while `sendCount`
551
- climbs, the WS path is wedged.
552
- - `notificationsByType` — map of `__typename` to arrival count. Useful for
553
- confirming what the server is actually emitting.
554
- - Per-remote `lastUpdateAt` / `msSinceUpdate`.
555
- - `visibilityState`, `hidden`, `lastWsResubscribeAt`, `reaperPaused`.
556
-
557
- Expose them on a global handle (e.g. `window.__MYGAME_NET__.snapshot()`)
558
- during development — a structured JSON snapshot is far easier to triage
559
- than a wall of streaming logs.
560
-
561
- ## API Reference
562
-
563
- ### Constructor
564
-
565
- ```typescript
566
- new CrowdyClient(config?: CrowdyClientConfig)
567
- ```
568
-
569
- | Option | Type | Default | Description |
570
- |--------|------|---------|-------------|
571
- | `graphqlEndpoint` | `string` | `http://localhost:3000/graphql` | HTTP endpoint for mutations/queries |
572
- | `wsEndpoint` | `string` | `ws://localhost:3000/graphql` | WebSocket endpoint for subscriptions |
573
- | `timeout` | `number` | `60000` | HTTP request timeout in ms |
574
-
575
- ### Authentication
576
-
577
- | Method | Returns | Description |
578
- |--------|---------|-------------|
579
- | `login(email, password)` | `Promise<AuthResponse>` | Login and store the game token |
580
- | `register(email, password, gamertag?)` | `Promise<AuthResponse>` | Register and store the game token |
581
- | `getAuthToken()` | `string \| null` | Get the current game token |
582
-
583
- ### UDP Proxy
584
-
585
- | Method | Returns | Description |
586
- |--------|---------|-------------|
587
- | `connectUdpProxy()` | `Promise<UdpProxyConnectionStatus>` | Open a UDP session (idempotent: returns the existing status if one is open). Optional — `udpNotifications` and any `send*` mutation also create a session lazily. |
588
- | `disconnectUdpProxy()` | `Promise<boolean>` | Release the UDP session and complete the current `udpNotifications` stream. To force a fresh socket: call `disconnectUdpProxy()` then `connectUdpProxy()`. |
589
- | `getConnectionStatus()` | `Promise<UdpProxyConnectionStatus>` | Check if a UDP session is active |
590
-
591
- ### Mutations
592
-
593
- | Method | Returns | Description |
594
- |--------|---------|-------------|
595
- | `sendActorUpdate(input)` | `Promise<boolean>` | Send an actor state update |
596
- | `sendVoxelUpdate(input)` | `Promise<boolean>` | Modify a voxel in a chunk |
597
- | `sendAudioPacket(input)` | `Promise<boolean>` | Send voice audio data |
598
- | `sendTextPacket(input)` | `Promise<boolean>` | Send chat text |
599
- | `sendClientEvent(input)` | `Promise<boolean>` | Send a custom event |
600
-
601
- ### Subscriptions
602
-
603
- | Method | Handler receives | Description |
604
- |--------|-----------------|-------------|
605
- | `onActorUpdate(handler)` | `ActorUpdateNotification` | Another client's actor state |
606
- | `onActorUpdateResponse(handler)` | `ActorUpdateResponse` | Server ack for your actor update |
607
- | `onVoxelUpdate(handler)` | `VoxelUpdateNotification` | A voxel was modified |
608
- | `onVoxelUpdateResponse(handler)` | `VoxelUpdateResponse` | Server ack for your voxel update |
609
- | `onClientAudio(handler)` | `ClientAudioNotification` | Voice audio from another client |
610
- | `onClientText(handler)` | `ClientTextNotification` | Chat text from another client |
611
- | `onClientEvent(handler)` | `ClientEventNotification` | Custom event from another client |
612
- | `onServerEvent(handler)` | `ServerEventNotification` | Event from the game server |
613
- | `onGenericError(handler)` | `GenericErrorResponse` | Error from the server |
614
-
615
- All subscription methods return an `UnsubscribeFn` -- call it to remove
616
- the handler.
617
-
618
- ### Cleanup
619
-
620
- | Method | Description |
621
- |--------|-------------|
622
- | `close()` | Close the WebSocket, remove all handlers, clear auth state |
623
-
624
- ## Domain APIs
625
-
626
- In addition to the replication-focused methods documented above, the
627
- client exposes a set of typed *domain wrappers* that cover the rest of
628
- the Crowded Kingdoms GraphQL API. Each wrapper is mounted on the
629
- `CrowdyClient` instance and uses the same auth token / base URL as the
630
- replication API. Operation inputs and return shapes are generated from
631
- the live `schema.gql` via `graphql-codegen`, so they stay in lockstep
632
- with the server.
633
-
634
- ```javascript
635
- const client = new CrowdyClient({ graphqlEndpoint: '...' });
636
- await client.auth.login({ email, password });
637
- ```
638
-
639
- ### `client.auth` -- authentication & session lifecycle
640
-
641
- ```javascript
642
- await client.auth.register({ email, password, gamertag });
643
- await client.auth.login({ email, password });
644
- await client.auth.confirmEmail('...token...');
645
- await client.auth.requestPasswordReset('user@example.com');
646
- await client.auth.resetPassword({ token: '...', newPassword: '...' });
647
- await client.auth.resendConfirmationEmail('user@example.com');
648
- await client.auth.changePassword('old', 'new');
649
- await client.auth.logout();
650
- await client.auth.logoutAllDevices();
651
- ```
652
-
653
- The legacy shortcuts `client.login(email, password)` and
654
- `client.register(email, password, gamertag?)` are kept for backwards
655
- compatibility and delegate to `client.auth`.
656
-
657
- ### `client.users` -- user directory & profile
658
-
659
- ```javascript
660
- const me = await client.users.me();
661
- const u = await client.users.byId('123');
662
- const all = await client.users.list();
663
- const list = await client.users.byGamertag('Gandalf');
664
- const list2 = await client.users.byEmail('a@b.c');
665
-
666
- await client.users.updateGamertag({ gamertag: 'Newname', disambiguation: '0001' });
667
- await client.users.updateUserState({ state: 'base64...' });
668
- ```
669
-
670
- ### `client.orgs` -- organizations / studios
671
-
672
- ```javascript
673
- const org = await client.orgs.create({ name: 'Acme', slug: 'acme' });
674
- const byId = await client.orgs.byId(org.orgId);
675
- const bySlug = await client.orgs.bySlug('acme');
676
- const members = await client.orgs.members(org.orgId);
677
-
678
- await client.orgs.inviteMember({ orgId: org.orgId, userId: '42' });
679
- const token = await client.orgs.createOrgToken({ orgId: org.orgId, label: 'CI' });
680
- ```
681
-
682
- ### `client.appAccess` -- app access tiers and grants
683
-
684
- ```javascript
685
- const tiers = await client.appAccess.listTiers(appId);
686
- const mine = await client.appAccess.myAccess(appId);
687
-
688
- await client.appAccess.createTier({ appId, name: 'Pro', isFree: false, tierOrder: 2 });
689
- await client.appAccess.grant({ appId, userId: '42', tierId });
690
- await client.appAccess.revoke(appId, '42');
691
- ```
692
-
693
- ### `client.billing` -- wallets & app budgets
694
-
695
- ```javascript
696
- const wallet = await client.billing.balance(orgId);
697
- const txns = await client.billing.transactions({ orgId, limit: 50, offset: 0 });
698
- const budget = await client.billing.appBudget(orgId, appId);
129
+ ```ts
130
+ const response = await client.udp.sendActorUpdateAndWait({
131
+ appId: '1',
132
+ chunk: { x: '0', y: '0', z: '0' },
133
+ uuid: '0123456789abcdef0123456789abcdef',
134
+ state: 'AA==', // base64-encoded payload
135
+ distance: 8,
136
+ decayRate: 1,
137
+ });
699
138
 
700
- await client.billing.setAppBudget({ orgId, appId, monthlyLimitCents: '5000' });
139
+ console.log(response.__typename, response.sequenceNumber);
701
140
  ```
702
141
 
703
- ### `client.quotas` -- per-org / per-app service quotas
142
+ The plain `sendActorUpdate`, `sendVoxelUpdate`, `sendAudioPacket`, `sendTextPacket`, and `sendClientEvent` methods return the GraphQL mutation result immediately. The `AndWait` variants allocate a `sequenceNumber` when one is missing and wait for either a matching notification or `GenericErrorResponse`.
704
143
 
705
- ```javascript
706
- const orgQ = await client.quotas.byOrg(orgId);
707
- const appQ = await client.quotas.byApp(appId);
144
+ ### Actor-to-actor messages
708
145
 
709
- const q = await client.quotas.set({
710
- orgId,
711
- appId,
712
- metric: 'voxel_updates',
713
- limitValue: '1000000',
714
- period: 'month',
146
+ ```ts
147
+ // Delivered only to the actor whose UUID matches `targetUuid`; you must know
148
+ // that actor's current chunk. Fire-and-forget — the sender gets no echo, so
149
+ // there is no `AndWait` variant. The target receives a
150
+ // `SingleActorMessageNotification` on its subscription.
151
+ await client.udp.sendSingleActorMessage({
152
+ appId: '1',
153
+ chunk: { x: '7', y: '1', z: '2' }, // the TARGET actor's chunk
154
+ targetUuid: '0123456789abcdef0123456789abcdef',
155
+ payload: 'aGVsbG8=', // base64; embed sender identity here if you need it
715
156
  });
716
- await client.quotas.delete(q.quotaId);
717
157
  ```
718
158
 
719
- ### `client.chunks` -- chunk + LOD queries and admin mutations
159
+ ## World helpers
720
160
 
721
- ```javascript
722
- const chunk = await client.chunks.get({ appId, coordinates: { x: '0', y: '0', z: '0' } });
723
- const lods = await client.chunks.getLods({ appId, coordinates, lodLevels: [0, 1, 2] });
724
- const nearby = await client.chunks.byDistance({ appId, centerCoordinate: coordinates, maxDistance: 4 });
725
- const voxels = await client.chunks.voxelList({ appId, coordinates });
161
+ ```ts
162
+ const world = client.world('1');
163
+ const actor = world.actor();
726
164
 
727
- await client.chunks.update({ appId, coordinates, voxels: '...base64...' });
728
- await client.chunks.updateState({ appId, coordinates, chunkState: '...' });
729
- await client.chunks.updateLods({ appId, coordinates, lods: [{ level: 0, data: '...' }] });
730
- ```
731
-
732
- ### `client.voxels` -- voxel queries, history, rollback
165
+ await actor.join({ x: '0', y: '0', z: '0' });
166
+ await actor.sendState('AA==');
167
+ await actor.sendText('hello nearby players');
733
168
 
734
- ```javascript
735
- await client.voxels.list({ appId, coordinates });
736
- await client.voxels.listByDistance({ appId, centerCoordinate: coordinates, maxDistance: 2 });
737
- await client.voxels.update({ appId, coordinates, location, voxelType: 5, state: '...' });
738
- await client.voxels.history({ appId, userId: '42', limit: 100 });
739
- await client.voxels.rollback({ appId, userId: '42', from, to, dryRun: true });
169
+ // Direct message to one other actor (you supply its UUID + current chunk):
170
+ await actor.sendToActor(
171
+ '0123456789abcdef0123456789abcdef',
172
+ 'aGVsbG8=', // base64 payload
173
+ { x: '7', y: '1', z: '2' },
174
+ );
740
175
  ```
741
176
 
742
- ### `client.actors` -- persisted actors (CRUD)
177
+ The world helpers are thin wrappers over `client.udp.*` with the appId pre-bound — convenient for browser games. Advanced callers can always use `client.udp.*` with the generated GraphQL input types directly.
743
178
 
744
- ```javascript
745
- const a = await client.actors.create({
746
- appId, uuid: '...', chunk: { x: '0', y: '0', z: '0' },
747
- });
748
- await client.actors.get(a.uuid);
749
- await client.actors.list({ appId });
750
- await client.actors.batchLookup({ uuids: [a.uuid] });
751
- await client.actors.update(a.uuid, { publicState: '...' });
752
- await client.actors.updateState(a.uuid, { publicState: '...' });
753
- await client.actors.delete(a.uuid);
754
- ```
179
+ ## Errors
755
180
 
756
- For high-frequency replication, continue to use the existing
757
- `client.sendActorUpdate(...)` UDP path -- it is unchanged.
181
+ Transport and protocol failures throw structured error classes:
758
182
 
759
- ### `client.teleport`
183
+ - `CrowdyHttpError` — non-2xx response from a GraphQL endpoint.
184
+ - `CrowdyGraphQLError` — preserves every GraphQL error including `path` and `extensions.code`.
185
+ - `CrowdyNetworkError` — network-level failure (DNS, TLS, connection refused).
186
+ - `CrowdyTimeoutError` — request or `AndWait` timed out.
187
+ - `CrowdyRealtimeError` — realtime subscription couldn't be established or was dropped.
188
+ - `CrowdyProtocolError` — server response failed schema validation.
760
189
 
761
- ```javascript
762
- const result = await client.teleport.request({
763
- appId,
764
- chunkAddress: { x: '0', y: '0', z: '0' },
765
- voxelAddress: { x: 0, y: 0, z: 0 },
766
- UUID: '...',
767
- });
768
- ```
190
+ ## Auth notes
769
191
 
770
- ### `client.state` -- per-user, per-app state
192
+ - Use `client.auth.setToken(token)` if you need to seed a token externally (e.g. when restoring auth from a non-default storage).
193
+ - `client.session.restore()` reads from the configured `tokenStore`. `BrowserLocalStorageTokenStore` is provided; bring your own for SSR or Node usage.
194
+ - A single `AuthState` is observed by both the HTTP client and the realtime socket, so HTTP and WebSocket auth can never drift.
771
195
 
772
- ```javascript
773
- await client.state.update({ appId, state: 'base64...' });
774
- await client.state.getOne(appId);
775
- await client.state.getAll();
776
- await client.state.delete(appId);
777
- ```
196
+ ## What's NOT in CrowdyJS
778
197
 
779
- ### `client.serverStatus`
198
+ CrowdyJS focuses on the **game-client surface**: auth, world data, UDP proxy, profile reads. The following operations are **not** exposed by the SDK and should be called against the management GraphQL API directly (with a server-side token, typically from a studio backend):
780
199
 
781
- ```javascript
782
- await client.serverStatus.serverWithLeastClients();
783
- await client.serverStatus.listAll();
784
- await client.serverStatus.listActiveGraphqlServers();
785
- await client.serverStatus.versionInfo();
786
- ```
200
+ - Org / app / billing / payments / quotas operations
201
+ - Access-tier and runtime-permission administration
202
+ - Game-token issuance / revocation
203
+ - Marketplace and catalog management
787
204
 
788
- ### `client.apps`
205
+ The SDK is intentionally scoped to client-side, end-user-facing flows.
789
206
 
790
- The current schema does not expose direct CRUD for the App entity --
791
- apps are provisioned out of band. `client.apps` exists as a stable
792
- namespace for future additions; in the meantime use `client.appAccess`,
793
- `client.state`, `client.billing`, `client.chunks`, and `client.voxels`
794
- for app-scoped functionality.
207
+ ## Low-level GraphQL access
795
208
 
796
- ## Codegen
209
+ Game-client methods are first-class, but generated operation documents are also available through a transport escape hatch:
797
210
 
798
- Operations live as `.graphql` files under `src/operations/<domain>/`.
799
- After editing them or pulling a new schema, regenerate the typed
800
- documents:
211
+ ```ts
212
+ import { VersionInfoDocument } from '@crowdedkingdomstudios/crowdyjs/generated';
801
213
 
802
- ```bash
803
- npm run codegen # one-shot
804
- npm run codegen:watch # watch mode
214
+ const data = await client.graphql.request(VersionInfoDocument);
805
215
  ```
806
216
 
807
- `npm run build` runs codegen automatically via `prebuild`.
217
+ Most consumers should prefer the typed methods on `client.auth`, `client.users`, `client.udp`, `client.serverStatus`, and `client.world()`.
808
218
 
809
- ## TypeScript
219
+ ## Migration
810
220
 
811
- The SDK is written in TypeScript and ships type declarations. All
812
- notification interfaces, input types, and handler signatures are fully
813
- typed for IDE autocomplete and compile-time safety. Schema-derived
814
- input/output types (e.g. `CreateOrganizationInput`, `Organization`,
815
- `AppBudget`) are re-exported from the package root.
221
+ See [MIGRATION.md](MIGRATION.md) for breaking changes between SDK majors.
816
222
 
817
223
  ## License
818
224