archetype-ecs-net 0.1.1 → 0.2.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.
package/README.md CHANGED
@@ -1,287 +1,331 @@
1
- <p align="center">
2
- <br>
3
- <img src="https://em-content.zobj.net/source/apple/391/satellite-antenna_1f4e1.png" width="80" />
4
- <br><br>
5
- <strong>archetype-ecs-net</strong>
6
- <br>
7
- <sub>Binary delta sync over WebSocket for archetype-ecs.</sub>
8
- <br><br>
9
- <a href="https://www.npmjs.com/package/archetype-ecs-net"><img src="https://img.shields.io/npm/v/archetype-ecs-net.svg?style=flat-square&color=000" alt="npm" /></a>
10
- <a href="https://github.com/RvRooijen/archetype-ecs-net/blob/master/LICENSE"><img src="https://img.shields.io/npm/l/archetype-ecs-net.svg?style=flat-square&color=000" alt="license" /></a>
11
- </p>
12
-
13
- ---
14
-
15
- Network layer for [archetype-ecs](https://github.com/RvRooijen/archetype-ecs). Tag entities with `Networked`, call `tick()` every frame — clients get binary deltas automatically.
16
-
17
- ```
18
- npm i archetype-ecs-net
19
- ```
20
-
21
- ---
22
-
23
- ### The full picture in 30 lines
24
-
25
- ```ts
26
- import { createEntityManager, component } from 'archetype-ecs'
27
- import { createComponentRegistry, createNetServer, Networked } from 'archetype-ecs-net'
28
-
29
- const Position = component('Position', { x: 'f32', y: 'f32' })
30
- const Velocity = component('Velocity', { vx: 'f32', vy: 'f32' })
31
-
32
- const registry = createComponentRegistry([
33
- { component: Position, name: 'Position' },
34
- { component: Velocity, name: 'Velocity' },
35
- ])
36
-
37
- const em = createEntityManager()
38
-
39
- // Spawn networked entities
40
- for (let i = 0; i < 1000; i++) {
41
- em.createEntityWith(
42
- Position, { x: Math.random() * 800, y: Math.random() * 600 },
43
- Velocity, { vx: 1, vy: 1 },
44
- Networked,
45
- )
46
- }
47
-
48
- const server = createNetServer(em, registry, { port: 9001 })
49
- await server.start()
50
-
51
- // Game loop
52
- setInterval(() => {
53
- em.forEach([Position, Velocity], (a) => {
54
- const px = a.field(Position.x), py = a.field(Position.y)
55
- const vx = a.field(Velocity.vx), vy = a.field(Velocity.vy)
56
- for (let i = 0; i < a.count; i++) { px[i] += vx[i]; py[i] += vy[i] }
57
- })
58
- server.tick() // diff → encode → broadcast
59
- }, 50)
60
- ```
61
-
62
- Game systems don't need to change — `tick()` diffs the raw TypedArrays against a double-buffered snapshot.
63
-
64
- ---
65
-
66
- ### How it works
67
-
68
- ```
69
- Game systems write to TypedArrays (front buffer)
70
-
71
-
72
- server.tick()
73
-
74
- ┌──────┴──────┐
75
- diff front │ Compare front[i] !== back[i] per field
76
- │ vs back │ Only iterates Networked archetypes
77
- └──────┬──────┘
78
-
79
- ┌──────┴──────┐
80
- │ encode │ Binary protocol: u8 wire IDs, field bitmasks
81
- └──────┬──────┘
82
-
83
- ┌──────┴──────┐
84
- │ broadcast │ WebSocket binary frames
85
- └──────┬──────┘
86
-
87
- ┌──────┴──────┐
88
- │ flush │ .set() memcpy front → back per field
89
- └─────────────┘
90
- ```
91
-
92
- Each tracked archetype keeps a back-buffer copy of its TypedArrays. `flushSnapshots()` copies front→back with `.set()` per field.
93
-
94
- ---
95
-
96
- ### Client
97
-
98
- ```ts
99
- import { createEntityManager, component } from 'archetype-ecs'
100
- import { createComponentRegistry, createNetClient } from 'archetype-ecs-net'
101
-
102
- // Same components, same registration order as server
103
- const Position = component('Position', { x: 'f32', y: 'f32' })
104
- const Velocity = component('Velocity', { vx: 'f32', vy: 'f32' })
105
-
106
- const registry = createComponentRegistry([
107
- { component: Position, name: 'Position' },
108
- { component: Velocity, name: 'Velocity' },
109
- ])
110
-
111
- const em = createEntityManager()
112
- const client = createNetClient(em, registry)
113
-
114
- client.onConnected = () => console.log('connected')
115
- client.connect('ws://localhost:9001')
116
-
117
- // On connect: receives full state snapshot
118
- // Every tick: receives binary delta, auto-applied to local EM
119
- ```
120
-
121
- The client uses the browser `WebSocket` API — no server-side dependencies needed client-side.
122
-
123
- ---
124
-
125
- ### Component registry
126
-
127
- Both server and client must register the same components in the same order. This assigns stable `u8` wire IDs (0–255) used in the binary protocol. Each component supports up to 16 fields (u16 bitmask).
128
-
129
- ```ts
130
- const registry = createComponentRegistry([
131
- { component: Position, name: 'Position' }, // wireId 0
132
- { component: Velocity, name: 'Velocity' }, // wireId 1
133
- { component: Health, name: 'Health' }, // wireId 2
134
- ])
135
- ```
136
-
137
- Field types are introspected from the component schema — `f32`, `i32`, `string`, etc. — and encoded with their native byte size on the wire.
138
-
139
- ---
140
-
141
- ### Networked tag
142
-
143
- Only entities with the `Networked` tag component are tracked and synced:
144
-
145
- ```ts
146
- import { Networked } from 'archetype-ecs-net'
147
-
148
- // This entity is synced
149
- em.createEntityWith(Position, { x: 0, y: 0 }, Networked)
150
-
151
- // This entity is local-only
152
- em.createEntityWith(Position, { x: 0, y: 0 })
153
- ```
154
-
155
- Removing the `Networked` tag triggers a destroy on all clients. Adding it triggers a create.
156
-
157
- ---
158
-
159
- ### Binary protocol
160
-
161
- Binary format. Field values are written with their native byte size, no JSON encoding.
162
-
163
- **Full state** (sent on client connect):
164
- ```
165
- [u8 0x01] [u32 registryHash] [u16 entityCount]
166
- for each: [varint netId] [u8 componentCount]
167
- for each: [u8 wireId] [field values in schema order]
168
- ```
169
-
170
- **Delta** (sent every tick):
171
- ```
172
- [u8 0x02]
173
- [u16 createdCount] → varint netId + full component data per entity
174
- [u16 destroyedCount] varint netIds only
175
- [u16 updatedEntityCount]
176
- for each: [varint netId] [u8 compCount]
177
- for each: [u8 wireId] [u16 fieldMask] [changed field values]
178
- ```
179
-
180
- Network IDs (`netId`) are varint-encoded (LEB128) 1 byte for IDs < 128, 2 bytes for < 16K. Updates are grouped per entity to avoid repeating the netId for each dirty component. The wire protocol uses stable netIds instead of raw entity IDs; the client maintains a `netId → localEntityId` mapping.
181
-
182
- Only changed fields are sent per entity — if only `Position.x` changed, `Position.y` stays off the wire.
183
-
184
- ---
185
-
186
- ### Pluggable transport
187
-
188
- Default transport uses `ws`. You can provide your own `ServerTransport`:
189
-
190
- ```ts
191
- import { createNetServer, createWsTransport } from 'archetype-ecs-net'
192
-
193
- // Default
194
- const server = createNetServer(em, registry, { port: 9001 })
195
-
196
- // Custom transport
197
- const server = createNetServer(em, registry, { port: 9001 }, myTransport)
198
- ```
199
-
200
- ```ts
201
- interface ServerTransport {
202
- start(port: number, handlers: TransportHandlers): Promise<void>
203
- stop(): Promise<void>
204
- send(clientId: ClientId, data: ArrayBuffer): void
205
- broadcast(data: ArrayBuffer): void
206
- }
207
- ```
208
-
209
- ---
210
-
211
- ### Example
212
-
213
- **[RPG demo](example/)** Full RPG with interest management, pathfinding, chunk-based visibility
214
-
215
- ```bash
216
- npm run build # build dist/ for browser imports
217
- npx tsx example/server/main.ts # start server
218
- # open example/client.html in browser (serve from project root)
219
- ```
220
-
221
- ---
222
-
223
- ## Benchmarks
224
-
225
- 1M entities across 4 archetypes (Players 50k/6c, Projectiles 250k/3c, NPCs 100k/5c, Static 600k/2c), 6 registered components, 3 game systems per tick. Termux/Android (aarch64):
226
-
227
- | Test | ms/frame | overhead | wire |
228
- |---|---:|---:|---:|
229
- | Raw game tick (1M, 4 archetypes) | 3.51 | baseline | |
230
- | + diffAndEncode (4k networked, 1%) | 5.59 | +2.08ms | 97 KB |
231
- | + diffAndEncode (40k networked, 10%) | 23.96 | +20.45ms | 995 KB |
232
-
233
- `diffAndEncode()` diffs and encodes in a single fused pass — no intermediate allocations, varint netIds, updates grouped per entity. At 1% networked (4k entities), the total overhead is 2.1ms — well within a 60fps budget.
234
-
235
- Run them yourself:
236
-
237
- ```bash
238
- npx tsx bench/net-bench.ts
239
- ```
240
-
241
- ---
242
-
243
- ## API reference
244
-
245
- ### `createComponentRegistry(registrations)`
246
-
247
- Create a registry mapping components to wire IDs. Must be identical on server and client.
248
-
249
- ### `Networked`
250
-
251
- Tag component. Add to any entity that should be synced over the network.
252
-
253
- ### `createNetServer(em, registry, config, transport?)`
254
-
255
- Create a network server that diffs and broadcasts on every `tick()`.
256
-
257
- | Method / Property | Description |
258
- |---|---|
259
- | `start()` | Start listening on configured port |
260
- | `stop()` | Stop server, disconnect all clients |
261
- | `tick(filter?)` | Diff → encode → send. No filter = broadcast. With filter = per-client interest |
262
- | `send(clientId, data)` | Send a custom message to a specific client |
263
- | `clientCount` | Number of connected clients |
264
- | `entityNetIds` | `ReadonlyMap<EntityId, number>` entity → netId mapping |
265
- | `onConnect` | Callback when client connects (receives full state) |
266
- | `onDisconnect` | Callback when client disconnects |
267
- | `onMessage` | Callback when client sends a message |
268
-
269
- The `filter` parameter is an `InterestFilter: (clientId) => ReadonlySet<number>` that returns the set of netIds visible to that client. Entities entering/leaving a client's interest set are sent as creates/destroys.
270
-
271
- ### `createNetClient(em, registry)`
272
-
273
- Create a client that connects via WebSocket and auto-applies received state.
274
-
275
- | Method / Property | Description |
276
- |---|---|
277
- | `connect(url)` | Connect to server |
278
- | `disconnect()` | Close connection |
279
- | `connected` | Whether currently connected |
280
- | `onConnected` | Callback on successful connection |
281
- | `onDisconnected` | Callback on disconnect |
282
-
283
- ---
284
-
285
- ## License
286
-
287
- MIT
1
+ <p align="center">
2
+ <br>
3
+ <img src="https://em-content.zobj.net/source/apple/391/satellite-antenna_1f4e1.png" width="80" />
4
+ <br><br>
5
+ <strong>archetype-ecs-net</strong>
6
+ <br>
7
+ <sub>Binary delta sync over WebSocket for archetype-ecs.</sub>
8
+ <br><br>
9
+ <a href="https://www.npmjs.com/package/archetype-ecs-net"><img src="https://img.shields.io/npm/v/archetype-ecs-net.svg?style=flat-square&color=000" alt="npm" /></a>
10
+ <a href="https://github.com/RvRooijen/archetype-ecs-net/blob/master/LICENSE"><img src="https://img.shields.io/npm/l/archetype-ecs-net.svg?style=flat-square&color=000" alt="license" /></a>
11
+ </p>
12
+
13
+ ---
14
+
15
+ Network layer for [archetype-ecs](https://github.com/RvRooijen/archetype-ecs). Tag entities with `Networked`, call `tick()` every frame — clients get binary deltas automatically.
16
+
17
+ ```
18
+ npm i archetype-ecs-net
19
+ ```
20
+
21
+ ---
22
+
23
+ ## Shared
24
+
25
+ Both server and client register the same components in the same order. This assigns stable wire IDs used in the binary protocol.
26
+
27
+ ```ts
28
+ import { component } from 'archetype-ecs'
29
+ import { createComponentRegistry } from 'archetype-ecs-net'
30
+
31
+ const Position = component('Pos', 'f32', ['x', 'y'])
32
+ const Velocity = component('Vel', 'f32', ['vx', 'vy'])
33
+ const Attacking = component('Atk', 'i16', ['targetX', 'targetY'])
34
+
35
+ const registry = createComponentRegistry([
36
+ { component: Position, name: 'Position', clientOwned: true }, // wireId 0
37
+ { component: Velocity, name: 'Velocity' }, // wireId 1
38
+ { component: Attacking, name: 'Attacking', clientOwned: true }, // wireId 2
39
+ ])
40
+ ```
41
+
42
+ Add the `Networked` tag to any entity that should sync. Entities without it stay local. Mark components as `clientOwned` to let clients write to them — field changes, attaches, and detaches auto-sync back to the server.
43
+
44
+ ```ts
45
+ import { Networked } from 'archetype-ecs-net'
46
+
47
+ em.createEntityWith(Position, { x: 0, y: 0 }, Networked) // synced
48
+ em.createEntityWith(Position, { x: 0, y: 0 }) // local-only
49
+ ```
50
+
51
+ ---
52
+
53
+ ## Server
54
+
55
+ ```ts
56
+ import { createEntityManager } from 'archetype-ecs'
57
+ import { createNetServer, Networked } from 'archetype-ecs-net'
58
+
59
+ const em = createEntityManager()
60
+
61
+ for (let i = 0; i < 1000; i++) {
62
+ em.createEntityWith(
63
+ Position, { x: Math.random() * 800, y: Math.random() * 600 },
64
+ Velocity, { vx: 1, vy: 1 },
65
+ Networked,
66
+ )
67
+ }
68
+
69
+ const server = createNetServer(em, registry, { port: 9001 })
70
+ server.onConnect = (clientId) => { /* spawn player, etc. */ }
71
+ await server.start()
72
+
73
+ setInterval(() => {
74
+ // game systems — write to ECS as usual
75
+ em.forEach([Position, Velocity], (a) => {
76
+ const px = a.field(Position.x), py = a.field(Position.y)
77
+ const vx = a.field(Velocity.vx), vy = a.field(Velocity.vy)
78
+ for (let i = 0; i < a.count; i++) { px[i] += vx[i]; py[i] += vy[i] }
79
+ })
80
+
81
+ server.tick() // diff → encode → broadcast
82
+ }, 50)
83
+ ```
84
+
85
+ Game systems don't need to change — `tick()` diffs the raw TypedArrays against a double-buffered snapshot.
86
+
87
+ #### How tick() works
88
+
89
+ ```
90
+ Game systems write to TypedArrays (front buffer)
91
+
92
+
93
+ server.tick()
94
+
95
+ ┌──────┴──────┐
96
+ │ diff front │ Compare front[i] !== back[i] per field
97
+ │ vs back │ Only iterates Networked archetypes
98
+ └──────┬──────┘
99
+
100
+ ┌──────┴──────┐
101
+ │ encode │ Binary protocol: u8 wire IDs, field bitmasks
102
+ └──────┬──────┘
103
+
104
+ ┌──────┴──────┐
105
+ │ broadcast │ WebSocket binary frames
106
+ └──────┬──────┘
107
+
108
+ ┌──────┴──────┐
109
+ │ flush │ .set() memcpy front → back per field
110
+ └─────────────┘
111
+ ```
112
+
113
+ #### Interest management
114
+
115
+ Pass a filter to `tick()` to send per-client views. Entities entering/leaving a client's interest set are sent as creates/destroys.
116
+
117
+ ```ts
118
+ server.tick((clientId) => {
119
+ const visible = new Set<number>()
120
+ // ... add netIds of entities visible to this client
121
+ return visible
122
+ })
123
+ ```
124
+
125
+ #### Pluggable transport
126
+
127
+ Default transport uses `ws`. Provide your own `ServerTransport` to swap it out:
128
+
129
+ ```ts
130
+ const server = createNetServer(em, registry, { port: 9001 }, myTransport)
131
+ ```
132
+
133
+ ---
134
+
135
+ ## Client
136
+
137
+ ```ts
138
+ import { createEntityManager } from 'archetype-ecs'
139
+ import { createNetClient } from 'archetype-ecs-net'
140
+
141
+ const em = createEntityManager()
142
+ const client = createNetClient(em, registry)
143
+
144
+ client.onConnected = () => console.log('connected')
145
+ client.connect('ws://localhost:9001')
146
+
147
+ // On connect: full state snapshot → entities created in local EM
148
+ // Every tick: binary delta → entities created/destroyed/updated automatically
149
+
150
+ // Read synced state via standard ECS queries
151
+ em.forEach([Position], (a) => {
152
+ const px = a.field(Position.x)
153
+ for (let i = 0; i < a.count; i++) { /* render px[i] ... */ }
154
+ })
155
+
156
+ // Find your own entity
157
+ for (const [netId, eid] of client.netToEntity) { /* ... */ }
158
+
159
+ // Write to client-owned components — synced automatically
160
+ em.set(myEid, Position.x, 10)
161
+
162
+ // Attach/detach client-owned components — synced as attach/detach deltas
163
+ em.addComponent(myEid, Attacking, { targetX: 5, targetY: 3 })
164
+ em.removeComponent(myEid, Attacking)
165
+
166
+ // Game loop: tick() diffs client-owned components and sends to server
167
+ function loop() {
168
+ client.tick() // diff → encode → send client-owned changes
169
+ render()
170
+ requestAnimationFrame(loop)
171
+ }
172
+ ```
173
+
174
+ The client uses the browser `WebSocket` API — works in any browser, no server-side dependencies needed.
175
+
176
+ ---
177
+
178
+ ## Wire protocol
179
+
180
+ Binary format. Field values are written with their native byte size, no JSON.
181
+
182
+ **Full state** (sent on connect):
183
+ ```
184
+ [u8 0x01] [u32 registryHash] [u16 entityCount]
185
+ for each: [varint netId] [u8 componentCount]
186
+ for each: [u8 wireId] [field values in schema order]
187
+ ```
188
+
189
+ **Delta** (sent every tick):
190
+ ```
191
+ [u8 0x02]
192
+ [u16 createdCount] → varint netId + full component data per entity
193
+ [u16 destroyedCount] → varint netIds only
194
+ [u16 updatedEntityCount]
195
+ for each: [varint netId] [u8 compCount]
196
+ for each: [u8 wireId] [u16 fieldMask] [changed field values]
197
+ [u16 attachedEntityCount]
198
+ for each: [varint netId] [u8 wireCount]
199
+ for each: [u8 wireId] [field values in schema order]
200
+ [u16 detachedEntityCount]
201
+ for each: [varint netId] [u8 wireCount]
202
+ for each: [u8 wireId]
203
+ ```
204
+
205
+ **Client delta** (sent by client for `clientOwned` components):
206
+ ```
207
+ [u8 0x03]
208
+ [u16 updatedEntityCount]
209
+ for each: [varint netId] [u8 compCount]
210
+ for each: [u8 wireId] [u16 fieldMask] [changed field values]
211
+ [u16 attachedEntityCount]
212
+ for each: [varint netId] [u8 wireCount]
213
+ for each: [u8 wireId] [field values in schema order]
214
+ [u16 detachedEntityCount]
215
+ for each: [varint netId] [u8 wireCount]
216
+ for each: [u8 wireId]
217
+ ```
218
+
219
+ NetIds are varint-encoded (LEB128) — 1 byte for IDs < 128, 2 bytes for < 16K. Only changed fields are sent — if only `Position.x` changed, `Position.y` stays off the wire. Attached/detached sections track components added to or removed from existing entities — `addComponent()` and `removeComponent()` are detected automatically.
220
+
221
+ ---
222
+
223
+ ### Example
224
+
225
+ **[RPG demo](example/)** Full multiplayer RPG with interest management, pathfinding, chunk-based visibility. Server and client share the same components and registry.
226
+
227
+ ```bash
228
+ npm run dev # starts game server + vite, opens browser
229
+ ```
230
+
231
+ ---
232
+
233
+ ## Benchmarks
234
+
235
+ 1M entities across 4 archetypes (Players 50k/6c, Projectiles 250k/3c, NPCs 100k/5c, Static 600k/2c), 6 registered components, 3 game systems per tick. Termux/Android (aarch64):
236
+
237
+ | Test | ms/frame | overhead | wire |
238
+ |---|---:|---:|---:|
239
+ | Raw game tick (1M, 4 archetypes) | 3.51 | baseline | |
240
+ | + diffAndEncode (4k networked, 1%) | 5.59 | +2.08ms | 97 KB |
241
+ | + diffAndEncode (40k networked, 10%) | 23.96 | +20.45ms | 995 KB |
242
+
243
+ `diffAndEncode()` diffs and encodes in a single fused pass — no intermediate allocations, varint netIds, updates grouped per entity. At 1% networked (4k entities), the total overhead is 2.1ms — well within a 60fps budget.
244
+
245
+ Run them yourself:
246
+
247
+ ```bash
248
+ npx tsx bench/net-bench.ts
249
+ ```
250
+
251
+ ---
252
+
253
+ ## API reference
254
+
255
+ ### Shared
256
+
257
+ | Export | Description |
258
+ |---|---|
259
+ | `createComponentRegistry(registrations)` | Create a registry mapping components to wire IDs. Set `clientOwned: true` per component for client→server sync. Must be identical on server and client. |
260
+ | `Networked` | Tag component. Add to any entity that should be synced. |
261
+
262
+ ### Server — `createNetServer(em, registry, config, transport?, options?)`
263
+
264
+ | Method / Property | Description |
265
+ |---|---|
266
+ | `start()` | Start listening on configured port |
267
+ | `stop()` | Stop server, disconnect all clients |
268
+ | `tick(filter?)` | Diff → encode → send. No filter = broadcast. With filter = per-client interest |
269
+ | `send(clientId, data)` | Send a custom message to a specific client |
270
+ | `clientCount` | Number of connected clients |
271
+ | `entityNetIds` | `ReadonlyMap<EntityId, number>` — entity → netId mapping |
272
+ | `onConnect` | Callback when client connects |
273
+ | `onDisconnect` | Callback when client disconnects |
274
+ | `onMessage` | Callback when client sends a custom message |
275
+ | `validate(component, handlers)` | Register per-component validation. Returns `server` for chaining. See below. |
276
+
277
+ **Options:**
278
+
279
+ | Option | Description |
280
+ |---|---|
281
+ | `ownerComponent` | `{ component, clientIdField }` — validates that client deltas only modify entities owned by the sender |
282
+
283
+ **Per-component validation** — `server.validate(component, handlers)`:
284
+
285
+ Register `delta`, `attach`, and/or `detach` handlers per component. Return `false` to reject.
286
+
287
+ ```ts
288
+ server.validate(Position, {
289
+ delta(clientId, entityId, data) {
290
+ if (!isWalkable(data.x, data.y)) return false
291
+ return true
292
+ },
293
+ })
294
+
295
+ server.validate(Attacking, {
296
+ attach(clientId, entityId, data) {
297
+ // check adjacency, cooldowns, etc.
298
+ return true
299
+ },
300
+ detach(clientId, entityId) {
301
+ return true
302
+ },
303
+ })
304
+ ```
305
+
306
+ ### Client — `createNetClient(em, registry, options?)`
307
+
308
+ | Method / Property | Description |
309
+ |---|---|
310
+ | `connect(url)` | Connect to server |
311
+ | `disconnect()` | Close connection |
312
+ | `tick()` | Diff client-owned components and send delta to server. Call once per frame. |
313
+ | `send(data)` | Send a binary message to the server |
314
+ | `connected` | Whether currently connected |
315
+ | `clientId` | Client ID assigned by the server on connect |
316
+ | `netToEntity` | `ReadonlyMap<number, EntityId>` — netId → local entity mapping |
317
+ | `onConnected` | Callback on successful connection |
318
+ | `onDisconnected` | Callback on disconnect |
319
+ | `onMessage` | Callback for unrecognized (custom) messages |
320
+
321
+ **Options:**
322
+
323
+ | Option | Description |
324
+ |---|---|
325
+ | `ownerComponent` | `{ component, clientIdField }` — only diff and send entities where `clientIdField` matches this client's ID |
326
+
327
+ ---
328
+
329
+ ## License
330
+
331
+ MIT
@@ -2,8 +2,10 @@ import type { ComponentRegistration, RegisteredComponent } from './types.js';
2
2
  export interface ComponentRegistry {
3
3
  /** All registered components in wire ID order */
4
4
  readonly components: readonly RegisteredComponent[];
5
- /** Deterministic 32-bit hash of the registry schema (names + field names + types) */
5
+ /** Deterministic 32-bit hash of the registry schema (names + field names + types + ownership) */
6
6
  readonly hash: number;
7
+ /** Wire IDs of client-owned components */
8
+ readonly clientOwnedWireIds: ReadonlySet<number>;
7
9
  /** Look up by wire ID */
8
10
  byWireId(id: number): RegisteredComponent | undefined;
9
11
  /** Look up by component symbol */