@voicethere/agent 0.7.13 → 0.8.1

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 (64) hide show
  1. package/README.md +14 -3
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/runtime.d.ts +10 -0
  7. package/dist/runtime.d.ts.map +1 -1
  8. package/dist/runtime.js +28 -2
  9. package/dist/runtime.js.map +1 -1
  10. package/dist/templates/echo/agent.js +16 -5
  11. package/dist/templates/echo-dc/agent.js +1 -1
  12. package/dist/templates/game-sync/agent.js +162 -125
  13. package/dist/templates/recording-consent/agent.js +12 -12
  14. package/dist/templates/registry.d.ts +1 -1
  15. package/dist/templates/registry.d.ts.map +1 -1
  16. package/dist/templates/registry.js +42 -26
  17. package/dist/templates/registry.js.map +1 -1
  18. package/dist/templates/voice-showcase/agent.js +64 -25
  19. package/dist/templates/voice-starter/agent.js +1 -1
  20. package/dist/templates/webhooks/agent.js +2 -2
  21. package/dist/templates/webhooks-redis/agent.js +11 -4
  22. package/dist/templates/world-sync/agent.js +829 -0
  23. package/dist/templates/world-sync-binary/agent.js +1031 -0
  24. package/package.json +3 -2
  25. package/templates/README.md +41 -69
  26. package/templates/crash/README.md +13 -0
  27. package/templates/echo/README.md +13 -0
  28. package/templates/{echo.ts → echo/agent.ts} +4 -6
  29. package/templates/echo-dc/README.md +13 -0
  30. package/templates/{echo-dc.ts → echo-dc/agent.ts} +1 -1
  31. package/templates/echo-smoke/README.md +13 -0
  32. package/templates/game-sync/README.md +21 -0
  33. package/templates/{game-sync.ts → game-sync/agent.ts} +126 -94
  34. package/templates/{game-sync-redis.ts → game-sync/redis.ts} +4 -13
  35. package/templates/{game-sync-sim.ts → game-sync/sim.ts} +14 -7
  36. package/templates/{game-sync-world-layout.ts → game-sync/world-layout.ts} +72 -30
  37. package/templates/game-sync-smoke/README.md +13 -0
  38. package/templates/mix-smoke/README.md +13 -0
  39. package/templates/positional-tts/README.md +13 -0
  40. package/templates/recording-consent/README.md +13 -0
  41. package/templates/redis-sync/README.md +23 -0
  42. package/templates/redis-sync/agent.ts +72 -95
  43. package/templates/redis-sync/world-layout.ts +103 -26
  44. package/templates/spatial-showcase/README.md +13 -0
  45. package/templates/voice-showcase/README.md +13 -0
  46. package/templates/voice-showcase/agent.ts +3 -1
  47. package/templates/voice-showcase/conversation.ts +9 -5
  48. package/templates/voice-showcase/delivery.ts +40 -10
  49. package/templates/voice-starter/README.md +13 -0
  50. package/templates/webhooks/README.md +13 -0
  51. package/templates/{webhooks.ts → webhooks/agent.ts} +1 -1
  52. package/templates/webhooks-redis/README.md +13 -0
  53. package/templates/{webhooks-redis.ts → webhooks-redis/agent.ts} +9 -9
  54. package/templates/world-sync/README.md +20 -0
  55. package/templates/world-sync/agent.ts +62 -0
  56. package/templates/world-sync-binary/README.md +21 -0
  57. package/templates/world-sync-binary/agent.ts +107 -0
  58. package/templates/world-sync-binary/protocol.ts +281 -0
  59. /package/templates/{crash.ts → crash/agent.ts} +0 -0
  60. /package/templates/{echo-smoke.ts → echo-smoke/agent.ts} +0 -0
  61. /package/templates/{game-sync-protocol.ts → game-sync/protocol.ts} +0 -0
  62. /package/templates/{game-sync-smoke.ts → game-sync-smoke/agent.ts} +0 -0
  63. /package/templates/{mix-smoke.ts → mix-smoke/agent.ts} +0 -0
  64. /package/templates/{agent.ts → voice-starter/agent.ts} +0 -0
@@ -1,24 +1,15 @@
1
1
  /**
2
2
  * Redis Lua scripts and helpers for game-sync world buffer.
3
3
  */
4
- import { MAX_LIVE_OBJECTS } from "./game-sync-protocol.js";
4
+ import { MAX_LIVE_OBJECTS } from "./protocol.js";
5
5
  import {
6
- objectIdHeaderBytes,
6
+ OBJECT_ID_HEADERS_BLOB,
7
7
  OBJECT_SLOT_BYTE_LENGTH,
8
8
  WORLD_BYTE_LENGTH,
9
9
  REDIS_WORLD_KEY,
10
- } from "./game-sync-world-layout.js";
10
+ } from "./world-layout.js";
11
11
 
12
- /** Concatenated 4-byte float32 headers for objectId 1..MAX_LIVE_OBJECTS. */
13
- export function buildObjectIdHeadersBlob(): Buffer {
14
- const parts: Buffer[] = [];
15
- for (let objectId = 1; objectId <= MAX_LIVE_OBJECTS; objectId += 1) {
16
- parts.push(objectIdHeaderBytes(objectId));
17
- }
18
- return Buffer.concat(parts);
19
- }
20
-
21
- export const OBJECT_ID_HEADERS_BLOB = buildObjectIdHeadersBlob();
12
+ export { OBJECT_ID_HEADERS_BLOB };
22
13
 
23
14
  /**
24
15
  * Atomic allocate: find first empty slot, enforce global live cap, write 9-float record.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Server-authoritative game-sync physics step (pure Float32Array, no Redis).
3
3
  */
4
- import { objectIdToSlot } from "./game-sync-world-layout.js";
4
+ import { objectIdToSlot } from "./world-layout.js";
5
5
 
6
6
  export const BOARD_WIDTH = 1280;
7
7
  export const BOARD_HEIGHT = 720;
@@ -9,12 +9,19 @@ export const OBJECT_RADIUS = 25;
9
9
  export const COLLISION_RESTITUTION = 1.0;
10
10
  export const OBJECT_STRIDE = 9;
11
11
 
12
+ /**
13
+ * Advance every active object by `dtSec`. `activeObjectIds` may be a plain
14
+ * array or a persistent Int32Array; `count` limits how many leading entries
15
+ * are live so the caller can reuse one typed array across ticks.
16
+ */
12
17
  export function simulateWorldStep(
13
18
  worldState: Float32Array,
14
19
  dtSec: number,
15
- activeObjectIds: number[],
20
+ activeObjectIds: ArrayLike<number>,
21
+ count: number = activeObjectIds.length,
16
22
  ): void {
17
- for (const objectId of activeObjectIds) {
23
+ for (let i = 0; i < count; i += 1) {
24
+ const objectId = activeObjectIds[i] ?? 0;
18
25
  const slot = objectIdToSlot(objectId);
19
26
  const start = slot * OBJECT_STRIDE;
20
27
  if (start < 0 || start + OBJECT_STRIDE > worldState.length) continue;
@@ -42,13 +49,13 @@ export function simulateWorldStep(
42
49
  worldState[start + 6] = vy;
43
50
  }
44
51
 
45
- for (let i = 0; i < activeObjectIds.length; i += 1) {
46
- const aId = activeObjectIds[i];
52
+ for (let i = 0; i < count; i += 1) {
53
+ const aId = activeObjectIds[i] ?? 0;
47
54
  const aSlot = objectIdToSlot(aId);
48
55
  const aStart = aSlot * OBJECT_STRIDE;
49
56
  if (aStart < 0 || aStart + OBJECT_STRIDE > worldState.length) continue;
50
- for (let j = i + 1; j < activeObjectIds.length; j += 1) {
51
- const bId = activeObjectIds[j];
57
+ for (let j = i + 1; j < count; j += 1) {
58
+ const bId = activeObjectIds[j] ?? 0;
52
59
  const bSlot = objectIdToSlot(bId);
53
60
  const bStart = bSlot * OBJECT_STRIDE;
54
61
  if (bStart < 0 || bStart + OBJECT_STRIDE > worldState.length) continue;
@@ -5,7 +5,7 @@
5
5
  * Slot layout: [objectId, posX, posY, posZ, posW, dirX, dirY, dirZ, dirW]
6
6
  * Empty slot: objectId === 0
7
7
  */
8
- import { MAX_LIVE_OBJECTS } from "./game-sync-protocol.js";
8
+ import { MAX_LIVE_OBJECTS } from "./protocol.js";
9
9
 
10
10
  export const OBJECT_STRIDE = 9;
11
11
  export const WORLD_FLOAT_COUNT = MAX_LIVE_OBJECTS * OBJECT_STRIDE;
@@ -74,6 +74,14 @@ export function markSlotFree(world: Float32Array, slot: number): void {
74
74
  }
75
75
  }
76
76
 
77
+ const OBJECT_SLOT_SCRATCH = new Float32Array(OBJECT_STRIDE);
78
+ const OBJECT_SLOT_SCRATCH_BUF = Buffer.from(
79
+ OBJECT_SLOT_SCRATCH.buffer,
80
+ OBJECT_SLOT_SCRATCH.byteOffset,
81
+ OBJECT_SLOT_BYTE_LENGTH,
82
+ );
83
+
84
+ /** Shared 9-float slot buffer (overwritten on the next call). */
77
85
  export function encodeObjectSlot(
78
86
  objectId: number,
79
87
  posX: number,
@@ -85,18 +93,16 @@ export function encodeObjectSlot(
85
93
  dirZ: number,
86
94
  dirW: number,
87
95
  ): Buffer {
88
- const floats = new Float32Array([
89
- objectId,
90
- posX,
91
- posY,
92
- posZ,
93
- posW,
94
- dirX,
95
- dirY,
96
- dirZ,
97
- dirW,
98
- ]);
99
- return Buffer.from(floats.buffer, floats.byteOffset, floats.byteLength);
96
+ OBJECT_SLOT_SCRATCH[0] = objectId;
97
+ OBJECT_SLOT_SCRATCH[1] = posX;
98
+ OBJECT_SLOT_SCRATCH[2] = posY;
99
+ OBJECT_SLOT_SCRATCH[3] = posZ;
100
+ OBJECT_SLOT_SCRATCH[4] = posW;
101
+ OBJECT_SLOT_SCRATCH[5] = dirX;
102
+ OBJECT_SLOT_SCRATCH[6] = dirY;
103
+ OBJECT_SLOT_SCRATCH[7] = dirZ;
104
+ OBJECT_SLOT_SCRATCH[8] = dirW;
105
+ return OBJECT_SLOT_SCRATCH_BUF;
100
106
  }
101
107
 
102
108
  export function writeObjectSlot(
@@ -162,6 +168,26 @@ export function collectActiveObjectIds(world: Float32Array): number[] {
162
168
  return ids;
163
169
  }
164
170
 
171
+ /**
172
+ * Allocation-free variant for the sim loop: writes live object ids into `dest`
173
+ * (capacity >= MAX_LIVE_OBJECTS) and returns how many were written.
174
+ */
175
+ export function collectActiveObjectIdsInto(
176
+ world: Float32Array,
177
+ dest: Int32Array,
178
+ ): number {
179
+ let count = 0;
180
+ const limit = Math.min(MAX_LIVE_OBJECTS, dest.length);
181
+ for (let slot = 0; slot < MAX_LIVE_OBJECTS && count < limit; slot += 1) {
182
+ const objectId = readSlotObjectId(world, slot);
183
+ if (objectId !== 0) {
184
+ dest[count] = objectId;
185
+ count += 1;
186
+ }
187
+ }
188
+ return count;
189
+ }
190
+
165
191
  export interface LiveWorldObjectInfo {
166
192
  objectId: number;
167
193
  ownerSessionId: string;
@@ -208,32 +234,48 @@ export function clampSimulationDtSec(
208
234
  }
209
235
 
210
236
  /**
211
- * Decode a Redis / Node Buffer into a fixed-size world Float32Array.
237
+ * Copy Redis / Node Buffer bytes into `dest` (default: a new world).
238
+ *
239
+ * Node Buffer pools can have `byteOffset` not divisible by 4, so we copy via
240
+ * a Uint8Array view onto `dest` instead of slicing a new ArrayBuffer.
212
241
  */
213
242
  export function normalizeWorldBuffer(
214
243
  raw: Uint8Array | null | undefined,
244
+ dest: Float32Array = createEmptyWorldBuffer(),
215
245
  ): Float32Array {
246
+ const destBytes = new Uint8Array(
247
+ dest.buffer,
248
+ dest.byteOffset,
249
+ dest.byteLength,
250
+ );
216
251
  if (!raw || raw.byteLength === 0) {
217
- return createEmptyWorldBuffer();
252
+ destBytes.fill(0);
253
+ return dest;
218
254
  }
219
- const bytes = Math.floor(raw.byteLength / 4) * 4;
220
- if (bytes === 0) {
221
- return createEmptyWorldBuffer();
255
+ const byteCount = Math.min(
256
+ Math.floor(raw.byteLength / 4) * 4,
257
+ destBytes.byteLength,
258
+ );
259
+ if (byteCount > 0) {
260
+ destBytes.set(raw.subarray(0, byteCount));
222
261
  }
223
- const aligned = raw.buffer.slice(raw.byteOffset, raw.byteOffset + bytes);
224
- const decoded = new Float32Array(aligned);
225
- if (decoded.length === WORLD_FLOAT_COUNT) {
226
- return decoded;
262
+ if (byteCount < destBytes.byteLength) {
263
+ destBytes.fill(0, byteCount);
227
264
  }
228
- const normalized = createEmptyWorldBuffer();
229
- normalized.set(
230
- decoded.subarray(0, Math.min(decoded.length, WORLD_FLOAT_COUNT)),
231
- );
232
- return normalized;
265
+ return dest;
233
266
  }
234
267
 
235
- /** Float32 objectId bytes for Lua slot header splice (objectId 1..MAX_LIVE_OBJECTS). */
268
+ /** Concatenated float32 objectId headers (1..MAX_LIVE_OBJECTS), one allocation. */
269
+ export const OBJECT_ID_HEADERS_BLOB = (() => {
270
+ const blob = Buffer.alloc(MAX_LIVE_OBJECTS * 4);
271
+ for (let objectId = 1; objectId <= MAX_LIVE_OBJECTS; objectId += 1) {
272
+ blob.writeFloatLE(objectId, (objectId - 1) * 4);
273
+ }
274
+ return blob;
275
+ })();
276
+
277
+ /** View into {@link OBJECT_ID_HEADERS_BLOB} for Lua slot header splice. */
236
278
  export function objectIdHeaderBytes(objectId: number): Buffer {
237
- const header = new Float32Array([objectId]);
238
- return Buffer.from(header.buffer, header.byteOffset, 4);
279
+ const index = objectId - 1;
280
+ return OBJECT_ID_HEADERS_BLOB.subarray(index * 4, index * 4 + 4);
239
281
  }
@@ -0,0 +1,13 @@
1
+ # game-sync-smoke
2
+
3
+ Minimal data-channel agent for deploy-smoke, shared-child, and idle smokes. Not seeded on project create.
4
+
5
+ ## Build
6
+
7
+ ```bash
8
+ npx @voicethere/agent build --entry templates/game-sync-smoke/agent.ts --outfile dist/agent.js
9
+ ```
10
+
11
+ ## Sources
12
+
13
+ - `agent.ts`
@@ -0,0 +1,13 @@
1
+ # mix-smoke
2
+
3
+ Positional mix DataChannel commands for voice-data-mix-smoke (Voice+Data, shared child). Not seeded on project create.
4
+
5
+ ## Build
6
+
7
+ ```bash
8
+ npx @voicethere/agent build --entry templates/mix-smoke/agent.ts --outfile dist/agent.js
9
+ ```
10
+
11
+ ## Sources
12
+
13
+ - `agent.ts`
@@ -0,0 +1,13 @@
1
+ # positional-tts
2
+
3
+ Voice+Data demo — enables positional mixing and orbits each listener’s TTS speaker with `setTtsPose`. Requires Voice+Data runner mode.
4
+
5
+ ## Build
6
+
7
+ ```bash
8
+ npx @voicethere/agent build --entry templates/positional-tts/agent.ts --outfile dist/agent.js
9
+ ```
10
+
11
+ ## Sources
12
+
13
+ - `agent.ts`, `orbit.ts`
@@ -0,0 +1,13 @@
1
+ # recording-consent
2
+
3
+ Conversation recording consent on connect. Pauses capture while collecting name and date of birth, then resumes only if the customer consented.
4
+
5
+ ## Build
6
+
7
+ ```bash
8
+ npx @voicethere/agent build --entry templates/recording-consent/agent.ts --outfile dist/agent.js
9
+ ```
10
+
11
+ ## Sources
12
+
13
+ - `agent.ts`, `conversation.ts`
@@ -0,0 +1,23 @@
1
+ # redis-sync
2
+
3
+ Redis-backed world buffer sync for redis-sync-smoke (Advanced tier + project Redis). Not seeded on project create.
4
+
5
+ Clients publish a 12-byte `Float32Array([clientIndex, x, y])` on `voicethere-sync`. Each tick's Redis GET is copied into one `Float32Array` in place and every broadcast is one `broadCastBinaryToClients` call with the same persistent `Buffer` view over a live session array. Slot patches reuse one 16-byte scratch buffer.
6
+
7
+ ## Protocol
8
+
9
+ - **Inbound (12 bytes):** little-endian `float32` `[clientIndex, x, y]` via `onDataChannelBinary`
10
+ - **Outbound:** full world blob (`MAX_PEERS` slots × `[clientIndex, x, y, active]`)
11
+ - **Redis:** key `e2e:redis-sync:world`, Lua `LUA_PATCH_PEER_SLOT`
12
+
13
+ Reuse one `Float32Array(3)` on the client (`sendSyncBinary`).
14
+
15
+ ## Build
16
+
17
+ ```bash
18
+ npx @voicethere/agent build --entry templates/redis-sync/agent.ts --outfile dist/agent.js
19
+ ```
20
+
21
+ ## Sources
22
+
23
+ - `agent.ts`, `world-layout.ts`
@@ -2,10 +2,11 @@
2
2
  * Redis-backed world buffer sync for redis-sync-smoke (Advanced tier + project Redis).
3
3
  *
4
4
  * Game-style layout: one Float32Array world blob in Redis (`e2e:redis-sync:world`).
5
- * Each client owns a fixed slot and publishes `{ type: "position", clientIndex, x, y }`.
6
- * Each runner pod runs its own broadcast loop: one Redis GET per tick, then binary
7
- * fan-out on voicethere-sync to all local sessions (no pub/sub). Clients patch
8
- * out of sync; the 20Hz server tick is the authoritative sync path.
5
+ * Each client owns a fixed slot and publishes a 12-byte `ArrayBuffer`
6
+ * `Float32Array([clientIndex, x, y])` on the sync DataChannel. Each runner pod
7
+ * runs its own broadcast loop: one Redis GET per tick, then binary fan-out on
8
+ * voicethere-sync to all local sessions (no pub/sub). Clients patch out of sync;
9
+ * the 20Hz server tick is the authoritative sync path.
9
10
  *
10
11
  * Slot writes use a Lua read-modify-write so concurrent patches from many sessions
11
12
  * cannot clobber each other (WATCH/MULTI lost slots under 30-way connect storms).
@@ -13,85 +14,50 @@
13
14
  * Build:
14
15
  * npx @voicethere/agent build --entry templates/redis-sync/agent.ts --outfile dist/agent.js
15
16
  */
16
- import Redis from "ioredis";
17
- import { agentLog, defineAgent, sendBinaryToClient } from "@voicethere/agent";
17
+ import { Redis } from "ioredis";
18
+ import {
19
+ agentLog,
20
+ broadCastBinaryToClients,
21
+ defineAgent,
22
+ sendBinaryToClient,
23
+ } from "@voicethere/agent";
18
24
 
19
25
  import {
20
26
  createEmptyWorldBuffer,
27
+ decodePositionBuffer,
21
28
  normalizeWorldBuffer,
22
29
  peerSlotOffset,
23
30
  PEER_SLOT_BYTE_LENGTH,
31
+ PEER_STRIDE,
24
32
  REDIS_WORLD_KEY,
25
33
  WORLD_BYTE_LENGTH,
26
34
  writePeerSlot,
35
+ LUA_PATCH_PEER_SLOT,
27
36
  } from "./world-layout.js";
28
37
 
29
- /** Atomic splice of one peer slot (16 bytes) into the world blob. */
30
- const LUA_PATCH_PEER_SLOT = `
31
- local key = KEYS[1]
32
- local offset = tonumber(ARGV[1])
33
- local slot = ARGV[2]
34
- local size = tonumber(ARGV[3])
35
- local world = redis.call('GET', key)
36
- if not world then
37
- world = string.rep(string.char(0), size)
38
- elseif #world < size then
39
- world = world .. string.rep(string.char(0), size - #world)
40
- elseif #world > size then
41
- world = string.sub(world, 1, size)
42
- end
43
- world = string.sub(world, 1, offset) .. slot .. string.sub(world, offset + #slot + 1)
44
- redis.call('SET', key, world)
45
- return size
46
- `;
47
-
48
38
  const WORLD_BROADCAST_HZ = 20;
49
39
  const WORLD_BROADCAST_INTERVAL_MS = Math.floor(1000 / WORLD_BROADCAST_HZ);
50
40
 
51
41
  const connectedSessions = new Set<string>();
42
+ /** Same members as connectedSessions; array form for the per-tick broadcast. */
43
+ const connectedSessionList: string[] = [];
52
44
  const sessionClientIndex = new Map<string, number>();
53
- let localWorld = createEmptyWorldBuffer();
45
+ /** The one world buffer: Redis GETs copy into it, every broadcast sends this view. */
46
+ const localWorld = createEmptyWorldBuffer();
47
+ const localWorldBytes = Buffer.from(
48
+ localWorld.buffer,
49
+ localWorld.byteOffset,
50
+ localWorld.byteLength,
51
+ );
54
52
  let redis: Redis | null = null;
55
53
  let broadcastTimer: NodeJS.Timeout | null = null;
56
54
 
57
- function parsePositionMessage(
58
- message: unknown,
59
- ): { clientIndex: number; x: number; y: number } | null {
60
- if (!message || typeof message !== "object") {
61
- return null;
62
- }
63
- const record = message as {
64
- type?: unknown;
65
- clientIndex?: unknown;
66
- x?: unknown;
67
- y?: unknown;
68
- };
69
- if (record.type !== "position") {
70
- return null;
71
- }
72
- if (
73
- typeof record.clientIndex !== "number" ||
74
- !Number.isFinite(record.clientIndex) ||
75
- record.clientIndex < 0
76
- ) {
77
- return null;
78
- }
79
- if (typeof record.x !== "number" || !Number.isFinite(record.x)) {
80
- return null;
81
- }
82
- if (typeof record.y !== "number" || !Number.isFinite(record.y)) {
83
- return null;
84
- }
85
- return {
86
- clientIndex: record.clientIndex,
87
- x: record.x,
88
- y: record.y,
89
- };
90
- }
91
-
92
- function copyWorldBuffer(world: Float32Array): Buffer {
93
- return Buffer.from(world.buffer, world.byteOffset, world.byteLength);
94
- }
55
+ const PEER_SLOT = new Float32Array(PEER_STRIDE);
56
+ const PEER_SLOT_BUF = Buffer.from(
57
+ PEER_SLOT.buffer,
58
+ PEER_SLOT.byteOffset,
59
+ PEER_SLOT.byteLength,
60
+ );
95
61
 
96
62
  function encodePeerSlot(
97
63
  clientIndex: number,
@@ -99,15 +65,15 @@ function encodePeerSlot(
99
65
  y: number,
100
66
  active: number,
101
67
  ): Buffer {
102
- const floats = new Float32Array([clientIndex, x, y, active]);
103
- return Buffer.from(floats.buffer, floats.byteOffset, floats.byteLength);
68
+ PEER_SLOT[0] = clientIndex;
69
+ PEER_SLOT[1] = x;
70
+ PEER_SLOT[2] = y;
71
+ PEER_SLOT[3] = active;
72
+ return PEER_SLOT_BUF;
104
73
  }
105
74
 
106
- function broadcastWorldBuffer(
107
- world: Float32Array,
108
- targetSessionId?: string,
109
- ): void {
110
- const payload = copyWorldBuffer(world);
75
+ function broadcastWorldBuffer(targetSessionId?: string): void {
76
+ const payload = localWorldBytes;
111
77
  if (targetSessionId) {
112
78
  try {
113
79
  sendBinaryToClient(targetSessionId, payload, "sync");
@@ -120,41 +86,44 @@ function broadcastWorldBuffer(
120
86
  }
121
87
  return;
122
88
  }
123
- for (const sessionId of connectedSessions) {
124
- try {
125
- sendBinaryToClient(sessionId, payload, "sync");
126
- } catch (error: unknown) {
127
- const detail = error instanceof Error ? error.message : String(error);
128
- agentLog("error", `world send failed session=${sessionId}: ${detail}`);
129
- }
89
+ if (connectedSessionList.length === 0) return;
90
+ try {
91
+ // payload is already a Buffer: forwarded as-is, one call, no Set iterator.
92
+ broadCastBinaryToClients(payload, connectedSessionList, "sync");
93
+ } catch (error: unknown) {
94
+ const detail = error instanceof Error ? error.message : String(error);
95
+ agentLog("error", `world broadcast send failed: ${detail}`);
130
96
  }
131
97
  }
132
98
 
133
- async function loadWorldFromRedis(): Promise<Float32Array> {
134
- if (!redis) {
135
- return new Float32Array(localWorld);
136
- }
99
+ /** Copy the Redis blob into `localWorld` in place; the GET reply is the only allocation. */
100
+ async function loadWorldFromRedis(): Promise<void> {
101
+ if (!redis) return;
137
102
  const raw = await redis.getBuffer(REDIS_WORLD_KEY);
138
- return normalizeWorldBuffer(raw);
103
+ normalizeWorldBuffer(raw, localWorld);
139
104
  }
140
105
 
141
106
  async function broadcastWorldFromRedis(
142
107
  targetSessionId?: string,
143
108
  ): Promise<void> {
144
- const world = await loadWorldFromRedis();
145
- broadcastWorldBuffer(world, targetSessionId);
109
+ await loadWorldFromRedis();
110
+ broadcastWorldBuffer(targetSessionId);
111
+ }
112
+
113
+ function logBroadcastError(error: unknown): void {
114
+ const detail = error instanceof Error ? error.message : String(error);
115
+ agentLog("error", `world broadcast failed: ${detail}`);
116
+ }
117
+
118
+ function onBroadcastTick(): void {
119
+ void broadcastWorldFromRedis().catch(logBroadcastError);
146
120
  }
147
121
 
148
122
  function startBroadcastLoopIfNeeded(): void {
149
123
  if (broadcastTimer || connectedSessions.size === 0) {
150
124
  return;
151
125
  }
152
- broadcastTimer = setInterval(() => {
153
- void broadcastWorldFromRedis().catch((error: unknown) => {
154
- const detail = error instanceof Error ? error.message : String(error);
155
- agentLog("error", `world broadcast failed: ${detail}`);
156
- });
157
- }, WORLD_BROADCAST_INTERVAL_MS);
126
+ broadcastTimer = setInterval(onBroadcastTick, WORLD_BROADCAST_INTERVAL_MS);
158
127
  agentLog("info", `world loop started (${WORLD_BROADCAST_HZ}Hz)`);
159
128
  }
160
129
 
@@ -218,13 +187,21 @@ defineAgent({
218
187
  },
219
188
 
220
189
  async onClientJoin({ sessionId }) {
221
- connectedSessions.add(sessionId);
190
+ if (!connectedSessions.has(sessionId)) {
191
+ connectedSessions.add(sessionId);
192
+ connectedSessionList.push(sessionId);
193
+ }
222
194
  startBroadcastLoopIfNeeded();
223
195
  await broadcastWorldFromRedis(sessionId);
224
196
  },
225
197
 
226
198
  async onClientLeave({ sessionId }) {
227
- connectedSessions.delete(sessionId);
199
+ if (connectedSessions.delete(sessionId)) {
200
+ const index = connectedSessionList.indexOf(sessionId);
201
+ if (index !== -1) {
202
+ connectedSessionList.splice(index, 1);
203
+ }
204
+ }
228
205
  const clientIndex = sessionClientIndex.get(sessionId);
229
206
  sessionClientIndex.delete(sessionId);
230
207
  stopBroadcastLoopIfNeeded();
@@ -239,8 +216,8 @@ defineAgent({
239
216
  }
240
217
  },
241
218
 
242
- async onDataChannelMessage(ctx) {
243
- const position = parsePositionMessage(ctx.message);
219
+ async onDataChannelBinary(ctx) {
220
+ const position = decodePositionBuffer(ctx.rawBinary);
244
221
  if (!position) {
245
222
  return;
246
223
  }