archetype-ecs-net 0.1.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.
package/README.md ADDED
@@ -0,0 +1,287 @@
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
@@ -0,0 +1,14 @@
1
+ import type { ComponentRegistration, RegisteredComponent } from './types.js';
2
+ export interface ComponentRegistry {
3
+ /** All registered components in wire ID order */
4
+ readonly components: readonly RegisteredComponent[];
5
+ /** Deterministic 32-bit hash of the registry schema (names + field names + types) */
6
+ readonly hash: number;
7
+ /** Look up by wire ID */
8
+ byWireId(id: number): RegisteredComponent | undefined;
9
+ /** Look up by component symbol */
10
+ bySymbol(sym: symbol): RegisteredComponent | undefined;
11
+ /** Look up by wire name */
12
+ byName(name: string): RegisteredComponent | undefined;
13
+ }
14
+ export declare function createComponentRegistry(registrations: ComponentRegistration[]): ComponentRegistry;
@@ -0,0 +1,90 @@
1
+ import { componentSchemas } from 'archetype-ecs';
2
+ // Maps TypedArray constructors back to wire type strings
3
+ const CTOR_TO_TYPE = new Map([
4
+ [Float32Array, 'f32'],
5
+ [Float64Array, 'f64'],
6
+ [Int8Array, 'i8'],
7
+ [Int16Array, 'i16'],
8
+ [Int32Array, 'i32'],
9
+ [Uint8Array, 'u8'],
10
+ [Uint16Array, 'u16'],
11
+ [Uint32Array, 'u32'],
12
+ [Array, 'string'],
13
+ ]);
14
+ const TYPE_BYTE_SIZE = {
15
+ f32: 4, f64: 8,
16
+ i8: 1, i16: 2, i32: 4,
17
+ u8: 1, u16: 2, u32: 4,
18
+ string: 0, // variable length
19
+ };
20
+ /** Simple FNV-1a 32-bit hash */
21
+ function fnv1a(str) {
22
+ let h = 0x811c9dc5;
23
+ for (let i = 0; i < str.length; i++) {
24
+ h ^= str.charCodeAt(i);
25
+ h = Math.imul(h, 0x01000193);
26
+ }
27
+ return h >>> 0;
28
+ }
29
+ export function createComponentRegistry(registrations) {
30
+ if (registrations.length > 255) {
31
+ throw new Error(`Max 255 networked components (got ${registrations.length})`);
32
+ }
33
+ const components = [];
34
+ const byWireIdMap = new Map();
35
+ const bySymbolMap = new Map();
36
+ const byNameMap = new Map();
37
+ for (let i = 0; i < registrations.length; i++) {
38
+ const reg = registrations[i];
39
+ const sym = reg.component._sym;
40
+ const schema = componentSchemas.get(sym);
41
+ const fields = [];
42
+ if (schema) {
43
+ for (const [fieldName, Ctor] of Object.entries(schema)) {
44
+ const wireType = CTOR_TO_TYPE.get(Ctor);
45
+ if (!wireType)
46
+ throw new Error(`Unknown constructor for field "${fieldName}" in "${reg.name}"`);
47
+ fields.push({
48
+ name: fieldName,
49
+ type: wireType,
50
+ byteSize: TYPE_BYTE_SIZE[wireType],
51
+ });
52
+ }
53
+ }
54
+ if (fields.length > 16) {
55
+ throw new Error(`Component "${reg.name}" has ${fields.length} fields (max 16 for u16 field bitmask)`);
56
+ }
57
+ const entry = {
58
+ wireId: i,
59
+ name: reg.name,
60
+ component: reg.component,
61
+ fields,
62
+ };
63
+ components.push(entry);
64
+ byWireIdMap.set(i, entry);
65
+ bySymbolMap.set(sym, entry);
66
+ byNameMap.set(reg.name, entry);
67
+ }
68
+ // Validate wireIds are sequential (defensive against future refactoring)
69
+ for (let i = 0; i < components.length; i++) {
70
+ if (components[i].wireId !== i) {
71
+ throw new Error(`Internal error: wireId mismatch at index ${i} (expected ${i}, got ${components[i].wireId})`);
72
+ }
73
+ }
74
+ // Build deterministic schema fingerprint: "name:field1:type1,field2:type2;..."
75
+ let schemaStr = '';
76
+ for (const c of components) {
77
+ schemaStr += c.name + ':';
78
+ for (const f of c.fields)
79
+ schemaStr += f.name + ':' + f.type + ',';
80
+ schemaStr += ';';
81
+ }
82
+ const hash = fnv1a(schemaStr);
83
+ return {
84
+ components,
85
+ hash,
86
+ byWireId: (id) => byWireIdMap.get(id),
87
+ bySymbol: (sym) => bySymbolMap.get(sym),
88
+ byName: (name) => byNameMap.get(name),
89
+ };
90
+ }
@@ -0,0 +1,47 @@
1
+ import type { EntityId, EntityManager, ComponentType } from 'archetype-ecs';
2
+ import type { ComponentRegistry } from './ComponentRegistry.js';
3
+ import { ProtocolEncoder } from './Protocol.js';
4
+ import type { ClientDelta } from './InterestManager.js';
5
+ /** Tag component — add to any entity that should be synced over the network */
6
+ export declare const Networked: ComponentType;
7
+ export interface CreatedEntry {
8
+ readonly netId: number;
9
+ readonly entityId: EntityId;
10
+ }
11
+ export interface DirtyEntry {
12
+ readonly netId: number;
13
+ readonly entityId: EntityId;
14
+ /** Per-wireId dirty field bitmask (copy, safe to read after compute) */
15
+ readonly dirtyMasks: Uint16Array;
16
+ }
17
+ export interface Changeset {
18
+ readonly created: readonly CreatedEntry[];
19
+ readonly destroyed: readonly number[];
20
+ readonly dirty: readonly DirtyEntry[];
21
+ /** Fast lookup sets for created/destroyed netIds */
22
+ readonly createdSet: ReadonlySet<number>;
23
+ readonly destroyedSet: ReadonlySet<number>;
24
+ }
25
+ export interface EntityCache {
26
+ /** Pre-encoded bytes for entity enters (varint netId + full component data) */
27
+ readonly enterSlices: ReadonlyMap<number, Uint8Array>;
28
+ /** Pre-encoded bytes for dirty entity updates (varint netId + dirty components) */
29
+ readonly updateSlices: ReadonlyMap<number, Uint8Array>;
30
+ }
31
+ export interface SnapshotDiffer {
32
+ /** Diff + encode in a single pass — avoids intermediate allocations (broadcast mode) */
33
+ diffAndEncode(encoder: ProtocolEncoder): ArrayBuffer;
34
+ /** Phase 1: compute what changed this tick (run once, then encode per client) */
35
+ computeChangeset(): Changeset;
36
+ /** Pre-encode all entities in a changeset into cached byte slices (1× per tick) */
37
+ preEncodeChangeset(encoder: ProtocolEncoder, changeset: Changeset, extraEnterNetIds: Iterable<number>): EntityCache;
38
+ /** Compose a per-client delta buffer from pre-encoded cache (fast memcpy path) */
39
+ composeFromCache(encoder: ProtocolEncoder, cache: EntityCache, clientDelta: ClientDelta): ArrayBuffer;
40
+ /** Flush snapshot buffers. Must call after all encoding is done when using computeChangeset(). */
41
+ flushSnapshots(): void;
42
+ /** Current mapping of entityId → netId for all tracked entities */
43
+ readonly entityNetIds: ReadonlyMap<EntityId, number>;
44
+ /** Reverse mapping of netId → entityId */
45
+ readonly netIdToEntity: ReadonlyMap<number, EntityId>;
46
+ }
47
+ export declare function createSnapshotDiffer(em: EntityManager, registry: ComponentRegistry): SnapshotDiffer;