@voicethere/agent 0.7.12 → 0.8.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 (62) 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.map +1 -1
  7. package/dist/runtime.js +11 -2
  8. package/dist/runtime.js.map +1 -1
  9. package/dist/templates/echo/agent.js +1 -1
  10. package/dist/templates/echo-dc/agent.js +1 -1
  11. package/dist/templates/game-sync/agent.js +162 -125
  12. package/dist/templates/registry.d.ts +1 -1
  13. package/dist/templates/registry.d.ts.map +1 -1
  14. package/dist/templates/registry.js +42 -26
  15. package/dist/templates/registry.js.map +1 -1
  16. package/dist/templates/spatial-showcase/agent.js +261 -22
  17. package/dist/templates/voice-starter/agent.js +1 -1
  18. package/dist/templates/webhooks/agent.js +2 -2
  19. package/dist/templates/webhooks-redis/agent.js +11 -4
  20. package/dist/templates/world-sync/agent.js +829 -0
  21. package/dist/templates/world-sync-binary/agent.js +1031 -0
  22. package/package.json +3 -2
  23. package/templates/README.md +41 -69
  24. package/templates/crash/README.md +13 -0
  25. package/templates/echo/README.md +13 -0
  26. package/templates/{echo.ts → echo/agent.ts} +1 -1
  27. package/templates/echo-dc/README.md +13 -0
  28. package/templates/{echo-dc.ts → echo-dc/agent.ts} +1 -1
  29. package/templates/echo-smoke/README.md +13 -0
  30. package/templates/game-sync/README.md +21 -0
  31. package/templates/{game-sync.ts → game-sync/agent.ts} +126 -94
  32. package/templates/{game-sync-redis.ts → game-sync/redis.ts} +4 -13
  33. package/templates/{game-sync-sim.ts → game-sync/sim.ts} +14 -7
  34. package/templates/{game-sync-world-layout.ts → game-sync/world-layout.ts} +72 -30
  35. package/templates/game-sync-smoke/README.md +13 -0
  36. package/templates/mix-smoke/README.md +13 -0
  37. package/templates/positional-tts/README.md +13 -0
  38. package/templates/recording-consent/README.md +13 -0
  39. package/templates/redis-sync/README.md +23 -0
  40. package/templates/redis-sync/agent.ts +72 -95
  41. package/templates/redis-sync/world-layout.ts +103 -26
  42. package/templates/spatial-showcase/README.md +13 -0
  43. package/templates/spatial-showcase/agent.ts +245 -18
  44. package/templates/spatial-showcase/protocol.ts +79 -0
  45. package/templates/spatial-showcase/sine.ts +28 -5
  46. package/templates/voice-showcase/README.md +13 -0
  47. package/templates/voice-starter/README.md +13 -0
  48. package/templates/webhooks/README.md +13 -0
  49. package/templates/{webhooks.ts → webhooks/agent.ts} +1 -1
  50. package/templates/webhooks-redis/README.md +13 -0
  51. package/templates/{webhooks-redis.ts → webhooks-redis/agent.ts} +9 -9
  52. package/templates/world-sync/README.md +20 -0
  53. package/templates/world-sync/agent.ts +62 -0
  54. package/templates/world-sync-binary/README.md +21 -0
  55. package/templates/world-sync-binary/agent.ts +107 -0
  56. package/templates/world-sync-binary/protocol.ts +281 -0
  57. /package/templates/{crash.ts → crash/agent.ts} +0 -0
  58. /package/templates/{echo-smoke.ts → echo-smoke/agent.ts} +0 -0
  59. /package/templates/{game-sync-protocol.ts → game-sync/protocol.ts} +0 -0
  60. /package/templates/{game-sync-smoke.ts → game-sync-smoke/agent.ts} +0 -0
  61. /package/templates/{mix-smoke.ts → mix-smoke/agent.ts} +0 -0
  62. /package/templates/{agent.ts → voice-starter/agent.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voicethere/agent",
3
- "version": "0.7.12",
3
+ "version": "0.8.0",
4
4
  "description": "VoiceThere customer agent SDK — IPC types and runtime helpers for sandboxed child bundles",
5
5
  "type": "module",
6
6
  "exports": {
@@ -56,6 +56,7 @@
56
56
  "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json",
57
57
  "test": "vitest run",
58
58
  "test:ci": "npm run typecheck && npm run build && npm run test && node scripts/ci/verify-package-exports.mjs",
59
+ "test:redis": "vitest run test/redis-lua.test.ts test/game-sync-redis-agent.test.ts",
59
60
  "verify:local": "npm run build && node ./dist/cli.js verify --no-build --bundle ./dist/agent.js",
60
61
  "verify:local:only": "node ./dist/cli.js verify --no-build --bundle ./dist/agent.js",
61
62
  "live-test:models": "bash ./scripts/select-sherpa-models.sh",
@@ -63,7 +64,7 @@
63
64
  "live-test:starter": "bash ./scripts/live-test-starter.sh",
64
65
  "live-test:stack": "npm run live-test:stack:echo",
65
66
  "live-test:stack:echo": "LIVE_TEST_AGENT_ENTRY=examples/agent.ts LIVE_TEST_PAGE_PATH=/examples/live-test/index.html LIVE_TEST_SESSION_MODE=voice bash ./scripts/live-test-stack.sh",
66
- "live-test:stack:game": "LIVE_TEST_AGENT_ENTRY=templates/game-sync.ts LIVE_TEST_PAGE_PATH=/examples/live-test/game-sync.html LIVE_TEST_SESSION_MODE=data-only bash ./scripts/live-test-stack.sh",
67
+ "live-test:stack:game": "LIVE_TEST_AGENT_ENTRY=templates/game-sync/agent.ts LIVE_TEST_PAGE_PATH=/examples/live-test/game-sync.html LIVE_TEST_SESSION_MODE=data-only bash ./scripts/live-test-stack.sh",
67
68
  "prepublishOnly": "npm run build",
68
69
  "prepare": "npm run build"
69
70
  },
@@ -1,6 +1,8 @@
1
1
  # Agent templates
2
2
 
3
- `@voicethere/agent` is the single source of truth for starter templates. Import the registry from `@voicethere/agent/templates`:
3
+ `@voicethere/agent` is the single source of truth for starter templates. Each template lives in its own folder under `templates/<id>/` with a `README.md` and an `agent.ts` entry.
4
+
5
+ Import the registry from `@voicethere/agent/templates`:
4
6
 
5
7
  ```typescript
6
8
  import {
@@ -15,16 +17,16 @@ import {
15
17
 
16
18
  ## Product vs e2e
17
19
 
18
- | Kind | Dashboard create | Prebuilt seed bundle | Typical consumer |
19
- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | ----------------------- |
20
- | **product** | Yes (`echo`, `echo-dc`, `voice-starter`, `game-sync`, `voice-showcase`, `recording-consent`, `positional-tts`, `spatial-showcase`, `webhooks`, `webhooks-redis`) | Yes — `dist/templates/<id>/agent.js` | Platform project create |
21
- | **e2e** | No | No — build from sources at test time | `voicethere/e2e` smokes |
20
+ | Kind | Dashboard create | Prebuilt seed bundle | Typical consumer |
21
+ | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | ----------------------- |
22
+ | **product** | Yes (`echo`, `echo-dc`, `voice-starter`, `world-sync`, `world-sync-binary`, `game-sync`, `voice-showcase`, `recording-consent`, `positional-tts`, `spatial-showcase`, `webhooks`, `webhooks-redis`) | Yes — `dist/templates/<id>/agent.js` | Platform project create |
23
+ | **e2e** | No | No — build from sources at test time | `voicethere/e2e` smokes |
22
24
 
23
25
  Product templates always set `seedOnCreate: true`. CI fails if a product template is missing its prebuilt bundle after `npm run build`.
24
26
 
25
27
  ## Sources vs prebuilt
26
28
 
27
- - **Sources** live under `templates/` in the published package (editable TypeScript).
29
+ - **Sources** live under `templates/<id>/` in the published package (editable TypeScript plus a README).
28
30
  - **Prebuilt** bundles are derived artifacts at `dist/templates/<id>/agent.js` for product templates only.
29
31
  - `loadTemplateSources(id)` returns `{ path, content }[]` — the canonical tree for a future web editor.
30
32
  - `loadTemplateBundle(id)` returns prebuilt bytes for platform seed/deploy (runner-ready `agent.js`).
@@ -32,7 +34,7 @@ Product templates always set `seedOnCreate: true`. CI fails if a product templat
32
34
  Build a template locally:
33
35
 
34
36
  ```bash
35
- npx @voicethere/agent build --entry templates/echo.ts --outfile dist/agent.js
37
+ npx @voicethere/agent build --entry templates/echo/agent.ts --outfile dist/agent.js
36
38
  ```
37
39
 
38
40
  Prebuild all product seed bundles (also runs in `npm run build`):
@@ -41,78 +43,48 @@ Prebuild all product seed bundles (also runs in `npm run build`):
41
43
  npm run build:templates
42
44
  ```
43
45
 
44
- ## Product templates
45
-
46
- ### `echo.ts` (`echo`)
47
-
48
- Full echo debug agent for the VoiceThere dashboard — speaks **"you said: …"** on voice finals and text chat, relays speech events over DataChannel, and echoes chat replies on DC.
49
-
50
- ### `echo-dc.ts` (`echo-dc`)
51
-
52
- Data-channel-only echo — relays speech events and chat text over DC without TTS.
53
-
54
- ### `agent.ts` (`voice-starter`)
55
-
56
- Full starter bundle covering every speech event from `@node-webrtc-rust/sdk/voice`. Customize `onUserSpeechFinal` for your LLM/tools.
57
-
58
- ### `game-sync.ts` (`game-sync`)
59
-
60
- Authoritative multi-object sync sample for real-time games/simulations (register, simulate, binary world snapshots). Live objects are capped at **25** per world; clients can send `{ type: "unregister" }` (or `{ type: "remove", objectId?: number }`) to release owned objects. Protocol helpers live in `game-sync-protocol.ts`.
61
-
62
- When `AGENT_REDIS_URL` is set (project Redis), the world blob is stored at `game-sync:world` and shared across runner workers — Lua atomic allocate/release enforces the global cap; one worker holds a sim lock per tick. Without Redis, the template falls back to per-worker in-memory state (local live-test stack).
63
-
64
- ### `voice-showcase/` (`voice-showcase`)
65
-
66
- Conversational voice demo for landing and dashboard previews — greets the user, asks for a name, then offers a menu: weather (Open-Meteo, no API key), count 1–10, short recipes, and rotating fun facts. Typed chat and voice finals share the same handler. Sends structured `menu` payloads plus `chat_reply` for the chat log, then triggers TTS play so the client has the spoken text before audio starts.
46
+ ## World sync (data-only)
67
47
 
68
- Sources: `voice-showcase/agent.ts` (defineAgent wiring), `conversation.ts` (pure state machine), `delivery.ts` (send-then-play order), `weather.ts`, `recipes.ts`, `fun-facts.ts`.
48
+ Three product templates cover positional / world sync, from JSON to Redis:
69
49
 
70
- ### `recording-consent/` (`recording-consent`)
50
+ | Id | Channel | State |
51
+ | -------------------- | ------------------------------- | ----------------------------- |
52
+ | `world-sync` | `onDataChannelMessage` (JSON) | One agent, in-memory, no Redis |
53
+ | `world-sync-binary` | `onDataChannelBinary` + `broadCastBinaryToClients` (`ArrayBuffer`) | One agent, in-memory, no Redis |
54
+ | `game-sync` | JSON control + binary world snapshots | Redis when `AGENT_REDIS_URL` is set |
71
55
 
72
- Demonstrates conversation recording consent on connect: asks whether recording is OK when the project has recording enabled, pauses capture while collecting name and date of birth, then resumes only if the customer consented. When project recording is off, logs a warning and never calls `startRecording`. Voice finals and typed chat share the same handler.
56
+ See each folder README for the wire format.
73
57
 
74
- Sources: `recording-consent/agent.ts` (defineAgent wiring), `conversation.ts` (pure state machine).
75
-
76
- ### `positional-tts/` (`positional-tts`)
77
-
78
- Voice+Data demo — enables positional mixing and orbits each listener’s TTS speaker with `setTtsPose` on a ~50 ms timer. Speaks a short greeting on connect and echoes voice finals / chat so you hear panning while TTS plays. Requires Voice+Data runner mode (`isMixAvailable`).
79
-
80
- Sources: `positional-tts/agent.ts` (defineAgent wiring), `positional-tts/orbit.ts` (pure circle helper for tests).
81
-
82
- ### `spatial-showcase/` (`spatial-showcase`)
83
-
84
- Spatial audio showcase for `/showcase` — one agent template with three demos selected by `{ type: "join", demo }` on the control DataChannel:
85
-
86
- | Demo | Behavior |
87
- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
88
- | `orbit` | Looping sine tone + TTS orbit the listener (`setPlayPose` + `setTtsPose`); STT echo off; `{ type: "orbit", action: "set" \| "say" }`; agent emits `{ type: "orbit_pose" }` |
89
- | `soundboard` | Positional clip pads — browser sends `clipId` only (never URLs); agent resolves against an allowlisted `assetOrigin` |
90
- | `proximity` | Shared mix room (`createMixGroup` + `setClientPose`); `{ type: "move" }`, `{ type: "mute_peer" }`; `{ type: "room_state" }` broadcast |
91
-
92
- **Protocol:** inbound `join`, `orbit`, `pad`, `pad_stop`, `pad_status`, `move`, `mute_peer`, `leave`, `ping` — outbound `showcase_ack`, `orbit_pose`, `pad_progress`, `room_state`, `room_full`, `error`.
93
-
94
- **Limits:** `MAX_ROOM_PEERS = 8`, `MAX_ACTIVE_PLAYS = 12` per session. Clip ids: `chime`, `bell`, `laser`, `impact`, `footsteps`, `rain-loop`, `cafe-loop`, `jingle`.
95
-
96
- Sources: `spatial-showcase/agent.ts`, `protocol.ts`, `sounds.ts`, `room.ts`, `orbit.ts`, `sine.ts`.
97
-
98
- ### `webhooks.ts` (`webhooks`)
99
-
100
- Inbound HTTP webhook sample — verifies `x-agent-webhook-signature` HMAC on the **raw body** with `AGENT_WEBHOOK_SIGNING_SECRET`, then `JSON.parse` and fans out to connected sessions via DataChannel + `speak`.
101
-
102
- ### `webhooks-redis.ts` (`webhooks-redis`)
58
+ ## Product templates
103
59
 
104
- Same HMAC verify path plus an atomic Redis counter (`AGENT_REDIS_URL`) before DataChannel fan-out. Fan-out to sessions does not require Redis; Redis is for shared cross-pod state.
60
+ Each folder has its own README. Summary:
61
+
62
+ | Id | Folder | Summary |
63
+ | -- | ------ | ------- |
64
+ | `echo` | `echo/` | Voice + chat echo |
65
+ | `echo-dc` | `echo-dc/` | Data-channel echo, no TTS |
66
+ | `voice-starter` | `voice-starter/` | Every speech event |
67
+ | `world-sync` | `world-sync/` | JSON pose broadcast |
68
+ | `world-sync-binary` | `world-sync-binary/` | Binary pose `ArrayBuffer` |
69
+ | `game-sync` | `game-sync/` | Authoritative sim, Redis + binary snapshots |
70
+ | `voice-showcase` | `voice-showcase/` | Conversational landing demo |
71
+ | `recording-consent` | `recording-consent/` | Recording consent flow |
72
+ | `positional-tts` | `positional-tts/` | Orbiting TTS |
73
+ | `spatial-showcase` | `spatial-showcase/` | Orbit / soundboard / proximity |
74
+ | `webhooks` | `webhooks/` | Inbound HMAC webhooks |
75
+ | `webhooks-redis` | `webhooks-redis/` | Webhooks plus Redis counter |
105
76
 
106
77
  ## E2e templates
107
78
 
108
79
  These mirror former `e2e/fixtures/*` sources. E2E resolves entries from the package, builds into ephemeral workdirs, and uploads `dist/agent.js`.
109
80
 
110
- | Id | Source | Purpose |
111
- | ----------------- | ----------------------------------------- | --------------------------------------------- |
112
- | `echo-smoke` | `echo-smoke.ts` | voice-smoke, agent-smoke, cli-smoke |
113
- | `crash` | `crash.ts` | session-errors-smoke, crash-policy smokes |
114
- | `game-sync-smoke` | `game-sync-smoke.ts` | deploy-smoke, shared-child, idle smokes |
115
- | `redis-sync` | `redis-sync/agent.ts` + `world-layout.ts` | redis-sync-smoke (project Redis world buffer) |
81
+ | Id | Source | Purpose |
82
+ | ----------------- | ---------------------------------------------- | --------------------------------------------- |
83
+ | `echo-smoke` | `echo-smoke/agent.ts` | voice-smoke, agent-smoke, cli-smoke |
84
+ | `crash` | `crash/agent.ts` | session-errors-smoke, crash-policy smokes |
85
+ | `game-sync-smoke` | `game-sync-smoke/agent.ts` | deploy-smoke, shared-child, idle smokes |
86
+ | `redis-sync` | `redis-sync/agent.ts` + `world-layout.ts` | redis-sync-smoke (binary positions + Redis world blob) |
87
+ | `mix-smoke` | `mix-smoke/agent.ts` | voice-data-mix-smoke |
116
88
 
117
89
  **Note:** Product `echo` is not the same as e2e `echo-smoke` — keep both ids.
118
90
 
@@ -0,0 +1,13 @@
1
+ # crash
2
+
3
+ Crash / echo agent for session-errors-smoke and agent-crash-policy smokes. Not seeded on project create.
4
+
5
+ ## Build
6
+
7
+ ```bash
8
+ npx @voicethere/agent build --entry templates/crash/agent.ts --outfile dist/agent.js
9
+ ```
10
+
11
+ ## Sources
12
+
13
+ - `agent.ts`
@@ -0,0 +1,13 @@
1
+ # echo
2
+
3
+ Full echo debug agent for the VoiceThere dashboard. Speaks **you said: …** on voice finals and text chat, relays speech events over DataChannel, and echoes chat replies.
4
+
5
+ ## Build
6
+
7
+ ```bash
8
+ npx @voicethere/agent build --entry templates/echo/agent.ts --outfile dist/agent.js
9
+ ```
10
+
11
+ ## Sources
12
+
13
+ - `agent.ts`
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Build:
5
5
  * npm install @voicethere/agent
6
- * npx @voicethere/agent build --entry templates/echo.ts
6
+ * npx @voicethere/agent build --entry templates/echo/agent.ts
7
7
  */
8
8
  import {
9
9
  agentLog,
@@ -0,0 +1,13 @@
1
+ # echo-dc
2
+
3
+ Data-channel-only echo — relays speech events and chat text over DC without TTS.
4
+
5
+ ## Build
6
+
7
+ ```bash
8
+ npx @voicethere/agent build --entry templates/echo-dc/agent.ts --outfile dist/agent.js
9
+ ```
10
+
11
+ ## Sources
12
+
13
+ - `agent.ts`
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Build:
5
5
  * npm install @voicethere/agent
6
- * npx @voicethere/agent build --entry templates/echo-dc.ts
6
+ * npx @voicethere/agent build --entry templates/echo-dc/agent.ts
7
7
  */
8
8
  import {
9
9
  agentLog,
@@ -0,0 +1,13 @@
1
+ # echo-smoke
2
+
3
+ Minimal echo agent for e2e voice-smoke, agent-smoke, and cli-smoke uploads. Not seeded on project create.
4
+
5
+ ## Build
6
+
7
+ ```bash
8
+ npx @voicethere/agent build --entry templates/echo-smoke/agent.ts --outfile dist/agent.js
9
+ ```
10
+
11
+ ## Sources
12
+
13
+ - `agent.ts`
@@ -0,0 +1,21 @@
1
+ # game-sync
2
+
3
+ Authoritative multi-object world sync for games and simulations. Live objects are capped at **25**. Clients register over JSON; the agent simulates at 60 Hz and broadcasts a **binary** `Float32Array` world snapshot with `sendBinaryToClient`.
4
+
5
+ When `AGENT_REDIS_URL` is set (Project Redis), the world blob lives at `game-sync:world` and is shared across runner workers. Redis GET bytes are copied into the one in-memory `Float32Array` (the GET reply is the only per-tick allocation); sim, broadcast, and Redis `SET` all use one persistent `Buffer` view of that array. The tick itself is allocation-free: live object ids are collected into a reusable `Int32Array`, tick bodies are module-level functions (no per-tick closures or option objects), and the broadcast is a single `broadCastBinaryToClients` call over a live session array. Without Redis, state is per-worker in memory.
6
+
7
+ This is the most advanced world-sync starter: Redis + binary snapshots + server-side physics.
8
+
9
+ ## Build
10
+
11
+ ```bash
12
+ npx @voicethere/agent build --entry templates/game-sync/agent.ts --outfile dist/agent.js
13
+ ```
14
+
15
+ ## Sources
16
+
17
+ - `agent.ts` — `defineAgent` wiring, register/unregister, sim loop
18
+ - `protocol.ts` — JSON control messages
19
+ - `world-layout.ts` — Float32Array slot layout
20
+ - `sim.ts` — wall bounce + collisions
21
+ - `redis.ts` — Lua allocate/release
@@ -30,13 +30,13 @@
30
30
  * - 60Hz world-state broadcast while the sim loop runs (no-op send when 0 sessions)
31
31
  *
32
32
  * Build:
33
- * npx @voicethere/agent build --entry templates/game-sync.ts
33
+ * npx @voicethere/agent build --entry templates/game-sync/agent.ts
34
34
  */
35
- import Redis from "ioredis";
35
+ import { Redis } from "ioredis";
36
36
  import {
37
37
  agentLog,
38
+ broadCastBinaryToClients,
38
39
  defineAgent,
39
- sendBinaryToClient,
40
40
  sendToClient,
41
41
  } from "@voicethere/agent";
42
42
 
@@ -48,21 +48,21 @@ import {
48
48
  resolveRemoveTarget,
49
49
  REGISTER_NACK_REASON_WORLD_FULL,
50
50
  UNREGISTER_NACK_REASON_NOT_FOUND,
51
- } from "./game-sync-protocol.js";
51
+ } from "./protocol.js";
52
52
  import {
53
53
  LUA_ALLOCATE_OBJECT,
54
54
  LUA_RELEASE_OBJECT,
55
55
  REDIS_EVAL_KEYS,
56
- } from "./game-sync-redis.js";
56
+ } from "./redis.js";
57
57
  import {
58
58
  BOARD_HEIGHT,
59
59
  BOARD_WIDTH,
60
60
  OBJECT_RADIUS,
61
61
  simulateWorldStep,
62
- } from "./game-sync-sim.js";
62
+ } from "./sim.js";
63
63
  import {
64
64
  clampSimulationDtSec,
65
- collectActiveObjectIds,
65
+ collectActiveObjectIdsInto,
66
66
  countLiveObjects,
67
67
  createEmptyWorldBuffer,
68
68
  findFirstEmptySlot,
@@ -70,13 +70,12 @@ import {
70
70
  markSlotFree,
71
71
  normalizeWorldBuffer,
72
72
  objectIdToSlot,
73
- OBJECT_SLOT_BYTE_LENGTH,
74
73
  planRedisSimTick,
75
74
  REDIS_SIM_LOCK_KEY,
76
75
  REDIS_WORLD_KEY,
77
76
  slotToObjectId,
78
77
  writeObjectSlot,
79
- } from "./game-sync-world-layout.js";
78
+ } from "./world-layout.js";
80
79
 
81
80
  const BROADCAST_HZ = 60;
82
81
  const BROADCAST_INTERVAL_MS = Math.floor(1000 / BROADCAST_HZ);
@@ -86,11 +85,19 @@ const MIN_SPEED = 90;
86
85
  const MAX_SPEED = 180;
87
86
 
88
87
  const connectedSessions = new Set<string>();
88
+ /** Same members as connectedSessions; array form lets the broadcast skip Set iteration. */
89
+ const connectedSessionList: string[] = [];
89
90
  const objectOwners = new Map<number, string>();
90
91
  const sessionObjects = new Map<string, Set<number>>();
91
92
  const freeSlots: number[] = [];
92
93
 
93
- let worldState = createEmptyWorldBuffer();
94
+ /** The one world buffer: Redis loads copy into it, sim/broadcast/SET read from it. */
95
+ const worldState = createEmptyWorldBuffer();
96
+ const worldStateBytes = Buffer.from(
97
+ worldState.buffer,
98
+ worldState.byteOffset,
99
+ worldState.byteLength,
100
+ );
94
101
  let redis: Redis | null = null;
95
102
  let broadcastTimer: NodeJS.Timeout | null = null;
96
103
  let worldMutationChain: Promise<void> = Promise.resolve();
@@ -151,18 +158,23 @@ function randomVelocity(): number {
151
158
  return (Math.random() < 0.5 ? -1 : 1) * rand(MIN_SPEED, MAX_SPEED);
152
159
  }
153
160
 
161
+ const INITIAL_TAIL = new Float32Array(8);
162
+ const INITIAL_TAIL_BUF = Buffer.from(
163
+ INITIAL_TAIL.buffer,
164
+ INITIAL_TAIL.byteOffset,
165
+ INITIAL_TAIL.byteLength,
166
+ );
167
+
154
168
  function randomInitialTail(): Buffer {
155
- const floats = new Float32Array([
156
- rand(OBJECT_RADIUS, BOARD_WIDTH - OBJECT_RADIUS),
157
- rand(OBJECT_RADIUS, BOARD_HEIGHT - OBJECT_RADIUS),
158
- 0,
159
- 1,
160
- randomVelocity(),
161
- randomVelocity(),
162
- 0,
163
- 0,
164
- ]);
165
- return Buffer.from(floats.buffer, floats.byteOffset, floats.byteLength);
169
+ INITIAL_TAIL[0] = rand(OBJECT_RADIUS, BOARD_WIDTH - OBJECT_RADIUS);
170
+ INITIAL_TAIL[1] = rand(OBJECT_RADIUS, BOARD_HEIGHT - OBJECT_RADIUS);
171
+ INITIAL_TAIL[2] = 0;
172
+ INITIAL_TAIL[3] = 1;
173
+ INITIAL_TAIL[4] = randomVelocity();
174
+ INITIAL_TAIL[5] = randomVelocity();
175
+ INITIAL_TAIL[6] = 0;
176
+ INITIAL_TAIL[7] = 0;
177
+ return INITIAL_TAIL_BUF;
166
178
  }
167
179
 
168
180
  function attachObjectToSession(sessionId: string, objectId: number): void {
@@ -259,7 +271,7 @@ async function registerObjectInRedis(
259
271
  }
260
272
 
261
273
  attachObjectToSession(sessionId, objectId);
262
- worldState = await loadWorldFromRedis();
274
+ await loadWorldFromRedis();
263
275
  return objectId;
264
276
  }
265
277
 
@@ -281,7 +293,7 @@ async function releaseObjectInRedis(objectId: number): Promise<boolean> {
281
293
  REDIS_EVAL_KEYS.headers,
282
294
  );
283
295
  if (Number(released) === 1) {
284
- worldState = await loadWorldFromRedis();
296
+ await loadWorldFromRedis();
285
297
  }
286
298
  return Number(released) === 1;
287
299
  }
@@ -345,93 +357,109 @@ function notifyObjectReleased(objectId: number, ownerSessionId: string): void {
345
357
  }
346
358
  }
347
359
 
348
- function copyWorldBuffer(world: Float32Array): Buffer {
349
- return Buffer.from(world.buffer, world.byteOffset, world.byteLength);
360
+ function broadcastWorldBuffer(): void {
361
+ if (connectedSessionList.length === 0) return;
362
+ // worldStateBytes is already a Buffer: forwarded as-is, one call, no iterator.
363
+ broadCastBinaryToClients(worldStateBytes, connectedSessionList, "sync");
350
364
  }
351
365
 
352
- function broadcastWorldBuffer(world: Float32Array): void {
353
- if (connectedSessions.size === 0) return;
354
- const payload = copyWorldBuffer(world);
355
- for (const sessionId of connectedSessions) {
356
- sendBinaryToClient(sessionId, payload, "sync");
357
- }
358
- }
359
-
360
- async function loadWorldFromRedis(): Promise<Float32Array> {
361
- if (!redis) {
362
- return new Float32Array(worldState);
363
- }
366
+ /**
367
+ * Copy the Redis blob into `worldState` in place (the GET reply is the only
368
+ * per-tick allocation; it is a fresh socket Buffer we cannot avoid).
369
+ */
370
+ async function loadWorldFromRedis(): Promise<void> {
371
+ if (!redis) return;
364
372
  const raw = await redis.getBuffer(REDIS_WORLD_KEY);
365
- return normalizeWorldBuffer(raw);
373
+ normalizeWorldBuffer(raw, worldState);
366
374
  }
367
375
 
368
- async function saveWorldToRedis(world: Float32Array): Promise<void> {
376
+ async function saveWorldToRedis(): Promise<void> {
369
377
  if (!redis) return;
370
- await redis.set(REDIS_WORLD_KEY, copyWorldBuffer(world));
378
+ await redis.set(REDIS_WORLD_KEY, worldStateBytes);
371
379
  }
372
380
 
373
- async function runSimulationTick(): Promise<void> {
381
+ /** Live object ids for the current tick; filled in place, never reallocated. */
382
+ const activeObjectIds = new Int32Array(MAX_LIVE_OBJECTS);
383
+
384
+ const SIM_LOCK_TRY_ONCE = { retryUntilAcquired: false } as const;
385
+ const SIM_TICK_PLAN_INPUT = { lockAcquired: false, connectedSessions } as const;
386
+
387
+ /**
388
+ * Elapsed seconds since the previous tick body ran. Measured inside the
389
+ * serialized mutation chain so queued ticks never share or overwrite a dt.
390
+ */
391
+ function takeTickDtSec(): number {
374
392
  const now = Date.now();
375
393
  const dt = clampSimulationDtSec(
376
394
  lastTickTimeMs === 0 ? 0 : now - lastTickTimeMs,
377
395
  BROADCAST_HZ,
378
396
  );
379
397
  lastTickTimeMs = now;
398
+ return dt;
399
+ }
380
400
 
401
+ function stepWorldInPlace(): void {
402
+ const dt = takeTickDtSec();
403
+ const count = collectActiveObjectIdsInto(worldState, activeObjectIds);
404
+ simulateWorldStep(worldState, dt, activeObjectIds, count);
405
+ }
406
+
407
+ async function stepWorldInMemory(): Promise<void> {
408
+ stepWorldInPlace();
409
+ }
410
+
411
+ async function stepWorldWithRedis(): Promise<boolean> {
412
+ await loadWorldFromRedis();
413
+ stepWorldInPlace();
414
+ await saveWorldToRedis();
415
+ broadcastWorldBuffer();
416
+ return true;
417
+ }
418
+
419
+ async function runRedisTick(): Promise<void> {
420
+ const lockResult = await withRedisSimLock(
421
+ stepWorldWithRedis,
422
+ SIM_LOCK_TRY_ONCE,
423
+ );
424
+ if (lockResult !== null) return;
425
+ takeTickDtSec();
426
+ if (planRedisSimTick(SIM_TICK_PLAN_INPUT) === "relay") {
427
+ await loadWorldFromRedis();
428
+ broadcastWorldBuffer();
429
+ }
430
+ }
431
+
432
+ // Tick bodies are module-level functions (no per-tick closures or option objects).
433
+ async function runSimulationTick(): Promise<void> {
381
434
  if (!redis) {
382
- await withWorldMutation(async () => {
383
- simulateWorldStep(worldState, dt, collectActiveObjectIds(worldState));
384
- });
385
- broadcastWorldBuffer(worldState);
435
+ await withWorldMutation(stepWorldInMemory);
436
+ broadcastWorldBuffer();
386
437
  return;
387
438
  }
439
+ await withWorldMutation(runRedisTick);
440
+ }
388
441
 
389
- await withWorldMutation(async () => {
390
- const lockResult = await withRedisSimLock(
391
- async () => {
392
- const world = await loadWorldFromRedis();
393
- const activeObjectIds = collectActiveObjectIds(world);
394
- simulateWorldStep(world, dt, activeObjectIds);
395
- await saveWorldToRedis(world);
396
- worldState = world;
397
- broadcastWorldBuffer(world);
398
- return true;
399
- },
400
- { retryUntilAcquired: false },
401
- );
442
+ function logTickError(error: unknown): void {
443
+ const detail = error instanceof Error ? error.message : String(error);
444
+ agentLog("error", `world tick failed: ${detail}`);
445
+ }
402
446
 
403
- if (lockResult === null) {
404
- const plan = planRedisSimTick({
405
- lockAcquired: false,
406
- connectedSessions,
407
- });
408
- if (plan === "relay") {
409
- const world = await loadWorldFromRedis();
410
- worldState = world;
411
- broadcastWorldBuffer(world);
412
- }
447
+ function onBroadcastTick(): void {
448
+ if (!redis && connectedSessions.size < 1) {
449
+ if (broadcastTimer) {
450
+ clearInterval(broadcastTimer);
451
+ broadcastTimer = null;
413
452
  }
414
- });
453
+ return;
454
+ }
455
+ void runSimulationTick().catch(logTickError);
415
456
  }
416
457
 
417
458
  function startBroadcastLoopIfNeeded(): void {
418
459
  if (broadcastTimer) return;
419
460
  if (!redis && connectedSessions.size < 1) return;
420
461
 
421
- broadcastTimer = setInterval(() => {
422
- if (!redis && connectedSessions.size < 1) {
423
- if (broadcastTimer) {
424
- clearInterval(broadcastTimer);
425
- broadcastTimer = null;
426
- }
427
- return;
428
- }
429
- void runSimulationTick().catch((error: unknown) => {
430
- const detail = error instanceof Error ? error.message : String(error);
431
- agentLog("error", `world tick failed: ${detail}`);
432
- });
433
- }, BROADCAST_INTERVAL_MS);
434
-
462
+ broadcastTimer = setInterval(onBroadcastTick, BROADCAST_INTERVAL_MS);
435
463
  agentLog("info", `world loop started (${BROADCAST_HZ}Hz)`);
436
464
  }
437
465
 
@@ -448,8 +476,7 @@ async function ensureRedisWorldInitialized(): Promise<void> {
448
476
  if (!redis) return;
449
477
  const existing = await redis.getBuffer(REDIS_WORLD_KEY);
450
478
  if (!existing || existing.byteLength === 0) {
451
- const empty = createEmptyWorldBuffer();
452
- await redis.set(REDIS_WORLD_KEY, copyWorldBuffer(empty));
479
+ await redis.set(REDIS_WORLD_KEY, worldStateBytes);
453
480
  }
454
481
  }
455
482
 
@@ -461,7 +488,6 @@ defineAgent({
461
488
  "warn",
462
489
  "AGENT_REDIS_URL unset — game-sync uses per-worker in-memory world only",
463
490
  );
464
- worldState = createEmptyWorldBuffer();
465
491
  return;
466
492
  }
467
493
 
@@ -471,17 +497,18 @@ defineAgent({
471
497
  });
472
498
  await redis.connect();
473
499
  await ensureRedisWorldInitialized();
474
- worldState = await loadWorldFromRedis();
500
+ await loadWorldFromRedis();
475
501
  startBroadcastLoopIfNeeded();
476
502
  agentLog("info", "game-sync agent connected to project Redis world buffer");
477
503
  },
478
504
 
479
505
  async onClientJoin({ sessionId }) {
480
- connectedSessions.add(sessionId);
481
- startBroadcastLoopIfNeeded();
482
- if (redis) {
483
- worldState = await loadWorldFromRedis();
506
+ if (!connectedSessions.has(sessionId)) {
507
+ connectedSessions.add(sessionId);
508
+ connectedSessionList.push(sessionId);
484
509
  }
510
+ startBroadcastLoopIfNeeded();
511
+ await loadWorldFromRedis();
485
512
  sendToClient(sessionId, {
486
513
  type: "world_snapshot",
487
514
  objects: liveWorldSnapshot(worldState, objectOwners),
@@ -490,7 +517,12 @@ defineAgent({
490
517
  },
491
518
 
492
519
  async onClientLeave({ sessionId }) {
493
- connectedSessions.delete(sessionId);
520
+ if (connectedSessions.delete(sessionId)) {
521
+ const index = connectedSessionList.indexOf(sessionId);
522
+ if (index !== -1) {
523
+ connectedSessionList.splice(index, 1);
524
+ }
525
+ }
494
526
  for (const [objectId, ownerSessionId] of objectOwners) {
495
527
  if (ownerSessionId === sessionId) {
496
528
  objectOwners.delete(objectId);
@@ -587,5 +619,5 @@ export {
587
619
  readSlotObjectId,
588
620
  slotToObjectId,
589
621
  writeObjectSlot,
590
- } from "./game-sync-world-layout.js";
591
- export { simulateWorldStep } from "./game-sync-sim.js";
622
+ } from "./world-layout.js";
623
+ export { simulateWorldStep } from "./sim.js";