koota 0.6.3 → 0.6.5-canary.0b1a1ea

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  [![Discord Shield](https://img.shields.io/discord/740090768164651008?style=flat&colorA=000000&colorB=000000&label=&logo=discord&logoColor=ffffff)](https://discord.gg/poimandres)
2
2
 
3
- <img src="logo.svg" alt="Koota" width="100%" />
3
+ <img src="docs/logo.svg" alt="Koota" width="100%" />
4
4
 
5
5
  Koota is an ECS-based state management library optimized for real-time apps, games, and XR experiences. Use as much or as little as you need.
6
6
 
@@ -8,7 +8,11 @@ Koota is an ECS-based state management library optimized for real-time apps, gam
8
8
  npm i koota
9
9
  ```
10
10
 
11
- 👉 [Try the starter template](https://github.com/Ctrlmonster/r3f-koota-starter)
11
+ The official AI agent skill can be installed from this repo. [Read more about skills here](https://agentskills.io/home).
12
+
13
+ ```bash
14
+ npx skills add pmndrs/koota
15
+ ```
12
16
 
13
17
  ### First, define traits
14
18
 
@@ -48,11 +52,18 @@ const goblin = world.spawn(Position({ x: 10, y: 10 }), Velocity, Mesh)
48
52
  Queries fetch entities sharing traits (archetypes). Use them to batch update entities efficiently.
49
53
 
50
54
  ```js
55
+ // updateEach mutates and writes back to stores
51
56
  // Run this in a loop
52
57
  world.query(Position, Velocity).updateEach(([position, velocity]) => {
53
58
  position.x += velocity.x * delta
54
59
  position.y += velocity.y * delta
55
60
  })
61
+
62
+ // For read-only operations with no mutation, use readEach
63
+ const data = []
64
+ world.query(Position, Velocity).readEach(([position, velocity]) => {
65
+ data.push({ x: position.x, y: position.y })
66
+ })
56
67
  ```
57
68
 
58
69
  ### Use in your React components
@@ -91,10 +102,10 @@ function RocketView({ entity }) {
91
102
  Use actions to safely modify Koota from inside of React in either effects or events.
92
103
 
93
104
  ```js
94
- import { defineActions } from 'koota'
105
+ import { createActions } from 'koota'
95
106
  import { useActions } from 'koota/react'
96
107
 
97
- const actions = defineActions((world) => ({
108
+ const actions = createActions((world) => ({
98
109
  spawnShip: (position) => world.spawn(Position(position), Velocity),
99
110
  destroyAllShips: () => {
100
111
  world.query(Position, Velocity).forEach((entity) => {
@@ -136,7 +147,7 @@ useEffect(() => {
136
147
 
137
148
  ### Relations
138
149
 
139
- Koota supports relations between entities using the `relation` function. Relations allow you to create connections between entities and query them efficiently.
150
+ Koota supports relations between entities using the `relation` function. Relations allow you to build graphs by creating connections between entities with efficient queries.
140
151
 
141
152
  ```js
142
153
  const ChildOf = relation()
@@ -147,6 +158,24 @@ const child = world.spawn(ChildOf(parent))
147
158
  const entity = world.queryFirst(ChildOf(parent)) // Returns child
148
159
  ```
149
160
 
161
+ A relation is typically owned by the entity that needs to express it. The **source** is the entity that has the relation added, and the **target** is the entity it points to.
162
+
163
+ ```mermaid
164
+ flowchart BT
165
+ subgraph Parent
166
+ end
167
+ subgraph childA["Child"]
168
+ COA["ChildOf(Parent)"]
169
+ end
170
+ subgraph childB["Child"]
171
+ COB["ChildOf(Parent)"]
172
+ end
173
+ COA --> Parent
174
+ COB --> Parent
175
+ ```
176
+
177
+ In `child.add(ChildOf(parent))`, child is the source and parent is the target. This design means the parent doesn't need to know about its children, instead children care about their parent, optimzing batch queries.
178
+
150
179
  #### With data
151
180
 
152
181
  Relations can contain data like any trait.
@@ -167,12 +196,14 @@ inventory.set(Contains(gold), { amount: 20 })
167
196
  const data = inventory.get(Contains(gold)) // { amount: 20 }
168
197
  ```
169
198
 
170
- #### Auto remove target
199
+ #### Auto destroy
200
+
201
+ Relations can automatically destroy related entities when their counterpart is destroyed using the `autoDestroy` option.
171
202
 
172
- Relations can automatically remove target entities and their descendants.
203
+ **Destroy orphans.** When a target is destroyed, destroy all sources pointing to it. This is commonly used for hierarchies when you want to clean up any detached graphs. It can be enabled with the `'orphan'` or `'source'` option.
173
204
 
174
205
  ```js
175
- const ChildOf = relation({ autoRemoveTarget: true })
206
+ const ChildOf = relation({ autoDestroy: 'orphan' }) // Or 'source'
176
207
 
177
208
  const parent = world.spawn()
178
209
  const child = world.spawn(ChildOf(parent))
@@ -183,6 +214,21 @@ parent.destroy()
183
214
  world.has(child) // False, the child and grandchild are destroyed too
184
215
  ```
185
216
 
217
+ **Destroy targets.** When a source is destroyed, destroy all its targets.
218
+
219
+ ```js
220
+ const Contains = relation({ autoDestroy: 'target' })
221
+
222
+ const container = world.spawn()
223
+ const itemA = world.spawn()
224
+ const itemB = world.spawn()
225
+
226
+ container.add(Contains(itemA), Contains(itemB))
227
+ container.destroy()
228
+
229
+ world.has(itemA) // False, items are destroyed with container
230
+ ```
231
+
186
232
  #### Exclusive relations
187
233
 
188
234
  Exclusive relations ensure each entity can only have one target.
@@ -206,7 +252,27 @@ hero.has(Targeting(goblin)) // True
206
252
  > ⚠️ **Experimental**<br>
207
253
  > This API is experimental and may change in future versions. Please provide feedback on GitHub or Discord.
208
254
 
209
- Ordered relations maintain a list of related entities with bidirectional sync.
255
+ Ordered relations maintain a list of related entities with
256
+ bidirectional sync.
257
+
258
+ A query like `world.query(ChildOf(parent))` returns a flat list of children without any ordering. If you need an ordered list, you'd have to store an order field and sort every time you query.
259
+
260
+ An ordered relation solves this by caching the order on the target. It's a trait added to the parent that maintains a view of all entities targeting it.
261
+
262
+ ```mermaid
263
+ flowchart BT
264
+ subgraph Parent
265
+ OC["OrderedChildren → [Child B, Child A]"]
266
+ end
267
+ subgraph childA["Child A"]
268
+ COA["ChildOf(Parent)"]
269
+ end
270
+ subgraph childB["Child B"]
271
+ COB["ChildOf(Parent)"]
272
+ end
273
+ COA --> Parent
274
+ COB --> Parent
275
+ ```
210
276
 
211
277
  ```js
212
278
  import { relation, ordered } from 'koota'
@@ -224,10 +290,10 @@ children.splice(0, 1) // removes ChildOf(parent) from child1
224
290
  child2.add(ChildOf(parent)) // child2 automatically added to list
225
291
  ```
226
292
 
227
- Ordered relations support array methods like `push()`, `pop()`, `shift()`, `unshift()`, and `splice()`, plus special methods `moveTo()` and `insert()` for precise control. Changes to the list automatically sync with relations, and vice versa.
293
+ Ordered relations support array methods like `push()`, `pop()`, `shift()`, `unshift()`, and `splice()`, plus special methods `moveTo()` and `insert()` for precise control. Changes to the list automatically sync with relations, and vice versa, as well as emit change events.
228
294
 
229
295
  > ⚠️ **Performance note**<br>
230
- > Ordered relations are more expensive to update than regular relations but enable faster traversal when order matters. Use them only when entity order is essential.
296
+ > Ordered relations requires additional bookkeeping where the cost of ordering is paid during structural changes (add, remove, move) instead of at query time. Use ordered relations only when entity order is essential or when hierarchical search (looping over children) is necessary.
231
297
 
232
298
  #### Querying relations
233
299
 
@@ -279,9 +345,6 @@ player.has(banana) // false
279
345
 
280
346
  Relations work with tracking modifiers to detect when entities gain, lose, or update relations. Changes can only be tracked on relations that have a store.
281
347
 
282
- > 👉 **Note**<br>
283
- > You can currently only track changes to all relations of a given type, such as `ChildOf`, but not specific relation pairs, such as `ChildOf(parent)`.
284
-
285
348
  ```js
286
349
  import { createAdded, createRemoved, createChanged } from 'koota'
287
350
 
@@ -301,18 +364,19 @@ const orphaned = world.query(Removed(ChildOf))
301
364
  const updated = world.query(Changed(ChildOf))
302
365
  ```
303
366
 
304
- Combine with relation filters to track changes for specific targets.
367
+ > 👉 **Note**<br>
368
+ > Tracking modifiers do not accept pairs directly such as `Changed(ChildOf(parent))`. Instead, pass the base relation to the modifier and add the pair as a separate query parameter to filter by target.
305
369
 
306
370
  ```js
307
371
  const parent = world.spawn()
308
372
 
309
- // Track changes only for entities related to parent
373
+ // Filter changed entities by a specific target
310
374
  const changedChildren = world.query(Changed(ChildOf), ChildOf(parent))
311
375
  ```
312
376
 
313
377
  #### Relation events
314
378
 
315
- Relations emit events per **relation pair**. This makes it easy to know exactly which target was involved.
379
+ Relations emit events per **pair**. This makes it easy to know exactly which target was involved.
316
380
 
317
381
  - `onAdd(Relation, (entity, target) => {})` triggers when `entity.add(Relation(target))` is called.
318
382
  - `onRemove(Relation, (entity, target) => {})` triggers when `entity.remove(Relation(target))` is called.
@@ -333,6 +397,16 @@ child.set(ChildOf(parent), { priority: 1 }) // onChange(child, parent)
333
397
  child.remove(ChildOf(parent)) // onRemove(child, parent)
334
398
  ```
335
399
 
400
+ Hooks also accept **relation pairs** for target-specific filtering. `ChildOf(parent)` only fires for that specific target, while `ChildOf('*')` fires for any target (equivalent to passing the relation itself).
401
+
402
+ ```js
403
+ // Only fires when a ChildOf relation to this specific parent is added
404
+ world.onAdd(ChildOf(parent), (entity, target) => {})
405
+
406
+ // Fires for any ChildOf addition, the same as passing the ChildOf trait
407
+ world.onAdd(ChildOf('*'), (entity, target) => {})
408
+ ```
409
+
336
410
  ### Query modifiers
337
411
 
338
412
  Modifiers are used to filter query results enabling powerful patterns. All modifiers can be mixed together.
@@ -361,6 +435,8 @@ const movingOrVisible = world.query(Or(Velocity, Renderable))
361
435
 
362
436
  The `Added` modifier tracks all entities that have added the specified traits or relations since the last time the query was run. A new instance of the modifier must be created for tracking to be unique.
363
437
 
438
+ When multiple traits are passed to `Added` it uses logical `AND`. Only entities where **all** specified traits have been added will be returned.
439
+
364
440
  ```js
365
441
  import { createAdded } from 'koota'
366
442
 
@@ -372,6 +448,12 @@ const newPositions = world.query(Added(Position))
372
448
  // Track entities that added a ChildOf relation
373
449
  const newChildren = world.query(Added(ChildOf))
374
450
 
451
+ // Track entities where BOTH Position AND Velocity were added
452
+ const fullyAdded = world.query(Added(Position, Velocity))
453
+
454
+ // Track entities where EITHER Position OR Velocity was added
455
+ const eitherAdded = world.query(Or(Added(Position), Added(Velocity)))
456
+
375
457
  // After running the query, the Added modifier is reset
376
458
  ```
377
459
 
@@ -379,6 +461,8 @@ const newChildren = world.query(Added(ChildOf))
379
461
 
380
462
  The `Removed` modifier tracks all entities that have removed the specified traits or relations since the last time the query was run. This includes entities that have been destroyed. A new instance of the modifier must be created for tracking to be unique.
381
463
 
464
+ When multiple traits are passed to `Removed` it uses logical `AND`. Only entities where **all** specified traits have been removed will be returned.
465
+
382
466
  ```js
383
467
  import { createRemoved } from 'koota'
384
468
 
@@ -390,6 +474,12 @@ const stoppedEntities = world.query(Removed(Velocity))
390
474
  // Track entities that removed a ChildOf relation
391
475
  const orphaned = world.query(Removed(ChildOf))
392
476
 
477
+ // Track entities where BOTH Position AND Velocity were removed
478
+ const fullyRemoved = world.query(Removed(Position, Velocity))
479
+
480
+ // Track entities where EITHER Position OR Velocity was removed
481
+ const eitherRemoved = world.query(Or(Removed(Position), Removed(Velocity)))
482
+
393
483
  // After running the query, the Removed modifier is reset
394
484
  ```
395
485
 
@@ -397,6 +487,8 @@ const orphaned = world.query(Removed(ChildOf))
397
487
 
398
488
  The `Changed` modifier tracks all entities that have had the specified traits or relation stores change since the last time the query was run. A new instance of the modifier must be created for tracking to be unique.
399
489
 
490
+ When multiple traits are passed to `Changed` it uses logical `AND`. Only entities where **all** specified traits have changed will be returned.
491
+
400
492
  ```js
401
493
  import { createChanged } from 'koota'
402
494
 
@@ -408,6 +500,12 @@ const movedEntities = world.query(Changed(Position))
408
500
  // Track entities whose ChildOf relation data has changed
409
501
  const updatedChildren = world.query(Changed(ChildOf))
410
502
 
503
+ // Track entities where BOTH Position AND Velocity have changed
504
+ const fullyUpdated = world.query(Changed(Position, Velocity))
505
+
506
+ // Track entities where EITHER Position OR Velocity has changed
507
+ const eitherChanged = world.query(Or(Changed(Position), Changed(Velocity)))
508
+
411
509
  // After running the query, the Changed modifier is reset
412
510
  ```
413
511
 
@@ -622,6 +720,10 @@ const unsub = world.onAdd(Position, (entity) => {})
622
720
  const unsub = world.onRemove(Position, (entity) => {})
623
721
  const unsub = world.onChange(Position, (entity) => {})
624
722
 
723
+ // Hooks also accept relation pairs for target-specific filtering
724
+ const unsub = world.onAdd(ChildOf(parent), (entity, target) => {})
725
+ const unsub = world.onAdd(ChildOf('*'), (entity, target) => {})
726
+
625
727
  // Subscribe to add or remove query events
626
728
  // This triggers whenever a query is updated
627
729
  // Return unsub function
@@ -876,7 +978,7 @@ While this is not likely to be a bottleneck in your code compared to the actual
876
978
 
877
979
  ```js
878
980
  // The internal query is created immediately before it is invoked
879
- const movementQuery = defineQuery(Position, Velocity)
981
+ const movementQuery = createQuery(Position, Velocity)
880
982
 
881
983
  // The query ref is used for fast array-based lookup
882
984
  function updateMovement(world) {
@@ -977,10 +1079,15 @@ function App() {
977
1079
 
978
1080
  Observes an entity, or world, for a given trait and reactively updates when it is added, removed or changes value. The returned trait snapshot maybe `undefined` if the trait is no longer on the target. This can be used to conditionally render.
979
1081
 
1082
+ Also accepts relation pairs like `ChildOf(parent)` to observe a specific relation's store data.
1083
+
980
1084
  ```js
981
1085
  // Get the position trait from an entity and reactively updates when it changes
982
1086
  const position = useTrait(entity, Position)
983
1087
 
1088
+ // Observe a specific relation pair's store data
1089
+ const childData = useTrait(entity, ChildOf(parent))
1090
+
984
1091
  // If position is removed from entity then it will be undefined
985
1092
  if (!position) return null
986
1093
 
@@ -1035,6 +1142,8 @@ function ActiveIndicator({ entity }) {
1035
1142
 
1036
1143
  Observes an entity, or world, for any trait and reactively updates when it is added or removed. Returns `true` when the trait is present or `false` when absent. Unlike `useTrait`, this only tracks presence and not the trait's value.
1037
1144
 
1145
+ Also accepts relation pairs like `ChildOf(parent)` or `ChildOf('*')` to track the presence of specific or any relation targets.
1146
+
1038
1147
  ```js
1039
1148
  const Health = trait({ amount: 100 })
1040
1149
 
@@ -1042,6 +1151,12 @@ function HealthIndicator({ entity }) {
1042
1151
  // Returns true if the entity has the trait, false otherwise
1043
1152
  const hasHealth = useHas(entity, Health)
1044
1153
 
1154
+ // Track a specific relation pair
1155
+ const isChildOfParent = useHas(entity, ChildOf(parent))
1156
+
1157
+ // Track any ChildOf relation
1158
+ const hasAnyParent = useHas(entity, ChildOf('*'))
1159
+
1045
1160
  if (!hasHealth) return null
1046
1161
 
1047
1162
  return <div>❤️ Has Health</div>
@@ -1050,7 +1165,7 @@ function HealthIndicator({ entity }) {
1050
1165
 
1051
1166
  ### `useTraitEffect`
1052
1167
 
1053
- Subscribes a callback to a trait on an entity. This callback fires as an effect whenever it is added, removed or changes value without rerendering.
1168
+ Subscribes a callback to a trait on an entity. This callback fires as an effect whenever it is added, removed or changes value without rerendering. Also accepts relation pairs.
1054
1169
 
1055
1170
  ```js
1056
1171
  // Subscribe to position changes on an entity and update a ref without causing a rerender
@@ -1059,6 +1174,11 @@ useTraitEffect(entity, Position, (position) => {
1059
1174
  meshRef.current.position.copy(position)
1060
1175
  })
1061
1176
 
1177
+ // Subscribe to a specific relation pair
1178
+ useTraitEffect(entity, ChildOf(parent), (data) => {
1179
+ console.log('ChildOf data changed:', data)
1180
+ })
1181
+
1062
1182
  // Subscribe to world-level traits
1063
1183
  useTraitEffect(world, GameState, (state) => {
1064
1184
  if (!state) return
@@ -1106,11 +1226,11 @@ function InventoryDisplay({ entity }) {
1106
1226
 
1107
1227
  ### `useActions`
1108
1228
 
1109
- Returns actions bound to the world that is in context. Use actions created by `defineActions`.
1229
+ Returns actions bound to the world that is in context. Use actions created by `createActions`.
1110
1230
 
1111
1231
  ```js
1112
1232
  // Create actions
1113
- const actions = defineActions((world) => ({
1233
+ const actions = createActions((world) => ({
1114
1234
  spawnPlayer: () => world.spawn(IsPlayer).
1115
1235
  destroyAllPlayers: () => {
1116
1236
  world.query(IsPlayer).forEach((player) => {