@vworlds/vecs 1.0.50 → 1.0.51

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
@@ -59,6 +59,8 @@ world.progress(performance.now(), 16); // call once per frame
59
59
 
60
60
  ## Documentation
61
61
 
62
+ For Node clients that need replicated Phaser poses, see [Headless clients](../vecs-phaser-client/README.md#headless-clients).
63
+
62
64
  The full documentation lives in [`docs/`](./docs/README.md). Recommended reading order:
63
65
 
64
66
  1. [Getting started](./docs/getting-started.md) — install to first ticking world.
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vworlds/vecs",
3
- "version": "1.0.50",
3
+ "version": "1.0.51",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -27,12 +27,5 @@
27
27
  "type": "git",
28
28
  "url": "https://github.com/vworlds/vecs",
29
29
  "directory": "lib/vecs"
30
- },
31
- "lint-staged": {
32
- "*.ts": [
33
- "prettier --write",
34
- "eslint --fix"
35
- ],
36
- "*.md": "prettier --write"
37
30
  }
38
31
  }
package/docs/README.md CHANGED
@@ -36,6 +36,8 @@ Keep the [Glossary](./glossary.md) at hand for terminology.
36
36
 
37
37
  ## Map
38
38
 
39
+ For Node clients that need replicated Phaser poses, see [Headless clients](../../vecs-phaser-client/README.md#headless-clients).
40
+
39
41
  | Guide | Covers |
40
42
  | -------------------------------------------------- | -------------------------------------------------------------------------------------------- |
41
43
  | [getting-started.md](./getting-started.md) | A runnable tutorial from `npm install` to a ticking world. |
@@ -57,6 +59,8 @@ sessions, and the client world) lives in the `@vworlds/vecs-wire`, `@vworlds/vec
57
59
  `@vworlds/vecs-server`, and `@vworlds/vecs-client` packages:
58
60
 
59
61
  - [Remote procedure calls](../../vecs-protocol/docs/rpc.md) — the call channel and typed errors.
62
+ - [State diffs](../../vecs-protocol/docs/state-diffs.md) — what a diff carries, the order a client
63
+ applies it in, and how a reused entity id stays unambiguous.
60
64
  - [Sessions and caller context](../../vecs-server/docs/sessions.md) — per-connection RPC context,
61
65
  `closeSession`, and exactly-once teardown.
62
66
  - [Client world](../../vecs-client/docs/client-world.md) — replication, render time,
@@ -73,7 +73,15 @@ released this frame is not immediately recycled. A frame is one `beginFrame()`,
73
73
  `progress()`; a wait of `0` frees the id at once. Once the wait is over, allocation hands out free
74
74
  ids before never-used ones, lowest first, so a released id is recycled by one of the next entities
75
75
  created. A `ClientWorld` sets the wait to `0` for the ids of replicated entities and keeps the
76
- default for its local ones. A recycled id is why a stored id is not a safe link between entities
76
+ default for its local ones. A `ServerWorld` allocates its replicated entities from the `entity` pool,
77
+ and holds a freed id for at least `historyFrames + 1` frames (65 by default) whatever the wait
78
+ asks for, so the frame that removed an entity has left every session's history before its id comes
79
+ back (see [Reusing entity ids](../../vecs-server/docs/sessions.md#reusing-entity-ids)). The
80
+ `networkEntityReuseWaitFrames` option of the networked worlds sets the wait of the pool that holds
81
+ the replicated entities: `entity` for a server, `network_entity` for a client. A diff that removes
82
+ an entity and snapshots a new one under the same id is read in a fixed order, which is how a client
83
+ tells the two apart (see [State diffs](../../vecs-protocol/docs/state-diffs.md)). A recycled id is
84
+ why a stored id is not a safe link between entities
77
85
  (see [Link with edges, not stored ids](./relationships.md#link-with-edges-not-stored-ids)).
78
86
  `world.peekNextEid(poolName)` returns the next id a pool would allocate without reserving it. Id 0
79
87
  is reserved.
@@ -155,6 +163,7 @@ world.component(Orange).set(Implements, { interfaces: [Fruit] });
155
163
  | `Traversable` | Exclusive relationships | Maintains hierarchy depth for cascade ordering |
156
164
  | `OwnedTarget` | Exclusive relationships other than `ChildOf` | Owns each explicit target through `ChildOf` and destructive edge replacement |
157
165
  | `OneOf` | Relationships | Constrains each target to be a direct `ChildOf` child of a declared parent |
166
+ | `With` | A component and a target of the same kind | Adding the component also adds the target (the same targets, for edges) |
158
167
 
159
168
  Traits declare policy; their built-in modules install the hooks and maintain the metadata that
160
169
  enforce it. Configure traits in feature-module setup rather than opportunistically inside systems.
@@ -168,6 +177,11 @@ Do not assume all traits have the same lifecycle:
168
177
  `ChildOf` child of the declared parent.
169
178
  - `Traversable` backfills existing relationship instances.
170
179
  - Setting or removing `Implements` recomputes interface aliases.
180
+ - Add `With` before the component is in use, as `world.component(X).add(With, world.component(Y))`.
181
+ It is irrevocable, both must be plain components or both relationships, and it only brings `Y`
182
+ along: an existing `Y` is left as it is, and removing `X` does not remove `Y`. On a `ClientWorld`,
183
+ a network removal of `Y` is skipped while `X` is on the entity (see
184
+ [Applying a frame](../../vecs-client/docs/client-world.md#applying-a-frame)).
171
185
 
172
186
  Treat traits as setup policy unless their documentation explicitly supports runtime changes. Before
173
187
  writing custom component-type bookkeeping, check whether an existing trait already expresses the
@@ -177,6 +177,7 @@ world.component(Orange).set(Implements, { interfaces: [Fruit] });
177
177
  | `Traversable` | An exclusive relationship hierarchy needs depth or cascade ordering |
178
178
  | `OwnedTarget` | An exclusive relationship owns its explicitly added target |
179
179
  | `OneOf` | A relationship's targets must be direct children of a declared parent (enum-style) |
180
+ | `With` | Adding one component should bring another along, as a companion it does not own |
180
181
 
181
182
  Traits declare policy; modules implement it through hooks and metadata. Configure them in module
182
183
  setup, not inside systems. Lifecycle differs by trait: `OwnedTarget` and `Singleton` are setup-time
@@ -184,6 +185,18 @@ policy, `Traversable` backfills, and `Implements` recomputes declarations. Do no
184
185
  dynamically removable. See the [Components guide](./components.md#component-traits-policy-on-component-entities)
185
186
  for the full model and custom-trait guidance.
186
187
 
188
+ `With` is a pair-form trait: `world.component(Motion).add(With, world.component(Position))` adds a
189
+ `Position` whenever a `Motion` is added. It only brings the companion along: removing `Motion` does
190
+ not remove `Position`, and a `Position` the entity already holds is left as it is. A networked
191
+ client has one more rule, because the server decides what a client replicates but the client decides
192
+ what it derives. **`ClientWorld.apply` skips a network removal of a component that another
193
+ component present on the entity brings through `With`**, so a server can swap the source of a
194
+ client-local component in one diff, say a streamed `Position` for a dead-reckoned `Motion`, and the
195
+ entity never goes without it: no `remove` hook fires, and the queries and `DrawBy` children that
196
+ match on it never see it leave. A bringer that the same diff sets counts as present. The entity-destroy
197
+ path is unaffected, and a relationship is removed as sent (see
198
+ [Applying a frame](../../vecs-client/docs/client-world.md#applying-a-frame)).
199
+
187
200
  ### Exclusive component groups
188
201
 
189
202
  `world.setExclusiveComponents(A, B, C)` declares a mutually exclusive set: setting one
@@ -269,6 +282,23 @@ classes or DSL objects (`{ all: [...] }`, `{ any: [...] }`, `{ parent: X }`, `{
269
282
  > `.with(...)`, `.without(...)`, and `.update(C)` own membership. `.update(C)` watches change events
270
283
  > and also requires `C`, so `.with(A).update(C)` matches entities carrying both `A` and `C`.
271
284
 
285
+ **`.without` is not free.** `.without(X)` is `.with({ not: X })`: a `not` term holds every live
286
+ entity that lacks `X`, a world-sized set, and the query intersects it with its positive terms. The
287
+ exclusion does not run over the positive terms' candidates. Vecs seeds that set with one scan of
288
+ the world when the term is first built, then adds each new entity to it, removes each destroyed
289
+ entity from it, and flips an entity in or out when `X` is added to it or removed from it. Changes
290
+ to `X`'s data cost nothing. The term is shared by every query that excludes the same `X`, so the
291
+ upkeep is paid once per distinct exclusion, plus one membership check per query that uses it.
292
+
293
+ - Add `.without(X)` only when the exclusion is needed, and put positive terms next to it: they keep
294
+ the query's members and callbacks small. A query made only of `.without` terms tracks every
295
+ entity in the world.
296
+ - The creation pattern `.with(NewShape).without(Phaser.GameObjects.NewKind).enter(...)` relies on
297
+ that narrowing. The positive term limits the members to the few entities that just got
298
+ `NewShape`, while the exclusion keeps those that already have a display object from entering.
299
+
300
+ Details: [Queries and filters](./queries-and-filters.md#the-cost-of-not-and-without).
301
+
272
302
  ### Reactive callbacks: `enter` / `update` / `exit`
273
303
 
274
304
  - **`enter`** — fires once when an entity _becomes_ a member. Use it for one-time setup
@@ -282,6 +312,14 @@ classes or DSL objects (`{ all: [...] }`, `{ any: [...] }`, `{ parent: X }`, `{
282
312
  Its injected components are read from a **snapshot captured at exit time**, so they're still
283
313
  resolvable even though they're being removed.
284
314
 
315
+ **Repeated writes in one tick.** Vecs never merges `update` events, so a callback fires once per
316
+ routed write. `entity.modified(C)` and `entity.getMut(C)` coalesce through the entity's dirty bit:
317
+ repeated calls before the queued command drains give one `update`. Each `entity.set(C, …)` queues
318
+ its own command and routes its own `update`, so three `set` calls give three callbacks. Write in
319
+ place plus `modified` (or `getMut`) to get one callback per tick, or compare before `set`. When
320
+ producers already use `modified`/`getMut`, do not add a defensive per-tick dedupe or batching layer
321
+ on the consumer side. See [Systems](./systems.md#repeated-writes-in-one-tick).
322
+
285
323
  Choose `onEnter: true` by asking who owns the initial value:
286
324
 
287
325
  - Use **`onEnter: true`** for projection / sync callbacks: copying ECS data into a runtime object,
@@ -339,7 +377,8 @@ Components listed in the inject tuple but **not** guaranteed by `.with(...)` res
339
377
  ### `each` vs reactive — don't scan when you can react
340
378
 
341
379
  - **`.each([...], cb)`** iterates **every matching entity every tick**. Use it only when you
342
- genuinely need a per-frame sweep over all members.
380
+ genuinely need a per-frame sweep over all members. An `.each` must justify running every tick: a
381
+ body that returns early for most members is a reactive system or a narrower query in disguise.
343
382
  - **Reactive `enter`/`update`/`exit`** fire only on change. For "copy A→B when A changes,"
344
383
  reactive `.update(A)` touches _nothing_ on idle entities, where `.each` would scan them all.
345
384
 
@@ -351,6 +390,28 @@ Components listed in the inject tuple but **not** guaranteed by `.with(...)` res
351
390
  .with(PhysicsPosition, RenderPosition).update(PhysicsPosition, (e, p) => e.set(RenderPosition, { x: p.x, y: p.y }));
352
391
  ```
353
392
 
393
+ When the work really is per-tick (a held input, a cooldown, a charge), narrow the query with a tag
394
+ or component that exists **only while the work is live**, instead of sweeping every candidate and
395
+ returning early. A reactive `update` on the intent adds and removes the marker:
396
+
397
+ ```ts
398
+ // ❌ visits every shooter every tick; most return at once
399
+ .with(Weapon).each([Weapon], (e, [w]) => { if (!e.has(TriggerHeld)) return; /* fire */ });
400
+
401
+ // ✅ the cadence system matches only entities that are firing right now
402
+ world.system("TriggerIntent").with(Weapon).update(FireIntent, (e, intent) =>
403
+ intent.held ? e.add(Firing) : e.remove(Firing));
404
+ world.system("Fire").with(Weapon, Firing).interval(0.1).each([Weapon], (e, [w]) => { /* fire */ });
405
+ ```
406
+
407
+ For a countdown, do not write a bespoke "tick every timer" system over all entities. Vecs has no
408
+ per-entity timer component: `interval`, `rate` and `tickSource` (see below) throttle _systems_.
409
+ Model the window as an owned helper entity, an `Exclusive` + `OwnedTarget` relationship target that
410
+ carries the deadline (see [Owned target entities](#owned-target-entities-entity-valued-properties)).
411
+ Its presence is the window: an `.each` over the helper entities visits only open windows, and
412
+ destroying a helper ends its window and removes the edge through the relationship's cleanup policy,
413
+ so no remove hook or flag is needed.
414
+
354
415
  ### `run` and `interval`
355
416
 
356
417
  - **`.run(cb)`** — a system with no per-entity query; the body runs once per tick. For
@@ -853,10 +914,19 @@ system after whatever produces the entity/companion.
853
914
  [Pose Updates Fire Only When The Pose Changes](../../vecs-physics/docs/systems-and-phases.md#pose-updates-fire-only-when-the-pose-changes)).
854
915
  - **vecs-phaser-server: game code owns the physics pose; the render pose is protocol-private.** On
855
916
  the server, read and write the physics `Position` and `Rotation` of every entity, including one
856
- without a `Body`. The render `Position` and `Rotation` and the dead-reckoning `Motion` are wire
857
- encodings of that pose that only the sync systems write: a `DeadReckoned` body has no render pose
858
- at all, and adding one throws (see
917
+ without a `Body`. The render `Position` and `Rotation` and the dead-reckoning `Motion` and
918
+ `MotionParams` are wire encodings of that pose that only the sync systems write: a `DeadReckoned`
919
+ body has no render pose at all, and adding one throws (see
859
920
  [Dead reckoning](../../vecs-phaser-server/README.md#dead-reckoning)).
921
+ - **vecs-phaser-server: switch `DeadReckoned` on a change of state, and keep a constantly driven body
922
+ streamed.** The tag may be added or removed at any time, so a body can be dead reckoned while it
923
+ drifts and streamed while joints, thrust or a tow drive it (a driven body diverges from its
924
+ prediction every step and would cost a baseline of about 27 bytes per step against about 22 for a
925
+ streamed pose update). Toggle it when the state changes, not every frame: each switch costs a
926
+ baseline one way and a pose update the other, and clients blend the hand-off over `blendMs`. A
927
+ render pose or `Motion` created or switched late in a frame, in `PRE_STORE`, `ON_STORE` or an RPC
928
+ handler, is still synced from the physics pose before the frame is recorded (see
929
+ [Switching at runtime](../../vecs-phaser-server/README.md#switching-at-runtime)).
860
930
  - **vecs-client: attach a `ClientWorld` socket only after `socket.connect()` resolves,** so no ack
861
931
  precedes the data channel (`connectDgram` does this). Pending `invoke` calls reject with
862
932
  `RPC_CLOSED_CODE` on disconnect or `dispose` (see
@@ -44,6 +44,31 @@ evaluates it against each entity pointing at the candidate and also matches when
44
44
  queries and systems. `target` / `parent` are fully reactive to edge changes and target-side changes
45
45
  — see [Relationships](./relationships.md#querying-through-relationships).
46
46
 
47
+ ### The cost of `not` and `.without`
48
+
49
+ `.without(X)` is `.with({ not: X })` (`lib/vecs/src/query/query.ts`, `without`), and `.with` merges
50
+ into `{ all: [...] }`. A `not` term is built from the world term plus `X`'s term and holds every live
51
+ entity that lacks `X` (`lib/vecs/src/terms/not_term.ts`). It is a world-sized set, independent of
52
+ the query's positive terms, and the `all` term intersects it with them.
53
+
54
+ | Event | Effect on a `not X` term |
55
+ | ----------------------------------- | ----------------------------------------------------------------------------- |
56
+ | Term built (first use) | One scan of the whole world seeds the set (`term.ts`, `_initializeFromWorld`) |
57
+ | Entity created or destroyed | Added to or removed from the set (`world_term.ts`, `entityCreated`) |
58
+ | `X` added to or removed from entity | The entity leaves or re-enters the set |
59
+ | `X` data modified | Nothing (`not_term.ts`, `onTermRefresh` is a no-op) |
60
+
61
+ Terms are cached by canonical key, so the upkeep is paid once per distinct exclusion, not once per
62
+ query. Each query that uses it adds one membership check per change of the set, and the positive
63
+ term comes first, so most checks stop there.
64
+
65
+ - Use `.without` only when the exclusion is needed, and pair it with positive terms that keep the
66
+ query's members small. A query with only `.without` terms tracks every entity in the world, so
67
+ its `enter` fires for each created entity.
68
+ - The creation pattern `.with(NewShape).without(Phaser.GameObjects.NewKind).enter(...)` depends on
69
+ this: the positive term limits the members, and the exclusion drops the few that already hold the
70
+ object.
71
+
47
72
  ### `QuerySpec`: adding `cascade` and `hint`
48
73
 
49
74
  Anywhere a DSL is accepted, you can pass the object form
@@ -40,8 +40,9 @@ component field (`ownerEid = 0`, `target: Entity | undefined`). The world mainta
40
40
  cannot maintain a field:
41
41
 
42
42
  - **A stored eid can come to name another entity.** A destroyed entity's id is reused once its
43
- pool's `reuseWaitFrames` have passed: 16 frames by default, and `0` for replicated entities on a
44
- `ClientWorld` (see [Id pools](./components.md#id-pools)). Allocation hands out free ids before
43
+ pool's `reuseWaitFrames` have passed: 16 frames by default, `0` for replicated entities on a
44
+ `ClientWorld`, and at least `historyFrames + 1` on a `ServerWorld` (see
45
+ [Id pools](./components.md#id-pools)). Allocation hands out free ids before
45
46
  never-used ones, so one of the next entities created takes it. Until an entity does,
46
47
  `world.getEntity(id)` returns `undefined`; from then on it returns that live, unrelated entity and
47
48
  the stale id looks valid.
package/docs/systems.md CHANGED
@@ -44,6 +44,7 @@ the full query DSL (operator semantics in
44
44
  .with({ parent: Body }) // ChildOf target has Body
45
45
  ```
46
46
 
47
+ `.without` is not free: see [its cost](./queries-and-filters.md#the-cost-of-not-and-without).
47
48
  Multiple `with` calls merge with `all`. The `source` / `children` operators are non-reactive and
48
49
  rejected by systems — they are filter-only (see
49
50
  [Queries and filters](./queries-and-filters.md#filters-one-shot-scans)).
@@ -131,6 +132,25 @@ Use `.update(C, ...)` by itself when `C` is the membership shape and watched val
131
132
  A query or system can register only one `update(C, ...)` callback for each component. A duplicate
132
133
  registration throws instead of replacing the existing callback.
133
134
 
135
+ #### Repeated writes in one tick
136
+
137
+ Each routed write is one `update` event, and a system's inbox replays its events in arrival order
138
+ without merging any (`lib/vecs/src/system.ts`, `_pushUpdate` and the drain loop in
139
+ `_runDeferred`). What differs is how many events a write routes:
140
+
141
+ | Write | Events routed for `C` in one drain window |
142
+ | -------------------------- | ------------------------------------------------------------------------------------------------ |
143
+ | `entity.set(C, …)` × n | n: every call queues its own command and fires its own `onSet` and `update` |
144
+ | `entity.modified(C)` × n | 1: the first call sets the dirty bit, the command clears it when it runs, later calls are no-ops |
145
+ | `entity.getMut(C, cb)` × n | 1: it marks modified through the same dirty bit |
146
+
147
+ Coalescing needs a queued command, so it applies to calls made while commands are deferred (inside
148
+ system callbacks); outside them `modified` applies immediately. To get one callback per tick, write
149
+ in place and call `modified` (or use `getMut`), or compare before `set` and skip equal values. Do
150
+ not add a per-tick dedupe or batching step in the consumer when producers already use
151
+ `modified`/`getMut`. Source: `lib/vecs/src/entity/entity.components.ts` (`_markModified`, `set`,
152
+ `_notifyRaw`).
153
+
134
154
  ### `.exit(callback)` / `.exit(inject, callback)` / `.exit(options, callback)`
135
155
 
136
156
  Fires when an entity stops matching (component removed, or entity destroyed). Directly injected
@@ -149,7 +169,9 @@ object remain visible:
149
169
  ### `.each(components, callback)` / `.each(options, callback)`
150
170
 
151
171
  Fires every tick the system runs, once per tracked entity, regardless of change. Use it only for
152
- genuinely per-frame sweeps — when reacting to change suffices, prefer `update` (see
172
+ genuinely per-frame sweeps — when reacting to change suffices, prefer `update`. An `.each` must
173
+ justify running every tick, and when it does, narrow `.with(...)` to a tag that exists only while
174
+ the work is live (see
153
175
  [Design guide](./design-guide.md#each-vs-reactive--dont-scan-when-you-can-react)):
154
176
 
155
177
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vworlds/vecs",
3
- "version": "1.0.50",
3
+ "version": "1.0.51",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -27,12 +27,5 @@
27
27
  "type": "git",
28
28
  "url": "https://github.com/vworlds/vecs",
29
29
  "directory": "lib/vecs"
30
- },
31
- "lint-staged": {
32
- "*.ts": [
33
- "prettier --write",
34
- "eslint --fix"
35
- ],
36
- "*.md": "prettier --write"
37
30
  }
38
31
  }
@@ -40,6 +40,10 @@ Before implementing, identify:
40
40
  - The state represented by each component and whether absence has meaning.
41
41
  - Which component transition creates, updates, or removes related state or resources.
42
42
  - Whether the work should react through `enter`, `update`, or `exit` rather than scan every frame.
43
+ An `.each` must justify running every tick; when it does, narrow its query with a tag that exists
44
+ only while the work is live (for example a `Firing` tag that a reactive `update` on the intent
45
+ adds and removes) instead of looping over every candidate and returning early.
46
+ ([`each` vs reactive](../../docs/design-guide.md#each-vs-reactive--dont-scan-when-you-can-react))
43
47
  - Which phase owns the work and what earlier or later phase it must order against.
44
48
  - Whether a multi-target relationship, an `Exclusive` relationship, an explicitly created owned
45
49
  target, a grouped query, or an attached resource expresses the lifecycle better than imperative
@@ -73,6 +77,15 @@ other anti-patterns and pitfalls; each rule below links to the detail.
73
77
  - Do not call `getMut` per tick unless a change is intended. It marks the component modified even
74
78
  without a callback and even if nothing is written, which fires `update` watchers and replication.
75
79
  Read with `get`. ([`getMut`](../../docs/entities.md#getmut-and-ensure-mutating-in-place))
80
+ - `.without(X)` does not filter the positive terms' candidates: it keeps a world-sized "not X" set,
81
+ updated on every entity create and destroy and every `X` add or remove, and intersects it with
82
+ them. Use it only when needed, after positive terms that keep the members small; a query with only
83
+ `.without` terms tracks the whole world.
84
+ ([Cost of `.without`](../../docs/queries-and-filters.md#the-cost-of-not-and-without))
85
+ - `entity.modified(C)` and `getMut(C)` coalesce into one `update` per drain; every `entity.set(C, …)`
86
+ routes its own, and the system inbox never merges events. Write in place plus `modified`, or
87
+ compare before `set`, and do not add per-tick dedupe batches when producers use `modified`/`getMut`.
88
+ ([Repeated writes](../../docs/systems.md#repeated-writes-in-one-tick))
76
89
  - A system's `enter` reaches only entities still in its query when the system drains. An entity
77
90
  that left the query or died first gets neither `enter` nor the paired `exit`, and a re-entry
78
91
  within one drain gives `enter`, `exit`, `enter`, so keep setup and teardown symmetric.