@crowdedkingdomstudios/crowdyjs 2.1.1 → 3.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 (45) hide show
  1. package/MIGRATION.md +26 -0
  2. package/README.md +110 -772
  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 +22 -17
  9. package/dist/crowdy-client.d.ts +21 -3
  10. package/dist/crowdy-client.d.ts.map +1 -1
  11. package/dist/crowdy-client.js +37 -20
  12. package/dist/domains/serverStatus.d.ts +2 -1
  13. package/dist/domains/serverStatus.d.ts.map +1 -1
  14. package/dist/domains/serverStatus.js +5 -1
  15. package/dist/domains/udp.d.ts +19 -2
  16. package/dist/domains/udp.d.ts.map +1 -1
  17. package/dist/domains/udp.js +38 -0
  18. package/dist/errors.d.ts +42 -0
  19. package/dist/errors.d.ts.map +1 -0
  20. package/dist/errors.js +42 -0
  21. package/dist/generated/graphql.d.ts +404 -12
  22. package/dist/generated/graphql.d.ts.map +1 -1
  23. package/dist/generated/graphql.js +2 -0
  24. package/dist/index.d.ts +10 -6
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +8 -6
  27. package/dist/logger.d.ts +8 -0
  28. package/dist/logger.d.ts.map +1 -0
  29. package/dist/logger.js +1 -0
  30. package/dist/realtime.d.ts +86 -0
  31. package/dist/realtime.d.ts.map +1 -0
  32. package/dist/realtime.js +270 -0
  33. package/dist/session.d.ts +27 -0
  34. package/dist/session.d.ts.map +1 -0
  35. package/dist/session.js +61 -0
  36. package/dist/subscriptions.d.ts +1 -48
  37. package/dist/subscriptions.d.ts.map +1 -1
  38. package/dist/subscriptions.js +1 -192
  39. package/dist/utils.d.ts +12 -0
  40. package/dist/utils.d.ts.map +1 -0
  41. package/dist/utils.js +50 -0
  42. package/dist/world.d.ts +35 -0
  43. package/dist/world.d.ts.map +1 -0
  44. package/dist/world.js +88 -0
  45. package/package.json +13 -3
package/README.md CHANGED
@@ -1,819 +1,157 @@
1
1
  # CrowdyJS SDK
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
+ Browser-first SDK for Crowded Kingdoms game clients. CrowdyJS wraps the GraphQL
4
+ API, the UDP proxy subscription, auth/session state, and spatial send helpers in
5
+ one typed client.
6
6
 
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
7
+ ## Install
16
8
 
17
9
  ```bash
18
10
  npm install @crowdedkingdomstudios/crowdyjs
19
11
  ```
20
12
 
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`.
13
+ CrowdyJS v3 targets browsers by default and uses native `fetch`, `WebSocket`,
14
+ `crypto`, `btoa`, and `atob`. Node tools can still use the SDK, but must provide
15
+ browser-compatible globals when they open realtime connections.
40
16
 
41
17
  ## Quick Start
42
18
 
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',
19
+ ```ts
20
+ import {
21
+ BrowserLocalStorageTokenStore,
22
+ createCrowdyClient,
23
+ } from '@crowdedkingdomstudios/crowdyjs';
24
+
25
+ const client = createCrowdyClient({
26
+ httpUrl: 'https://api.example.com/graphql',
27
+ wsUrl: 'wss://api.example.com/graphql',
28
+ tokenStore: new BrowserLocalStorageTokenStore(),
29
+ realtime: {
30
+ retryAttempts: 8,
31
+ waitTimeoutMs: 5000,
32
+ },
49
33
  });
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
34
 
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
- ```
35
+ await client.session.restore();
72
36
 
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
- ```
37
+ if (!client.session.getToken()) {
38
+ await client.auth.login({ email: 'player@example.com', password: 'secret' });
39
+ }
123
40
 
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,
41
+ const bootstrap = await client.serverStatus.gameClientBootstrap('1');
42
+ console.log(bootstrap.versionInfo.minimumClientVersion);
43
+ ```
44
+
45
+ ## Game Loop Lifecycle
46
+
47
+ 1. Authenticate with `client.auth.login()` or restore a previous token through
48
+ `client.session.restore()`.
49
+ 2. Subscribe to UDP proxy notifications with `client.udp.subscribe()` or
50
+ `client.realtime.connect()`.
51
+ 3. Join a chunk by sending an initial actor update.
52
+ 4. Send actor, voxel, text, audio, and client-event updates through `client.udp`
53
+ or the higher-level `client.world(appId)` helpers.
54
+ 5. Call `client.udp.disconnect()` when leaving the world, then `client.close()`
55
+ when disposing the SDK instance.
56
+
57
+ ## Realtime Notifications
58
+
59
+ ```ts
60
+ const unsubscribe = client.udp.subscribe({
61
+ actorUpdate: (event) => {
62
+ console.log(event.uuid, event.state);
63
+ },
64
+ genericError: (event) => {
65
+ console.warn(event.sequenceNumber, event.errorCode);
66
+ },
67
+ connectionEvent: (event) => {
68
+ console.warn(event.code, event.message);
69
+ },
70
+ error: (error) => {
71
+ console.error(error.code, error.message);
72
+ },
132
73
  });
133
- ```
134
74
 
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,
75
+ client.realtime.onStatus((status) => {
76
+ console.log('realtime:', status);
159
77
  });
160
78
  ```
161
79
 
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 |
80
+ The SDK uses the `graphql-transport-ws` protocol through `graphql-ws`, reconnects
81
+ with backoff, re-reads the current token before reconnecting, and resubscribes to
82
+ the generated `UdpNotifications` document.
278
83
 
279
- ### `sendTextPacket`
84
+ ## Raw UDP Sends
280
85
 
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 |
291
-
292
- ## Complete Example
293
-
294
- ```javascript
295
- import WebSocket from 'ws';
296
- globalThis.WebSocket = WebSocket;
297
-
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,
86
+ ```ts
87
+ const response = await client.udp.sendActorUpdateAndWait({
88
+ appId: '1',
89
+ chunk: { x: '0', y: '0', z: '0' },
90
+ uuid: '0123456789abcdef0123456789abcdef',
326
91
  state: 'AA==',
327
- sequenceNumber: 1,
328
- });
329
-
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; }
409
- }
410
-
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
- });
419
- ```
420
-
421
- `ActorUpdateResponse` is retired on the wire — the self-uuid notification
422
- is the canonical ack. `GenericErrorResponse` carries failures (correlate
423
- via `sequenceNumber`).
424
-
425
- ### Keep the UDP session alive
426
-
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.
432
-
433
- ### Detect and recover from connection drops
434
-
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:
438
-
439
- ```javascript
440
- let lastNotificationAt = performance.now();
441
- function bumpActivity() { lastNotificationAt = performance.now(); }
442
-
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(); /* ... */ });
447
-
448
- setInterval(() => {
449
- if (performance.now() - lastNotificationAt > 8000) {
450
- forceResubscribe(); // see below
451
- }
452
- }, 1000);
453
- ```
454
-
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.
459
-
460
- ```javascript
461
- function forceResubscribe() {
462
- unsubAll();
463
- unsubAll = installSubscriptions(); // re-registers handlers
464
- void client.sendActorUpdate({ /* re-register */ });
465
- }
466
- ```
467
-
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);
483
- });
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
-
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
- }
92
+ distance: 8,
93
+ decayRate: 1,
505
94
  });
506
- ```
507
95
 
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);
96
+ console.log(response.__typename, response.sequenceNumber);
521
97
  ```
522
98
 
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.
99
+ The plain `sendActorUpdate`, `sendVoxelUpdate`, `sendAudioPacket`,
100
+ `sendTextPacket`, and `sendClientEvent` methods return the GraphQL mutation
101
+ result immediately. The `AndWait` variants allocate a `sequenceNumber` when one
102
+ is missing and wait for either a matching notification or `GenericErrorResponse`.
526
103
 
527
- ### Suggested module layout
104
+ ## World Helpers
528
105
 
529
- A clean separation makes the rest much easier:
106
+ ```ts
107
+ const world = client.world('1');
108
+ const actor = world.actor();
530
109
 
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
110
+ await actor.join({ x: '0', y: '0', z: '0' });
111
+ await actor.sendState('AA==');
112
+ await actor.sendText('hello nearby players');
537
113
  ```
538
114
 
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.
115
+ The world helpers are convenience wrappers for browser games. Advanced callers
116
+ can always use `client.udp.*` with generated GraphQL input types.
543
117
 
544
- ### Diagnostics worth logging
118
+ ## Errors
545
119
 
546
- When something goes wrong, these are the values you'll wish you had:
120
+ Transport and protocol failures use structured error classes:
547
121
 
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`.
122
+ - `CrowdyHttpError`
123
+ - `CrowdyGraphQLError`
124
+ - `CrowdyNetworkError`
125
+ - `CrowdyTimeoutError`
126
+ - `CrowdyRealtimeError`
127
+ - `CrowdyProtocolError`
556
128
 
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.
129
+ `CrowdyGraphQLError` preserves every GraphQL error, including `path` and
130
+ `extensions.code`.
560
131
 
561
- ## API Reference
132
+ ## Low-Level GraphQL Access
562
133
 
563
- ### Constructor
134
+ Game-client methods are first-class, but generated operations are still
135
+ available through the transport escape hatch:
564
136
 
565
- ```typescript
566
- new CrowdyClient(config?: CrowdyClientConfig)
567
- ```
137
+ ```ts
138
+ import { VersionInfoDocument } from '@crowdedkingdomstudios/crowdyjs/generated';
568
139
 
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 });
140
+ const data = await client.graphql.request(VersionInfoDocument);
637
141
  ```
638
142
 
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
- ```
143
+ Most consumers should prefer the methods on `client.auth`, `client.udp`,
144
+ `client.serverStatus`, `client.users`, `client.apps`, and `client.world()`.
681
145
 
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);
699
-
700
- await client.billing.setAppBudget({ orgId, appId, monthlyLimitCents: '5000' });
701
- ```
702
-
703
- ### `client.quotas` -- per-org / per-app service quotas
704
-
705
- ```javascript
706
- const orgQ = await client.quotas.byOrg(orgId);
707
- const appQ = await client.quotas.byApp(appId);
708
-
709
- const q = await client.quotas.set({
710
- orgId,
711
- appId,
712
- metric: 'voxel_updates',
713
- limitValue: '1000000',
714
- period: 'month',
715
- });
716
- await client.quotas.delete(q.quotaId);
717
- ```
718
-
719
- ### `client.chunks` -- chunk + LOD queries and admin mutations
720
-
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 });
726
-
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
733
-
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 });
740
- ```
741
-
742
- ### `client.actors` -- persisted actors (CRUD)
743
-
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
- ```
755
-
756
- For high-frequency replication, continue to use the existing
757
- `client.sendActorUpdate(...)` UDP path -- it is unchanged.
758
-
759
- ### `client.teleport`
760
-
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
- ```
769
-
770
- ### `client.state` -- per-user, per-app state
771
-
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
- ```
778
-
779
- ### `client.serverStatus`
780
-
781
- ```javascript
782
- await client.serverStatus.serverWithLeastClients();
783
- await client.serverStatus.listAll();
784
- await client.serverStatus.listActiveGraphqlServers();
785
- await client.serverStatus.versionInfo();
786
- ```
787
-
788
- ### `client.apps`
789
-
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.
795
-
796
- ## Codegen
797
-
798
- Operations live as `.graphql` files under `src/operations/<domain>/`.
799
- After editing them or pulling a new schema, regenerate the typed
800
- documents:
146
+ ## Development
801
147
 
802
148
  ```bash
803
- npm run codegen # one-shot
804
- npm run codegen:watch # watch mode
149
+ npm install
150
+ npm run codegen
151
+ npm run build
152
+ npm test
805
153
  ```
806
154
 
807
- `npm run build` runs codegen automatically via `prebuild`.
808
-
809
- ## TypeScript
810
-
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.
816
-
817
- ## License
818
-
819
- MIT
155
+ `npm run codegen` syncs `../cks-graphql-api/schema.gql` when the API repo is
156
+ checked out beside this SDK. `npm run check:schema` fails if the committed SDL or
157
+ generated types drift from the API schema.