@colyseus/schema 4.0.20 → 5.0.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 (96) hide show
  1. package/README.md +2 -0
  2. package/build/Metadata.d.ts +56 -2
  3. package/build/Reflection.d.ts +28 -34
  4. package/build/Schema.d.ts +70 -9
  5. package/build/annotations.d.ts +64 -17
  6. package/build/codegen/cli.cjs +84 -67
  7. package/build/codegen/cli.cjs.map +1 -1
  8. package/build/decoder/DecodeOperation.d.ts +48 -5
  9. package/build/decoder/Decoder.d.ts +2 -2
  10. package/build/decoder/strategy/Callbacks.d.ts +1 -1
  11. package/build/encoder/ChangeRecorder.d.ts +107 -0
  12. package/build/encoder/ChangeTree.d.ts +218 -69
  13. package/build/encoder/EncodeDescriptor.d.ts +63 -0
  14. package/build/encoder/EncodeOperation.d.ts +25 -2
  15. package/build/encoder/Encoder.d.ts +59 -3
  16. package/build/encoder/MapJournal.d.ts +62 -0
  17. package/build/encoder/RefIdAllocator.d.ts +35 -0
  18. package/build/encoder/Root.d.ts +94 -13
  19. package/build/encoder/StateView.d.ts +116 -8
  20. package/build/encoder/changeTree/inheritedFlags.d.ts +34 -0
  21. package/build/encoder/changeTree/liveIteration.d.ts +3 -0
  22. package/build/encoder/changeTree/parentChain.d.ts +24 -0
  23. package/build/encoder/changeTree/treeAttachment.d.ts +13 -0
  24. package/build/encoder/streaming.d.ts +73 -0
  25. package/build/encoder/subscriptions.d.ts +25 -0
  26. package/build/index.cjs +5258 -1549
  27. package/build/index.cjs.map +1 -1
  28. package/build/index.d.ts +7 -3
  29. package/build/index.js +5258 -1549
  30. package/build/index.mjs +5249 -1549
  31. package/build/index.mjs.map +1 -1
  32. package/build/input/InputDecoder.d.ts +32 -0
  33. package/build/input/InputEncoder.d.ts +117 -0
  34. package/build/input/index.cjs +7453 -0
  35. package/build/input/index.cjs.map +1 -0
  36. package/build/input/index.d.ts +3 -0
  37. package/build/input/index.mjs +7450 -0
  38. package/build/input/index.mjs.map +1 -0
  39. package/build/types/HelperTypes.d.ts +67 -9
  40. package/build/types/TypeContext.d.ts +9 -0
  41. package/build/types/builder.d.ts +192 -0
  42. package/build/types/custom/ArraySchema.d.ts +25 -4
  43. package/build/types/custom/CollectionSchema.d.ts +30 -2
  44. package/build/types/custom/MapSchema.d.ts +52 -3
  45. package/build/types/custom/SetSchema.d.ts +32 -2
  46. package/build/types/custom/StreamSchema.d.ts +114 -0
  47. package/build/types/symbols.d.ts +48 -5
  48. package/package.json +9 -3
  49. package/src/Metadata.ts +259 -31
  50. package/src/Reflection.ts +15 -13
  51. package/src/Schema.ts +176 -134
  52. package/src/annotations.ts +365 -252
  53. package/src/bench_bloat.ts +173 -0
  54. package/src/bench_decode.ts +221 -0
  55. package/src/bench_decode_mem.ts +165 -0
  56. package/src/bench_encode.ts +108 -0
  57. package/src/bench_init.ts +150 -0
  58. package/src/bench_static.ts +109 -0
  59. package/src/bench_stream.ts +295 -0
  60. package/src/bench_view_cmp.ts +142 -0
  61. package/src/codegen/languages/csharp.ts +0 -24
  62. package/src/codegen/parser.ts +83 -61
  63. package/src/decoder/DecodeOperation.ts +168 -63
  64. package/src/decoder/Decoder.ts +20 -10
  65. package/src/decoder/ReferenceTracker.ts +4 -0
  66. package/src/decoder/strategy/Callbacks.ts +30 -26
  67. package/src/decoder/strategy/getDecoderStateCallbacks.ts +16 -13
  68. package/src/encoder/ChangeRecorder.ts +276 -0
  69. package/src/encoder/ChangeTree.ts +674 -519
  70. package/src/encoder/EncodeDescriptor.ts +213 -0
  71. package/src/encoder/EncodeOperation.ts +107 -65
  72. package/src/encoder/Encoder.ts +630 -119
  73. package/src/encoder/MapJournal.ts +124 -0
  74. package/src/encoder/RefIdAllocator.ts +68 -0
  75. package/src/encoder/Root.ts +247 -120
  76. package/src/encoder/StateView.ts +592 -121
  77. package/src/encoder/changeTree/inheritedFlags.ts +217 -0
  78. package/src/encoder/changeTree/liveIteration.ts +74 -0
  79. package/src/encoder/changeTree/parentChain.ts +131 -0
  80. package/src/encoder/changeTree/treeAttachment.ts +171 -0
  81. package/src/encoder/streaming.ts +232 -0
  82. package/src/encoder/subscriptions.ts +71 -0
  83. package/src/index.ts +15 -3
  84. package/src/input/InputDecoder.ts +57 -0
  85. package/src/input/InputEncoder.ts +303 -0
  86. package/src/input/index.ts +3 -0
  87. package/src/types/HelperTypes.ts +121 -24
  88. package/src/types/TypeContext.ts +14 -2
  89. package/src/types/builder.ts +331 -0
  90. package/src/types/custom/ArraySchema.ts +210 -197
  91. package/src/types/custom/CollectionSchema.ts +115 -35
  92. package/src/types/custom/MapSchema.ts +162 -58
  93. package/src/types/custom/SetSchema.ts +128 -39
  94. package/src/types/custom/StreamSchema.ts +310 -0
  95. package/src/types/symbols.ts +93 -6
  96. package/src/utils.ts +4 -6
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Benchmark: Schema instance initialization throughput.
3
+ *
4
+ * Measures how fast schema instances can be created. The main hot path is
5
+ * Schema.initialize() — which allocates $changes and $values per instance.
6
+ *
7
+ * Usage (current branch):
8
+ * npx tsx --tsconfig tsconfig.test.json bench_init.ts
9
+ *
10
+ * Usage (previous version — run from the sibling checkout):
11
+ * cd ../schema && npx tsx --tsconfig tsconfig.test.json ../schema-5.0/bench_init.ts
12
+ *
13
+ * The benchmark uses the same schema hierarchy as bench_encode.js so results
14
+ * are directly comparable.
15
+ */
16
+ import { Schema, type, ArraySchema, MapSchema, Encoder } from "./index";
17
+
18
+ class Attribute extends Schema {
19
+ @type("string") name: string;
20
+ @type("number") value: number;
21
+ }
22
+
23
+ class Item extends Schema {
24
+ @type("number") price: number;
25
+ @type([Attribute]) attributes = new ArraySchema<Attribute>();
26
+ }
27
+
28
+ class Position extends Schema {
29
+ @type("number") x: number;
30
+ @type("number") y: number;
31
+ }
32
+
33
+ class Player extends Schema {
34
+ @type(Position) position = new Position();
35
+ @type({ map: Item }) items = new MapSchema<Item>();
36
+ }
37
+
38
+ class State extends Schema {
39
+ @type({ map: Player }) players = new MapSchema<Player>();
40
+ @type("string") currentTurn: string;
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Warmup — let V8 JIT the constructors
45
+ // ---------------------------------------------------------------------------
46
+ for (let i = 0; i < 500; i++) {
47
+ const p = new Player();
48
+ p.position.x = i;
49
+ const item = new Item();
50
+ const attr = new Attribute();
51
+ attr.name = "warmup";
52
+ attr.value = i;
53
+ item.attributes.push(attr);
54
+ p.items.set("w", item);
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Benchmark 1: Raw instance creation (no encoder, no parent wiring)
59
+ // ---------------------------------------------------------------------------
60
+ const INSTANCES = 50_000;
61
+
62
+ globalThis.gc?.();
63
+ const t0 = performance.now();
64
+ const players: Player[] = new Array(INSTANCES);
65
+ for (let i = 0; i < INSTANCES; i++) {
66
+ players[i] = new Player();
67
+ }
68
+ const t1 = performance.now();
69
+ console.log(`\n--- Raw instance creation (${INSTANCES} Player instances) ---`);
70
+ console.log(`Total: ${(t1 - t0).toFixed(2)} ms`);
71
+ console.log(`Per instance: ${((t1 - t0) / INSTANCES * 1000).toFixed(2)} µs`);
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Benchmark 2: Deep hierarchy creation (Player + Position + 10 Items × 5 Attributes)
75
+ // ---------------------------------------------------------------------------
76
+ const DEEP_COUNT = 5_000;
77
+
78
+ globalThis.gc?.();
79
+ const t2 = performance.now();
80
+ for (let i = 0; i < DEEP_COUNT; i++) {
81
+ const player = new Player();
82
+ player.position.x = i;
83
+ player.position.y = i;
84
+ for (let j = 0; j < 10; j++) {
85
+ const item = new Item();
86
+ item.price = j * 50;
87
+ for (let k = 0; k < 5; k++) {
88
+ const attr = new Attribute();
89
+ attr.name = `Attribute ${k}`;
90
+ attr.value = k;
91
+ item.attributes.push(attr);
92
+ }
93
+ player.items.set(`item-${j}`, item);
94
+ }
95
+ }
96
+ const t3 = performance.now();
97
+ const totalInstances = DEEP_COUNT * (1 /* Player */ + 1 /* Position */ + 10 /* Items */ + 50 /* Attributes */);
98
+ console.log(`\n--- Deep hierarchy creation (${DEEP_COUNT} trees, ${totalInstances} total instances) ---`);
99
+ console.log(`Total: ${(t3 - t2).toFixed(2)} ms`);
100
+ console.log(`Per tree: ${((t3 - t2) / DEEP_COUNT * 1000).toFixed(2)} µs`);
101
+ console.log(`Per instance: ${((t3 - t2) / totalInstances * 1000).toFixed(2)} µs`);
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Benchmark 3: Full encode cycle (matches bench_encode.js structure)
105
+ // ---------------------------------------------------------------------------
106
+ const state = new State();
107
+ Encoder.BUFFER_SIZE = 4096 * 4096;
108
+ const encoder = new Encoder(state);
109
+
110
+ const ROUNDS = 50;
111
+ const PLAYERS_PER_ROUND = 50;
112
+
113
+ globalThis.gc?.();
114
+ let totalMakeChanges = 0;
115
+ let totalEncode = 0;
116
+
117
+ for (let i = 0; i < ROUNDS; i++) {
118
+ const mc0 = performance.now();
119
+ for (let j = 0; j < PLAYERS_PER_ROUND; j++) {
120
+ const player = new Player();
121
+ state.players.set(`p-${i}-${j}`, player);
122
+ player.position.x = (j + 1) * 100;
123
+ player.position.y = (j + 1) * 100;
124
+ for (let k = 0; k < 10; k++) {
125
+ const item = new Item();
126
+ item.price = (j + 1) * 50;
127
+ for (let l = 0; l < 5; l++) {
128
+ const attr = new Attribute();
129
+ attr.name = `Attribute ${l}`;
130
+ attr.value = l;
131
+ item.attributes.push(attr);
132
+ }
133
+ player.items.set(`item-${k}`, item);
134
+ }
135
+ }
136
+ const mc1 = performance.now();
137
+ totalMakeChanges += mc1 - mc0;
138
+
139
+ const enc0 = performance.now();
140
+ encoder.encode();
141
+ encoder.discardChanges();
142
+ const enc1 = performance.now();
143
+ totalEncode += enc1 - enc0;
144
+ }
145
+
146
+ console.log(`\n--- Encode cycle (${ROUNDS} rounds × ${PLAYERS_PER_ROUND} players) ---`);
147
+ console.log(`Avg make changes: ${(totalMakeChanges / ROUNDS).toFixed(2)} ms`);
148
+ console.log(`Avg encode: ${(totalEncode / ROUNDS).toFixed(2)} ms`);
149
+ console.log(`Total: ${(totalMakeChanges + totalEncode).toFixed(2)} ms`);
150
+ console.log(`Encoded size: ${Array.from(encoder.encodeAll()).length} bytes`);
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Benchmark: per-tick encode cost with vs without `.static()` on fields
3
+ * that are mutated every tick but semantically configure-once.
4
+ *
5
+ * Scenario: 1000 entities with 4 "config" fields + 2 "dynamic" fields.
6
+ * Both variants mutate all 6 fields on all entities every tick. The
7
+ * `.static()` variant skips change-tracking for the 4 config fields,
8
+ * so per-tick encode emits and transmits only the 2 dynamic ones.
9
+ *
10
+ * Measures (per 1000 ticks of 1000 entities × 6 mutations):
11
+ * - encode() wall time
12
+ * - encoded tick patch size (bytes)
13
+ */
14
+
15
+ import { Encoder, Schema, type, schema, t, MapSchema } from "./index";
16
+
17
+ const NUM_ENTITIES = 1000;
18
+ const NUM_TICKS = 1000;
19
+
20
+ // ──────────────────────────────────────────────────────────────────────────
21
+ // Variant A: all fields tracked (no .static())
22
+ // ──────────────────────────────────────────────────────────────────────────
23
+ const EntityA = schema({
24
+ maxHp: t.uint16(),
25
+ team: t.uint8(),
26
+ mapId: t.uint8(),
27
+ spawnIndex: t.uint16(),
28
+ hp: t.uint16(),
29
+ x: t.float32(),
30
+ }, "EntityA");
31
+ const StateA = schema({
32
+ entities: t.map(EntityA),
33
+ }, "StateA");
34
+
35
+ // ──────────────────────────────────────────────────────────────────────────
36
+ // Variant B: 4 fields marked `.static()` (sync-once, tick-skip)
37
+ // ──────────────────────────────────────────────────────────────────────────
38
+ const EntityB = schema({
39
+ maxHp: t.uint16().static(),
40
+ team: t.uint8().static(),
41
+ mapId: t.uint8().static(),
42
+ spawnIndex: t.uint16().static(),
43
+ hp: t.uint16(),
44
+ x: t.float32(),
45
+ }, "EntityB");
46
+ const StateB = schema({
47
+ entities: t.map(EntityB),
48
+ }, "StateB");
49
+
50
+ function run<T extends Schema, E extends Schema>(
51
+ label: string,
52
+ StateCtor: new () => T,
53
+ EntityCtor: new () => E,
54
+ ) {
55
+ const state = new StateCtor();
56
+ const entitiesMap = (state as any).entities as MapSchema<E>;
57
+
58
+ for (let i = 0; i < NUM_ENTITIES; i++) {
59
+ const e = new EntityCtor();
60
+ (e as any).maxHp = 100;
61
+ (e as any).team = i % 4;
62
+ (e as any).mapId = 1;
63
+ (e as any).spawnIndex = i;
64
+ (e as any).hp = 100;
65
+ (e as any).x = 0;
66
+ entitiesMap.set(`e${i}`, e);
67
+ }
68
+
69
+ const encoder = new Encoder(state);
70
+
71
+ // Prime: initial encode + discard so tick timings measure steady-state.
72
+ encoder.encode();
73
+ encoder.discardChanges();
74
+
75
+ let totalBytes = 0;
76
+ const start = performance.now();
77
+ for (let tick = 0; tick < NUM_TICKS; tick++) {
78
+ for (let i = 0; i < NUM_ENTITIES; i++) {
79
+ const e = entitiesMap.get(`e${i}`);
80
+ // Mutate all 6 fields. The `.static()` variant will silently
81
+ // drop the first 4 at tracking time.
82
+ (e as any).maxHp = 100 + (tick & 15);
83
+ (e as any).team = i % 4;
84
+ (e as any).mapId = 1;
85
+ (e as any).spawnIndex = i;
86
+ (e as any).hp = 100 - (tick & 31);
87
+ (e as any).x = tick * 0.1;
88
+ }
89
+ const bytes = encoder.encode();
90
+ totalBytes += bytes.length;
91
+ encoder.discardChanges();
92
+ }
93
+ const elapsed = performance.now() - start;
94
+
95
+ console.log(
96
+ `${label.padEnd(28)} ${elapsed.toFixed(1).padStart(7)} ms total, ` +
97
+ `${(elapsed / NUM_TICKS).toFixed(3)} ms/tick, ` +
98
+ `${(totalBytes / NUM_TICKS).toFixed(0)} bytes/tick`
99
+ );
100
+ }
101
+
102
+ // Warm up JIT with a short run on each variant before measuring.
103
+ run("warmup-A", StateA as any, EntityA as any);
104
+ run("warmup-B", StateB as any, EntityB as any);
105
+
106
+ console.log();
107
+ console.log(`--- ${NUM_ENTITIES} entities × ${NUM_TICKS} ticks × 6 mutations ---`);
108
+ run("without .static()", StateA as any, EntityA as any);
109
+ run("with .static() on 4 of 6", StateB as any, EntityB as any);
@@ -0,0 +1,295 @@
1
+ /**
2
+ * bench_stream.ts — RTS-style ECS workload over StreamSchema.
3
+ *
4
+ * Simulates a room with many units, each built from several polymorphic
5
+ * components (Hp, Team, Position, Velocity). Exercises:
6
+ * - Encode throughput of a large initial spawn spread across ticks
7
+ * (priority-batched per-view vs broadcast).
8
+ * - Steady-state per-tick mutation cost (hp damage + position move).
9
+ * - Memory footprint.
10
+ *
11
+ * Run: npx tsx --tsconfig tsconfig.test.json --expose-gc src/bench_stream.ts
12
+ */
13
+ import {
14
+ Encoder,
15
+ Schema,
16
+ type,
17
+ ArraySchema,
18
+ StreamSchema,
19
+ StateView,
20
+ } from "./index";
21
+
22
+ // Pre-size the shared buffer so the steady-state loops don't print
23
+ // overflow-warning noise mid-measurement.
24
+ Encoder.BUFFER_SIZE = 256 * 1024;
25
+
26
+ // ─── Schema ───────────────────────────────────────────────────────────
27
+
28
+ class Vec3 extends Schema {
29
+ @type("number") x: number = 0;
30
+ @type("number") y: number = 0;
31
+ @type("number") z: number = 0;
32
+ }
33
+
34
+ class Component extends Schema {
35
+ @type("string") kind: string = "";
36
+ }
37
+
38
+ class Hp extends Component {
39
+ @type("uint16") current: number = 100;
40
+ @type("uint16") max: number = 100;
41
+ }
42
+
43
+ class Team extends Component {
44
+ @type("uint8") teamId: number = 0;
45
+ @type("uint32") color: number = 0xffffff;
46
+ }
47
+
48
+ class Position extends Component {
49
+ @type(Vec3) value: Vec3 = new Vec3();
50
+ }
51
+
52
+ class Velocity extends Component {
53
+ @type(Vec3) value: Vec3 = new Vec3();
54
+ }
55
+
56
+ class Unit extends Schema {
57
+ @type("string") name: string = "";
58
+ @type([Component]) components = new ArraySchema<Component>();
59
+ }
60
+
61
+ class State extends Schema {
62
+ @type({ stream: Unit }) units = new StreamSchema<Unit>();
63
+ }
64
+
65
+ // ─── Helpers ──────────────────────────────────────────────────────────
66
+
67
+ function mkUnit(i: number): Unit {
68
+ const u = new Unit().assign({ name: `U${i}` });
69
+ const hp = new Hp().assign({ kind: "hp", current: 100, max: 100 });
70
+ const team = new Team().assign({ kind: "team", teamId: i % 2, color: 0xff0000 });
71
+ const pos = new Position().assign({ kind: "position" });
72
+ pos.value.assign({ x: i, y: 0, z: i * 2 });
73
+ const vel = new Velocity().assign({ kind: "velocity" });
74
+ vel.value.assign({ x: 1, y: 0, z: 1 });
75
+ u.components.push(hp);
76
+ u.components.push(team);
77
+ u.components.push(pos);
78
+ u.components.push(vel);
79
+ return u;
80
+ }
81
+
82
+ function ms(fn: () => void): number {
83
+ const t = performance.now();
84
+ fn();
85
+ return performance.now() - t;
86
+ }
87
+
88
+ function sum(arr: number[]): number {
89
+ let s = 0;
90
+ for (const v of arr) s += v;
91
+ return s;
92
+ }
93
+
94
+ // ─── 1. Memory footprint ──────────────────────────────────────────────
95
+
96
+ const UNIT_COUNT = 1000;
97
+
98
+ globalThis.gc?.();
99
+ const heapBefore = process.memoryUsage().heapUsed;
100
+
101
+ const state = new State();
102
+ const encoder = new Encoder(state);
103
+
104
+ for (let i = 0; i < UNIT_COUNT; i++) {
105
+ state.units.add(mkUnit(i));
106
+ }
107
+
108
+ globalThis.gc?.();
109
+ const heapAfter = process.memoryUsage().heapUsed;
110
+ console.log(`Heap for ${UNIT_COUNT} units × 4 components: ${((heapAfter - heapBefore) / 1024 / 1024).toFixed(2)} MB`);
111
+
112
+ // ─── 2. Broadcast-mode encoding (no views) ────────────────────────────
113
+ // maxPerTick drains units in batches. Measure bytes + time per tick
114
+ // until the backlog is fully drained.
115
+
116
+ {
117
+ const s = new State();
118
+ s.units.maxPerTick = 50;
119
+ const enc = new Encoder(s);
120
+
121
+ // Spawn all units in one "tick" (worst case for backlog).
122
+ for (let i = 0; i < UNIT_COUNT; i++) {
123
+ s.units.add(mkUnit(i));
124
+ }
125
+
126
+ // Bootstrap full state snapshot.
127
+ enc.encodeAll();
128
+ enc.discardChanges();
129
+
130
+ const tickTimes: number[] = [];
131
+ const tickBytes: number[] = [];
132
+ let tick = 0;
133
+ while (
134
+ ((s.units as any)._stream?.broadcastPending.size ?? 0) > 0 ||
135
+ ((s.units as any)._stream?.broadcastDeletes.size ?? 0) > 0
136
+ ) {
137
+ tick++;
138
+ const t = performance.now();
139
+ const bytes = enc.encode();
140
+ tickTimes.push(performance.now() - t);
141
+ tickBytes.push(bytes.length);
142
+ enc.discardChanges();
143
+ if (tick > 1000) throw new Error("drain did not converge");
144
+ }
145
+ console.log(
146
+ `Broadcast drain: ${tick} ticks @ maxPerTick=50, total=${sum(tickBytes)} bytes, ` +
147
+ `avg=${(sum(tickTimes) / tick).toFixed(3)}ms/tick`,
148
+ );
149
+ }
150
+
151
+ // ─── 3. View-mode encoding (1 client) ─────────────────────────────────
152
+ // Same workload but with a StateView. Exercises the priority pass +
153
+ // per-view pending state.
154
+
155
+ {
156
+ const s = new State();
157
+ s.units.maxPerTick = 50;
158
+ const enc = new Encoder(s);
159
+
160
+ // Instance-level priority override (same sort path exercised).
161
+ s.units.priority = (_view: any, el: Unit) => {
162
+ const pos = el.components[2] as Position;
163
+ return -(pos?.value?.x ?? 0);
164
+ };
165
+
166
+ // Create view BEFORE adding units so stream.add doesn't seed
167
+ // broadcast pending (view mode = no auto-seed, explicit subscribe).
168
+ const view = new StateView();
169
+ view.add(s);
170
+
171
+ for (let i = 0; i < UNIT_COUNT; i++) {
172
+ const u = mkUnit(i);
173
+ s.units.add(u);
174
+ view.add(u);
175
+ }
176
+
177
+ // Bootstrap.
178
+ const bootIt = { offset: 0 };
179
+ enc.encodeAll(bootIt);
180
+ const bootShared = bootIt.offset;
181
+ enc.encodeAllView(view, bootShared, bootIt);
182
+ enc.discardChanges();
183
+
184
+ const tickTimes: number[] = [];
185
+ const tickBytes: number[] = [];
186
+ let tick = 0;
187
+ while (true) {
188
+ const pending = (s.units as any)._stream?.pendingByView.get(view.id);
189
+ if (!pending || pending.size === 0) break;
190
+ tick++;
191
+ const t = performance.now();
192
+ const it = { offset: 0 };
193
+ enc.encode(it);
194
+ const sharedOffset = it.offset;
195
+ const bytes = enc.encodeView(view, sharedOffset, it);
196
+ tickTimes.push(performance.now() - t);
197
+ tickBytes.push(bytes.length);
198
+ enc.discardChanges();
199
+ if (tick > 1000) throw new Error("drain did not converge");
200
+ }
201
+ console.log(
202
+ `View drain: ${tick} ticks @ maxPerTick=50 w/ priority sort, total=${sum(tickBytes)} bytes, ` +
203
+ `avg=${(sum(tickTimes) / tick).toFixed(3)}ms/tick`,
204
+ );
205
+ }
206
+
207
+ // ─── 4. Steady-state mutations ────────────────────────────────────────
208
+ // After the backlog is drained, simulate 60Hz gameplay: every unit's
209
+ // hp.current and position.value.x mutate each tick.
210
+
211
+ {
212
+ const s = new State();
213
+ s.units.maxPerTick = Number.MAX_SAFE_INTEGER; // drain everything immediately
214
+ const enc = new Encoder(s);
215
+
216
+ const units: Unit[] = [];
217
+ for (let i = 0; i < UNIT_COUNT; i++) {
218
+ const u = mkUnit(i);
219
+ units.push(u);
220
+ s.units.add(u);
221
+ }
222
+
223
+ // Bootstrap — drain the whole backlog in one tick.
224
+ enc.encodeAll();
225
+ enc.encode();
226
+ enc.discardChanges();
227
+
228
+ const iterations = 200;
229
+ const mutateAndEncode = ms(() => {
230
+ for (let it = 0; it < iterations; it++) {
231
+ for (const u of units) {
232
+ const hp = u.components[0] as Hp;
233
+ const pos = u.components[2] as Position;
234
+ hp.current = Math.max(0, hp.current - 1);
235
+ pos.value.x++;
236
+ }
237
+ enc.encode();
238
+ enc.discardChanges();
239
+ }
240
+ });
241
+ console.log(
242
+ `Steady-state mutations (${iterations} ticks × ${UNIT_COUNT} units × 2 fields): ` +
243
+ `${mutateAndEncode.toFixed(1)}ms total, ${(mutateAndEncode / iterations).toFixed(3)}ms/tick`,
244
+ );
245
+ }
246
+
247
+ // ─── 5. View-mode steady state (1 client w/ per-view priority) ────────
248
+
249
+ {
250
+ const s = new State();
251
+ s.units.maxPerTick = Number.MAX_SAFE_INTEGER;
252
+ const enc = new Encoder(s);
253
+
254
+ // View must exist before units so stream.add doesn't seed broadcast.
255
+ const view = new StateView();
256
+ view.add(s);
257
+
258
+ const units: Unit[] = [];
259
+ for (let i = 0; i < UNIT_COUNT; i++) {
260
+ const u = mkUnit(i);
261
+ units.push(u);
262
+ s.units.add(u);
263
+ view.add(u);
264
+ }
265
+
266
+ const bootIt = { offset: 0 };
267
+ enc.encodeAll(bootIt);
268
+ const bootShared = bootIt.offset;
269
+ enc.encodeAllView(view, bootShared, bootIt);
270
+ // Drain pending.
271
+ const drainIt = { offset: 0 };
272
+ enc.encode(drainIt);
273
+ enc.encodeView(view, drainIt.offset, drainIt);
274
+ enc.discardChanges();
275
+
276
+ const iterations = 200;
277
+ const mutateAndEncode = ms(() => {
278
+ for (let it = 0; it < iterations; it++) {
279
+ for (const u of units) {
280
+ const hp = u.components[0] as Hp;
281
+ const pos = u.components[2] as Position;
282
+ hp.current = Math.max(0, hp.current - 1);
283
+ pos.value.x++;
284
+ }
285
+ const tickIt = { offset: 0 };
286
+ enc.encode(tickIt);
287
+ enc.encodeView(view, tickIt.offset, tickIt);
288
+ enc.discardChanges();
289
+ }
290
+ });
291
+ console.log(
292
+ `View steady-state (${iterations} ticks × ${UNIT_COUNT} units × 2 fields, 1 client): ` +
293
+ `${mutateAndEncode.toFixed(1)}ms total, ${(mutateAndEncode / iterations).toFixed(3)}ms/tick`,
294
+ );
295
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * bench_view_cmp.ts — StateView-path counterpart to bench_bloat.ts.
3
+ *
4
+ * Same entity layout (1000 players × Position × 5 scores) but with N
5
+ * StateViews subscribing to a @view-tagged secret field on each player.
6
+ * Measures per-tick encode throughput under view-filtered fanout.
7
+ *
8
+ * Run: npx tsx --tsconfig tsconfig.test.json --expose-gc src/bench_view_cmp.ts
9
+ */
10
+ import { Encoder, Schema, type, view, MapSchema, ArraySchema, StateView } from "./index";
11
+
12
+ class Position extends Schema {
13
+ @type("number") x: number = 0;
14
+ @type("number") y: number = 0;
15
+ }
16
+
17
+ class Player extends Schema {
18
+ @type("string") name: string = "";
19
+ @type(Position) position = new Position();
20
+ @type(["number"]) scores = new ArraySchema<number>();
21
+ @view() @type("uint32") secret: number = 0;
22
+ }
23
+
24
+ class State extends Schema {
25
+ @type({ map: Player }) players = new MapSchema<Player>();
26
+ }
27
+
28
+ const N_PLAYERS = 1000;
29
+ const N_CLIENTS = 10;
30
+
31
+ // ─── Setup ─────────────────────────────────────────────────────────────
32
+
33
+ globalThis.gc?.();
34
+ const heapBefore = process.memoryUsage().heapUsed;
35
+
36
+ const state = new State();
37
+ const encoder = new Encoder(state);
38
+ Encoder.BUFFER_SIZE = 256 * 1024;
39
+
40
+ for (let i = 0; i < N_PLAYERS; i++) {
41
+ const p = new Player();
42
+ p.name = `P${i}`;
43
+ p.position.x = i;
44
+ p.position.y = i;
45
+ for (let j = 0; j < 5; j++) p.scores.push(j);
46
+ p.secret = i;
47
+ state.players.set(`p${i}`, p);
48
+ }
49
+
50
+ globalThis.gc?.();
51
+ const heapAfter = process.memoryUsage().heapUsed;
52
+ console.log(`Heap (${N_PLAYERS} players): ${((heapAfter - heapBefore) / 1024 / 1024).toFixed(2)} MB`);
53
+
54
+ // Subscribe N clients, each to every player.
55
+ const views: StateView[] = [];
56
+ for (let c = 0; c < N_CLIENTS; c++) {
57
+ const v = new StateView();
58
+ for (let i = 0; i < N_PLAYERS; i++) {
59
+ v.add(state.players.get(`p${i}`)!);
60
+ }
61
+ views.push(v);
62
+ }
63
+
64
+ // Bootstrap full encode.
65
+ encoder.encodeAll();
66
+ for (const v of views) {
67
+ const it = { offset: 0 };
68
+ encoder.encodeAll(it);
69
+ encoder.encodeAllView(v, it.offset, it);
70
+ }
71
+ encoder.discardChanges();
72
+
73
+ // ─── 1. 10-mutation ticks ──────────────────────────────────────────────
74
+
75
+ {
76
+ const iterations = 2000;
77
+ const start = performance.now();
78
+ for (let i = 0; i < iterations; i++) {
79
+ for (let j = 0; j < 10; j++) {
80
+ const p = state.players.get(`p${j}`)!;
81
+ p.position.x++;
82
+ p.position.y++;
83
+ }
84
+ const it = { offset: 0 };
85
+ encoder.encode(it);
86
+ const sharedOffset = it.offset;
87
+ for (const v of views) encoder.encodeView(v, sharedOffset, it);
88
+ encoder.discardChanges();
89
+ }
90
+ const elapsed = performance.now() - start;
91
+ console.log(
92
+ `${iterations} ticks × (10 mutations, shared + ${N_CLIENTS} views): ` +
93
+ `${elapsed.toFixed(1)}ms total (${(elapsed / iterations).toFixed(4)}ms/tick)`,
94
+ );
95
+ }
96
+
97
+ // ─── 2. 100-mutation ticks ─────────────────────────────────────────────
98
+
99
+ {
100
+ const iterations = 500;
101
+ const start = performance.now();
102
+ for (let i = 0; i < iterations; i++) {
103
+ for (let j = 0; j < 100; j++) {
104
+ const p = state.players.get(`p${j}`)!;
105
+ p.position.x++;
106
+ p.position.y++;
107
+ }
108
+ const it = { offset: 0 };
109
+ encoder.encode(it);
110
+ const sharedOffset = it.offset;
111
+ for (const v of views) encoder.encodeView(v, sharedOffset, it);
112
+ encoder.discardChanges();
113
+ }
114
+ const elapsed = performance.now() - start;
115
+ console.log(
116
+ `${iterations} ticks × (100 mutations, shared + ${N_CLIENTS} views): ` +
117
+ `${elapsed.toFixed(1)}ms total (${(elapsed / iterations).toFixed(3)}ms/tick)`,
118
+ );
119
+ }
120
+
121
+ // ─── 3. View-tagged field mutations (exercises per-view filtering) ─────
122
+
123
+ {
124
+ const iterations = 500;
125
+ const start = performance.now();
126
+ for (let i = 0; i < iterations; i++) {
127
+ for (let j = 0; j < 100; j++) {
128
+ const p = state.players.get(`p${j}`)!;
129
+ p.secret = (p.secret + 1) >>> 0;
130
+ }
131
+ const it = { offset: 0 };
132
+ encoder.encode(it);
133
+ const sharedOffset = it.offset;
134
+ for (const v of views) encoder.encodeView(v, sharedOffset, it);
135
+ encoder.discardChanges();
136
+ }
137
+ const elapsed = performance.now() - start;
138
+ console.log(
139
+ `${iterations} ticks × (100 @view-field mutations, shared + ${N_CLIENTS} views): ` +
140
+ `${elapsed.toFixed(1)}ms total (${(elapsed / iterations).toFixed(3)}ms/tick)`,
141
+ );
142
+ }