@vworlds/vecs 1.0.25 → 1.0.26
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 +29 -778
- package/dist/component.d.ts +1 -1
- package/dist/component.js +1 -1
- package/dist/component.js.map +1 -1
- package/dist/component_meta.d.ts +5 -1
- package/dist/component_meta.js +10 -0
- package/dist/component_meta.js.map +1 -1
- package/dist/dsl.d.ts +2 -2
- package/dist/entity/entity.base.d.ts +2 -18
- package/dist/entity/entity.base.js +2 -20
- package/dist/entity/entity.base.js.map +1 -1
- package/dist/entity/entity.components.d.ts +1 -0
- package/dist/entity/entity.components.js +50 -0
- package/dist/entity/entity.components.js.map +1 -1
- package/dist/entity/entity.lifecycle.d.ts +3 -3
- package/dist/entity/entity.lifecycle.js +6 -9
- package/dist/entity/entity.lifecycle.js.map +1 -1
- package/dist/entity/entity.relationships.js +2 -2
- package/dist/entity/entity.relationships.js.map +1 -1
- package/dist/filter.d.ts +1 -0
- package/dist/filter.js +3 -2
- package/dist/filter.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/inject.d.ts +3 -2
- package/dist/inject.js.map +1 -1
- package/dist/modules/implements.d.ts +14 -0
- package/dist/modules/implements.js +98 -0
- package/dist/modules/implements.js.map +1 -0
- package/dist/package.json +1 -4
- package/dist/query/callbacks.d.ts +6 -2
- package/dist/query/callbacks.js +5 -2
- package/dist/query/callbacks.js.map +1 -1
- package/dist/query/query.d.ts +14 -1
- package/dist/query/query.js +26 -15
- package/dist/query/query.js.map +1 -1
- package/dist/system.d.ts +5 -4
- package/dist/system.js +17 -6
- package/dist/system.js.map +1 -1
- package/dist/util/array_map.d.ts +70 -12
- package/dist/util/array_map.js +113 -26
- package/dist/util/array_map.js.map +1 -1
- package/dist/util/bitset.js +0 -17
- package/dist/util/bitset.js.map +1 -1
- package/dist/util/events.d.ts +42 -12
- package/dist/util/events.js +94 -43
- package/dist/util/events.js.map +1 -1
- package/dist/util/ordered_set.js +43 -19
- package/dist/util/ordered_set.js.map +1 -1
- package/dist/world/world.deferred.js +2 -0
- package/dist/world/world.deferred.js.map +1 -1
- package/dist/world/world.entities.d.ts +8 -1
- package/dist/world/world.entities.js +25 -6
- package/dist/world/world.entities.js.map +1 -1
- package/dist/world/world.js +8 -1
- package/dist/world/world.js.map +1 -1
- package/dist/world/world.queries.js +6 -1
- package/dist/world/world.queries.js.map +1 -1
- package/dist/world/world.storage.d.ts +2 -2
- package/dist/world/world.storage.js +6 -3
- package/dist/world/world.storage.js.map +1 -1
- package/docs/README.md +50 -0
- package/docs/components.md +267 -0
- package/docs/concepts.md +86 -0
- package/docs/design-guide.md +506 -0
- package/docs/entities.md +175 -0
- package/docs/execution-model.md +173 -0
- package/docs/getting-started.md +215 -0
- package/docs/glossary.md +113 -0
- package/docs/modules.md +108 -0
- package/docs/queries-and-filters.md +187 -0
- package/docs/relationships.md +148 -0
- package/docs/systems.md +311 -0
- package/docs/utilities.md +139 -0
- package/package.json +1 -4
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
# vecs Design Guide
|
|
2
|
+
|
|
3
|
+
How to design with **vecs** — for humans and agents. This is a practical, opinionated
|
|
4
|
+
guide distilled from real designs (the physics engine, the Phaser render layer, the demo).
|
|
5
|
+
It explains the model, the execution semantics that make the tricks work, the idioms to
|
|
6
|
+
reach for, and the anti-patterns to avoid.
|
|
7
|
+
|
|
8
|
+
This guide assumes you know the machinery; the [docs home](./README.md) holds the
|
|
9
|
+
subsystem guides — start with [Concepts](./concepts.md) and the
|
|
10
|
+
[Execution model](./execution-model.md) if you are new.
|
|
11
|
+
|
|
12
|
+
> **Golden rule:** components are _data_, systems are _behavior_, and **lifecycle is driven
|
|
13
|
+
> by the presence and change of components — not by imperative bookkeeping.** Almost every
|
|
14
|
+
> good pattern below is a consequence of taking that seriously.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 1. The model in one minute
|
|
19
|
+
|
|
20
|
+
| Concept | What it is |
|
|
21
|
+
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
22
|
+
| **World** | The container. Holds entities, registered components, systems, phases, modules. You drive it with `world.progress(now, delta)`. |
|
|
23
|
+
| **Entity** | An id with a set of components. Create with `world.entity()`. |
|
|
24
|
+
| **Component** | A plain data class. Registered with `world.component(C)` (or by a module). No behavior. |
|
|
25
|
+
| **System** | A query + reactive callbacks (`enter`/`update`/`exit`) or a `run`/`each` body. Created with `world.system(name)`. |
|
|
26
|
+
| **Phase** | An ordered slot in the per-tick pipeline. Systems are assigned to a phase with `.phase(P)`. |
|
|
27
|
+
| **Module** | A unit of packaging: `class M extends Module<Config>` whose `init()` registers components, systems, phases, and policy. Installed with `world.module(M, config)`. |
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 2. The execution model (read this first — every trick follows from it)
|
|
32
|
+
|
|
33
|
+
`world.progress(now, delta)` runs the **pipeline**: systems grouped by **phase**, phases in a
|
|
34
|
+
fixed order. (The full mechanics are in [Execution model](./execution-model.md); this section
|
|
35
|
+
is the design-relevant core.) Internalize these four facts:
|
|
36
|
+
|
|
37
|
+
1. **Phases run in a fixed order.** The eight built-in phases (`lib/vecs/src/modules/pipeline.ts`):
|
|
38
|
+
|
|
39
|
+
| Phase | Use it for |
|
|
40
|
+
| ------------- | ------------------------------------------------------------------------------------------------------ |
|
|
41
|
+
| `ON_LOAD` | Load data _into_ the ECS: inputs, network snapshots (`ClientWorld.apply()` runs here), external reads. |
|
|
42
|
+
| `POST_LOAD` | Process raw loaded data: turn key presses into high-level actions, map inputs to commands. |
|
|
43
|
+
| `PRE_UPDATE` | Final prep before game logic: clean up last frame, prepare state gameplay will use. |
|
|
44
|
+
| `ON_UPDATE` | **The default phase.** Main gameplay/simulation logic goes here. |
|
|
45
|
+
| `ON_VALIDATE` | Validate state after updates: collision detection, constraint checks. |
|
|
46
|
+
| `POST_UPDATE` | Apply corrections from validation: resolve collisions, fix up state. |
|
|
47
|
+
| `PRE_STORE` | Prepare data for output: compute transforms/render state once all logic is done, before storage. |
|
|
48
|
+
| `ON_STORE` | Store/emit the final frame: rendering, writing output, sending snapshots. |
|
|
49
|
+
|
|
50
|
+
2. **Within a phase, systems run in registration order** (creation/eid order). So the order you
|
|
51
|
+
_create_ systems (e.g. the order an umbrella module loads sub-modules) is the order they run
|
|
52
|
+
_within the same phase_. Across **different** phases, ordering is guaranteed by the phase
|
|
53
|
+
spine — independent of registration order.
|
|
54
|
+
|
|
55
|
+
3. **Structural changes are deferred and flushed after every system.** When a system calls
|
|
56
|
+
`entity.set/add/remove`/`entity.destroy()` during its run, the change is queued and applied
|
|
57
|
+
in a `flush()` **immediately after that system finishes**
|
|
58
|
+
(`lib/vecs/src/world/world.pipeline.ts`: `system._run()` then `this.flush()`). Consequence:
|
|
59
|
+
**a structural change made by system A is visible to every
|
|
60
|
+
later system in the same tick** (later in the same phase, or in a later phase). It is _not_
|
|
61
|
+
visible to A mid-iteration. This single fact enables the companion-component lifecycle, the
|
|
62
|
+
teardown-before-create ordering, and same-tick reconciliation.
|
|
63
|
+
|
|
64
|
+
4. **Reactive routing.** `set`/`add`/`remove`/`destroy` route `enter`/`update`/`exit` events into
|
|
65
|
+
the matching systems' inboxes; each system drains its inbox at the top of its run. So systems
|
|
66
|
+
react to change without scanning.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## 3. Components
|
|
71
|
+
|
|
72
|
+
### Small and single-purpose, always optional
|
|
73
|
+
|
|
74
|
+
Prefer many small components over one big one. `Position {x,y}`, `Rotation {angle}`,
|
|
75
|
+
`LinearVelocity {x,y}` — **not** a `Transform {x,y,angle,sx,sy}` god-component. Why:
|
|
76
|
+
|
|
77
|
+
- **Queries compose.** `.with(Position, Rotation)` selects exactly what a system needs; a system
|
|
78
|
+
that only reads position isn't coupled to rotation.
|
|
79
|
+
- **Optionality is the point.** An entity has a component or it doesn't. A render entity that
|
|
80
|
+
never rotates simply omits `Rotation`; a sync system gated on `.with(Rotation, ...)` ignores it.
|
|
81
|
+
Don't bake optional fields into a required mega-component with sentinel defaults.
|
|
82
|
+
- **Exclusivity and reuse.** Small components can be grouped, made exclusive, and reused across
|
|
83
|
+
unrelated features.
|
|
84
|
+
|
|
85
|
+
A component is a **plain data class**. No methods, no behavior:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
export class Position {
|
|
89
|
+
x = 0;
|
|
90
|
+
y = 0;
|
|
91
|
+
}
|
|
92
|
+
export class Rotation {
|
|
93
|
+
angle = 0;
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Networked components
|
|
98
|
+
|
|
99
|
+
Mark wire fields with `@type` from `@vworlds/vecs-wire` (conventionally imported as
|
|
100
|
+
`import { type as wireType }`). The
|
|
101
|
+
**order** you pass components in `networkComponents` _is the wire protocol_ (type id = index + 1).
|
|
102
|
+
Reordering is a breaking protocol change — snapshot-test the order.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
class Arc {
|
|
106
|
+
@wireType("f32") radius = 0.5;
|
|
107
|
+
}
|
|
108
|
+
new ServerWorld({ networkComponents: [Position, Rotation, /* … */ Arc] });
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
> **Decorator gotcha.** `@type` must work under both _legacy_ (`experimentalDecorators`, used by
|
|
112
|
+
> `tsc`) and _standard_ TC39 decorators (what esbuild — `tsx`/Vite — emits for source files).
|
|
113
|
+
> If you load decorated component classes from **source** through an esbuild-based runtime and
|
|
114
|
+
> their fields silently fail to register, that's the standard-vs-legacy mismatch. vecs-wire
|
|
115
|
+
> supports both; if you author new decorators, preserve that.
|
|
116
|
+
|
|
117
|
+
### Companion / wrapper components (hold non-ECS resources)
|
|
118
|
+
|
|
119
|
+
To attach a non-ECS resource (a Phaser `GameObject`, a WASM handle, a DOM node) to an entity,
|
|
120
|
+
**wrap it in a local, non-networked component** instead of keeping a side `Map<eid, resource>`.
|
|
121
|
+
The entity's component set _is_ the lifecycle; queries drive create/destroy; reset is free.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
export class VGameObject {
|
|
125
|
+
obj!: Phaser.GameObjects.GameObject;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
world.component(VGameObject).onRemove((_entity, vgo) => vgo.obj.destroy());
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`onRemove` (below) releases the resource whenever the component leaves — whether removed
|
|
132
|
+
explicitly or because the entity was destroyed.
|
|
133
|
+
|
|
134
|
+
### Component lifecycle hooks: `onAdd` / `onSet` / `onRemove`
|
|
135
|
+
|
|
136
|
+
`world.component(C)` returns a handle with hooks fired on every entity:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
world.component(Impulse).onSet((entity, impulse) => {
|
|
140
|
+
/* accumulate */
|
|
141
|
+
});
|
|
142
|
+
world.component(VGameObject).onRemove((_entity, vgo) => vgo.obj.destroy());
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Use them for resource management and cross-cutting reactions that aren't worth a system. (Note
|
|
146
|
+
the difference from systems: hooks are per-component-instance and run at the mutation site;
|
|
147
|
+
systems are queries that run in a phase.)
|
|
148
|
+
|
|
149
|
+
### Exclusive component groups
|
|
150
|
+
|
|
151
|
+
`world.setExclusiveComponents(A, B, C)` declares a mutually exclusive set: setting one
|
|
152
|
+
**auto-removes** the others. This is how you model "exactly one of N" (one renderable per
|
|
153
|
+
entity; one physics shape per body) and it makes **type changes** clean — see the swap pattern
|
|
154
|
+
in §7.
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
world.setExclusiveComponents(...phaserRenderableComponents); // Arc | Rectangle | Text | …
|
|
158
|
+
entity.set(Arc, { radius: 1 });
|
|
159
|
+
entity.set(Rectangle, { width: 2, height: 1 }); // Arc auto-removed
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Relationships
|
|
163
|
+
|
|
164
|
+
A relationship is a component whose value points at another entity:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
class InCell extends Relationship {}
|
|
168
|
+
entity.set(InCell, { target: cell }); // retarget freely
|
|
169
|
+
entity.parent(InCell); // the target entity
|
|
170
|
+
world.query("ByCell").with(Networked, Position).groupBy(InCell); // index by target
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
- **`Relationship.target` is an `Entity` — not serializable, so relationships are not
|
|
174
|
+
networkable.** Don't try to replicate them.
|
|
175
|
+
- **Retargeting is a plain data change** (no archetype churn). Prefer retargeting a relationship
|
|
176
|
+
over swapping marker components to express "which bucket this entity is in."
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## 4. Systems & queries
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
world
|
|
184
|
+
.system("name")
|
|
185
|
+
.with(/* membership spec */)
|
|
186
|
+
.without(/* exclusion spec */)
|
|
187
|
+
.phase(ON_UPDATE)
|
|
188
|
+
.enter(/* … */)
|
|
189
|
+
.update(C /* … */)
|
|
190
|
+
.exit(/* … */);
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Membership: `.with` / `.without`
|
|
194
|
+
|
|
195
|
+
`.with(...)` defines who's in the query; `.without(...)` excludes. Specs can be component
|
|
196
|
+
classes or DSL objects (`{ all: [...] }`, `{ any: [...] }`, `{ parent: X }`, `{ target: [Rel, Tag] }`).
|
|
197
|
+
|
|
198
|
+
> `.with(...)`, `.without(...)`, and `.update(C)` own membership. `.update(C)` watches change events
|
|
199
|
+
> and also requires `C`, so `.with(A).update(C)` matches entities carrying both `A` and `C`.
|
|
200
|
+
|
|
201
|
+
### Reactive callbacks: `enter` / `update` / `exit`
|
|
202
|
+
|
|
203
|
+
- **`enter`** — fires once when an entity _becomes_ a member. Use it for one-time setup
|
|
204
|
+
(create the resource).
|
|
205
|
+
- **`update(C, …)`** — adds `C` to the predicate and fires when watched component `C` is modified.
|
|
206
|
+
**It also fires on entry for every already-present watched component** (`System._enter` re-pushes
|
|
207
|
+
update events for watched components). So `.update(C)` initializes on entry _and_ reacts to
|
|
208
|
+
changes — **don't write a separate `.enter` that duplicates the same logic.**
|
|
209
|
+
- **`exit`** — fires when an entity stops matching (a component removed, or the entity destroyed).
|
|
210
|
+
Its injected components are read from a **snapshot captured at exit time**, so they're still
|
|
211
|
+
resolvable even though they're being removed.
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
// ❌ redundant
|
|
215
|
+
.enter([Rotation, VGameObject], (_e, [r, vgo]) => vgo.obj.setRotation(coords.rot(r.angle)))
|
|
216
|
+
.update(Rotation, [VGameObject], (_e, r, [vgo]) => vgo.obj.setRotation(coords.rot(r.angle)));
|
|
217
|
+
|
|
218
|
+
// ✅ update fires on entry too
|
|
219
|
+
.update(Rotation, [VGameObject], (_e, r, [vgo]) => vgo.obj.setRotation(coords.rot(r.angle)));
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Injection
|
|
223
|
+
|
|
224
|
+
Pull components into the callback instead of `entity.get()`-ing them by hand:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
.enter([Arc], (entity, [arc]) => { /* … */ })
|
|
228
|
+
.update(Arc, [VGameObject], (entity, arc, [vgo]) => { /* … */ })
|
|
229
|
+
.exit([Arc, VGameObject], (entity, [arc, vgo]) => { /* … */ })
|
|
230
|
+
.each([Position, Velocity], (entity, [pos, vel]) => { /* … */ })
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Components listed in the inject tuple but **not** guaranteed by `.with(...)` resolve as
|
|
234
|
+
`T | undefined` (the types reflect this) — guard them; the ones in `.with` are always present.
|
|
235
|
+
|
|
236
|
+
### `each` vs reactive — don't scan when you can react
|
|
237
|
+
|
|
238
|
+
- **`.each([...], cb)`** iterates **every matching entity every tick**. Use it only when you
|
|
239
|
+
genuinely need a per-frame sweep over all members.
|
|
240
|
+
- **Reactive `enter`/`update`/`exit`** fire only on change. For "copy A→B when A changes,"
|
|
241
|
+
reactive `.update(A)` touches _nothing_ on idle entities, where `.each` would scan them all.
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
// ❌ scans every body every tick, even if it didn't move
|
|
245
|
+
.with(PhysicsPosition, RenderPosition).each([...], (e, [...]) => { if (changed) e.set(...) });
|
|
246
|
+
|
|
247
|
+
// ✅ fires only when physics actually moved the body
|
|
248
|
+
.with(PhysicsPosition, RenderPosition).update(PhysicsPosition, (e, p) => e.set(RenderPosition, { x: p.x, y: p.y }));
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### `run` and `interval`
|
|
252
|
+
|
|
253
|
+
- **`.run(cb)`** — a system with no per-entity query; the body runs once per tick. For
|
|
254
|
+
frame-level work, external I/O, stepping an embedded simulation.
|
|
255
|
+
- **`.interval(seconds)`** — throttle a system to run at most every `seconds`.
|
|
256
|
+
|
|
257
|
+
### Grouped queries (indexing without scans)
|
|
258
|
+
|
|
259
|
+
`groupBy` maintains a live bucketing you can look up by key — for spatial grids, interest
|
|
260
|
+
management, any "find members in bucket K" need — without re-scanning each tick:
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
const ballsByCell = world
|
|
264
|
+
.query("ByCell")
|
|
265
|
+
.with(Networked, Ball, Position)
|
|
266
|
+
.groupBy([Position], (_e, [p]) => cellIndex(p)); // re-buckets reactively as Position changes
|
|
267
|
+
|
|
268
|
+
ballsByCell.group(k)?.forEach([Position, Ball], (e, [p, b]) => {
|
|
269
|
+
/* candidates in cell k */
|
|
270
|
+
});
|
|
271
|
+
ballsByCell.group(k)?.count;
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### One system = one phase
|
|
275
|
+
|
|
276
|
+
A system lives in exactly **one** phase. If a concern needs part of its work earlier and part
|
|
277
|
+
later, **split it into multiple systems** in different phases (e.g. teardown in `PRE_STORE`,
|
|
278
|
+
build in `ON_STORE`). You cannot put a system's `.exit` in one phase and its `.update` in another.
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## 5. Phases & ordering
|
|
283
|
+
|
|
284
|
+
- **Map your work onto the standard taxonomy** (§2). Input → `ON_LOAD`/`POST_LOAD`; gameplay →
|
|
285
|
+
`ON_UPDATE`; validation → `ON_VALIDATE`; corrections → `POST_UPDATE`; render prep → `PRE_STORE`;
|
|
286
|
+
output/render/send → `ON_STORE`.
|
|
287
|
+
- **Avoid custom phases for application/render code.** `insertPhaseAfter` exists, but it's for
|
|
288
|
+
engine-level modules that genuinely need their own spine (e.g. physics inserts
|
|
289
|
+
`physics-pre`/`physics-step`/`physics-post` after `ON_UPDATE`). App and render systems should
|
|
290
|
+
use the built-in phases; a render system just needs to run _after_ whoever produced its inputs,
|
|
291
|
+
and `PRE_STORE`/`ON_STORE` are after gameplay and physics.
|
|
292
|
+
- **Order within a phase = registration order**, which an umbrella module controls by the order
|
|
293
|
+
it loads sub-modules. **Cross-module ordering should be expressed with phases, not registration
|
|
294
|
+
order** — never rely on "module A happened to load before module B" for correctness when the two
|
|
295
|
+
are independent. (Example: teardown must run before creation across all shapes → put teardown
|
|
296
|
+
in `PRE_STORE` and creation in `ON_STORE`; then it holds no matter which shape module loaded
|
|
297
|
+
first.)
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## 6. Modules
|
|
302
|
+
|
|
303
|
+
A module packages a feature: it registers components, systems, exclusivity, and (rarely) phases.
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
export class PhaserServerModule extends Module<undefined> {
|
|
307
|
+
public init(): void {
|
|
308
|
+
const world = this.world;
|
|
309
|
+
world.setExclusiveComponents(...phaserRenderableComponents);
|
|
310
|
+
world
|
|
311
|
+
.system("phaser.sync.position")
|
|
312
|
+
.with(PhysicsPosition, RenderPosition)
|
|
313
|
+
.phase(PRE_STORE)
|
|
314
|
+
.update(PhysicsPosition, (e, p) => e.set(RenderPosition, { x: p.x, y: p.y }));
|
|
315
|
+
world
|
|
316
|
+
.system("phaser.sync.rotation")
|
|
317
|
+
.with(PhysicsRotation, RenderRotation)
|
|
318
|
+
.phase(PRE_STORE)
|
|
319
|
+
.update(PhysicsRotation, (e, r) => e.set(RenderRotation, { angle: r.angle }));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
world.module(PhaserServerModule); // registered once; init runs once
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Guidelines:
|
|
327
|
+
|
|
328
|
+
- **Declare systems inline in `init`.** Don't write an `installFooSystem(world)` helper that just
|
|
329
|
+
registers one system — it adds a layer for nothing.
|
|
330
|
+
- **Config flows through `world.module(M, config)`** and into `init(config)`. Keep it minimal;
|
|
331
|
+
prefer zero-config when a sensible default (e.g. a standard phase) exists.
|
|
332
|
+
- **One module per cohesive thing.** A big feature can be **one module per item** (e.g. an
|
|
333
|
+
`ArcModule`, `RectangleModule`, …) plus a thin **umbrella module** whose `init` composes them:
|
|
334
|
+
|
|
335
|
+
```ts
|
|
336
|
+
export class PhaserRenderModule extends Module<{ scene; pixelsPerMeter? }> {
|
|
337
|
+
public init(cfg): void {
|
|
338
|
+
const coords = new CoordSpace(cfg.scene, cfg.pixelsPerMeter);
|
|
339
|
+
this.world.component(VGameObject).onRemove((_e, vgo) => vgo.obj.destroy());
|
|
340
|
+
this.world.module(ArcModule, { scene: cfg.scene, coords });
|
|
341
|
+
this.world.module(RectangleModule, { scene: cfg.scene, coords });
|
|
342
|
+
// … shape modules first …
|
|
343
|
+
this.world.module(TransformStyleSyncModule, { coords }); // … then sync, so create precedes sync within ON_STORE
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
A module's `init` can `this.world.module(Sub, subConfig)` to compose, and the **load order sets
|
|
349
|
+
same-phase ordering**.
|
|
350
|
+
|
|
351
|
+
- **Modules own cross-cutting policy:** exclusivity (`setExclusiveComponents`), phase placement,
|
|
352
|
+
component registration. Keep that out of application code.
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## 7. Patterns (reach for these)
|
|
357
|
+
|
|
358
|
+
### Resource-as-component lifecycle (kills the `eid → object` map)
|
|
359
|
+
|
|
360
|
+
The canonical pattern for binding an external object to entities, fully query-driven:
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
class VGameObject {
|
|
364
|
+
obj!: RenderObject;
|
|
365
|
+
}
|
|
366
|
+
world.component(VGameObject).onRemove((_e, vgo) => vgo.obj.destroy()); // release on remove/destroy
|
|
367
|
+
|
|
368
|
+
// CREATE: a renderable that has no object yet
|
|
369
|
+
world
|
|
370
|
+
.system("new.arc")
|
|
371
|
+
.with(Arc)
|
|
372
|
+
.without(VGameObject)
|
|
373
|
+
.phase(ON_STORE)
|
|
374
|
+
.enter([Arc], (e) => {
|
|
375
|
+
const obj = scene.add.arc(0, 0);
|
|
376
|
+
e.set(VGameObject, { obj });
|
|
377
|
+
});
|
|
378
|
+
// ↑ set(VGameObject) is flushed after this system → the entity now matches the systems below, this same tick
|
|
379
|
+
|
|
380
|
+
// UPDATE geometry on the bound object (fires on entry + on change)
|
|
381
|
+
world
|
|
382
|
+
.system("render.arc")
|
|
383
|
+
.with(Arc, VGameObject)
|
|
384
|
+
.phase(ON_STORE)
|
|
385
|
+
.update(Arc, [VGameObject], (e, arc, [vgo]) =>
|
|
386
|
+
(vgo.obj as ArcObject).setRadius(coords.len(arc.radius))
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
// TEARDOWN: when the shape leaves (type swap) or the entity dies, drop the companion → onRemove destroys
|
|
390
|
+
world
|
|
391
|
+
.system("teardown.arc")
|
|
392
|
+
.with(Arc, VGameObject)
|
|
393
|
+
.phase(PRE_STORE)
|
|
394
|
+
.exit([Arc, VGameObject], (e) => {
|
|
395
|
+
if (e.has(VGameObject)) e.remove(VGameObject);
|
|
396
|
+
});
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Why it's good: no manual map; **destroy/reset is automatic** (destroying the entity removes
|
|
400
|
+
`VGameObject` → `onRemove` releases the resource); **type changes work for free** via exclusivity
|
|
401
|
+
(see below). Note the ordering: teardown in `PRE_STORE` runs before creation in `ON_STORE`, so an
|
|
402
|
+
`Arc → Rectangle` swap removes the old companion _before_ the new shape's `.without(VGameObject)`
|
|
403
|
+
creation query runs — same frame.
|
|
404
|
+
|
|
405
|
+
### Exclusivity-driven type swap
|
|
406
|
+
|
|
407
|
+
With `setExclusiveComponents(...renderables)`, replacing the renderable removes the old one. The
|
|
408
|
+
old shape's teardown system (`.with(OldShape, VGameObject).exit`) fires and drops the companion;
|
|
409
|
+
the new shape's creation system (`.with(NewShape).without(VGameObject).enter`) fires and rebinds.
|
|
410
|
+
No special-casing anywhere.
|
|
411
|
+
|
|
412
|
+
### Reactive sync between two component spaces
|
|
413
|
+
|
|
414
|
+
Copy `A → B` only when `A` changes, gated on both existing (so you never _create_ `B`, you only
|
|
415
|
+
keep an existing `B` in sync — pure-ECS: the owner decides whether `B` exists):
|
|
416
|
+
|
|
417
|
+
```ts
|
|
418
|
+
.with(PhysicsPosition, RenderPosition).update(PhysicsPosition, (e, p) => e.set(RenderPosition, { x: p.x, y: p.y }));
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
### Relationship retargeting + grouped queries for spatial/interest indexing
|
|
422
|
+
|
|
423
|
+
Bucket entities by a relationship target (or a derived key) and look up buckets in O(1) instead of
|
|
424
|
+
scanning. Moving between buckets is a retarget (data change), not a component swap.
|
|
425
|
+
|
|
426
|
+
### Initialize via `update`-on-entry
|
|
427
|
+
|
|
428
|
+
Because `update(C)` fires on entry, the "apply current value when the entity appears" step needs
|
|
429
|
+
no separate `enter` — just order the system after whatever produces the entity/companion.
|
|
430
|
+
|
|
431
|
+
---
|
|
432
|
+
|
|
433
|
+
## 8. Anti-patterns (keep ECS semantics)
|
|
434
|
+
|
|
435
|
+
| Smell | Do instead |
|
|
436
|
+
| ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
437
|
+
| A side `Map<eid, resource>` | Store the resource in a **companion component**; let queries drive its lifecycle; release in `onRemove`. |
|
|
438
|
+
| An imperative runtime object with `start()/stop()/track/enable` | A **module** + the world's own lifecycle (`clearAllEntities()`, closing the world). Systems live on the world; they don't need a wrapper to turn on. |
|
|
439
|
+
| Builder/factory helpers that _create entities_ (`createArc(world, …)`) | Let callers `world.entity().add(Networked).set(Arc, …).set(FillStyle, …)`. Components are small and optional; the caller adds exactly what it wants. |
|
|
440
|
+
| A god component with many optional fields | Split into small single-purpose components; absence encodes "not applicable." |
|
|
441
|
+
| Duplicated `enter` + `update` doing the same thing | One `update(C)` — it fires on entry too. |
|
|
442
|
+
| One system trying to act in two phases | Split into two systems, one per phase. |
|
|
443
|
+
| Custom phases for app/render logic | Use the standard taxonomy; only engine modules insert phases. |
|
|
444
|
+
| `.each` scanning every entity to detect change | Reactive `.update(C)` — fires only on change. |
|
|
445
|
+
| Networking a relationship | You can't (target is an `Entity`); replicate a plain id/tag instead, or keep the relationship server-local. |
|
|
446
|
+
| A `registerFooSystem(world)` wrapper per system | Declare the system inline in the module's `init`. |
|
|
447
|
+
|
|
448
|
+
---
|
|
449
|
+
|
|
450
|
+
## 9. Pitfalls / gotchas checklist
|
|
451
|
+
|
|
452
|
+
- **A system is in exactly one phase.** Plan splits accordingly.
|
|
453
|
+
- **Structural changes are deferred and flushed _after_ each system** — visible to later systems
|
|
454
|
+
this tick, not to the current system mid-iteration.
|
|
455
|
+
- **`update(C)` fires on entry** for already-present watched components. Lean on it; don't
|
|
456
|
+
duplicate in `enter`.
|
|
457
|
+
- **Membership is fixed by `.with(...)` / `.without(...)` / `.update(C)`;** `update(C)` watches `C`
|
|
458
|
+
and requires it.
|
|
459
|
+
- **`exit` reads a snapshot** of the injected components captured at exit (they're being removed).
|
|
460
|
+
- **Within a phase, order = registration order;** across phases, the spine guarantees order. Don't
|
|
461
|
+
use registration order for cross-module correctness — use phases.
|
|
462
|
+
- **Networked component order = wire protocol.** Reordering breaks the protocol.
|
|
463
|
+
- **`@type`/`@wireType` must survive esbuild's standard decorators** when components load from
|
|
464
|
+
source (tsx/Vite). Otherwise fields silently don't register.
|
|
465
|
+
- **`Relationship.target` is an `Entity`** — not wire-encodable.
|
|
466
|
+
- **`world.module(M)` runs `init` once.** Re-registering with a different config throws.
|
|
467
|
+
|
|
468
|
+
---
|
|
469
|
+
|
|
470
|
+
## 10. Case study: the Phaser render layer
|
|
471
|
+
|
|
472
|
+
A compact tour that uses everything above. Goal: the server owns authoritative entities with
|
|
473
|
+
small **render components**; the client turns them into Phaser `GameObject`s with **no per-entity
|
|
474
|
+
render code**.
|
|
475
|
+
|
|
476
|
+
- **Data (`@vworlds/vecs-phaser`):** small, single-purpose, networked components — `Position`,
|
|
477
|
+
`Rotation`, `Scale`, `Alpha`, `Depth`, `FillStyle`, `StrokeStyle`, and one-of-N renderables
|
|
478
|
+
(`Arc`, `Rectangle`, `Text`, …). `networkComponents` order is the protocol.
|
|
479
|
+
- **Server (`PhaserServerModule`, zero-config):** owns exclusivity
|
|
480
|
+
(`setExclusiveComponents(...renderables)`); two **reactive** pose-sync systems in `PRE_STORE`
|
|
481
|
+
copy physics `Position`/`Rotation` → render pose only when physics moved (`.update`, not
|
|
482
|
+
`.each`), and only for entities that already have a render pose (no creation — pure ECS). No
|
|
483
|
+
builders: game code creates entities and adds the components it wants.
|
|
484
|
+
- **Client (`PhaserRenderModule` umbrella → per-shape modules):** binds each entity's renderable
|
|
485
|
+
to a Phaser object stored in the **`VGameObject` companion component** (`onRemove → destroy`).
|
|
486
|
+
Per shape: a **creation** system (`.with(Shape).without(VGameObject).enter` → `scene.add.*` +
|
|
487
|
+
`set(VGameObject)`) in `ON_STORE`; a **geometry** system (`.with(Shape, VGameObject).update`)
|
|
488
|
+
in `ON_STORE`; a **teardown** system (`.exit → remove VGameObject`) in `PRE_STORE`. Shared
|
|
489
|
+
transform/style **sync** systems (`.with(Component, VGameObject).update(Component)`) apply
|
|
490
|
+
position/rotation/scale/alpha/depth/fill/stroke — initialization comes for free because
|
|
491
|
+
`update` fires on entry, and the umbrella loads shape modules _before_ the sync module so
|
|
492
|
+
creation precedes sync within `ON_STORE`.
|
|
493
|
+
- **Lifecycle that falls out for free:** a renderable **type swap** is handled by exclusivity +
|
|
494
|
+
teardown(`PRE_STORE`)-before-create(`ON_STORE`); a **baseline reset / disconnect** is just
|
|
495
|
+
`clearAllEntities()` → entities destroyed → `VGameObject` removed → `onRemove` destroys the
|
|
496
|
+
Phaser objects. No maps, no `start/stop`, no reset handler.
|
|
497
|
+
|
|
498
|
+
The whole thing is "server says _what exists, where, and what it looks like_; the client owns the
|
|
499
|
+
Phaser lifecycle" — expressed entirely as small components + phased reactive systems + modules.
|
|
500
|
+
|
|
501
|
+
---
|
|
502
|
+
|
|
503
|
+
_Keep components small and dumb, let systems and phases express behavior and order, store
|
|
504
|
+
resources in components, react to change instead of scanning, and package it as modules. When a
|
|
505
|
+
design starts growing maps, factories, and start/stop wrappers, that's the signal you've stepped
|
|
506
|
+
outside ECS — step back in._
|