koota

repository·main·Indexed 20 days ago

https://github.com/pmndrs/koota

An ECS (Entity Component System) based state management library designed for high-performance, real-time applications like games and XR experiences. Koota provides a reactive bridge to React via a WorldProvider and specialized hooks (useQuery, useTrait) to manage complex, high-frequency state updates. It supports schema-based and callback-based traits, graph-based relations with auto-destruction, and efficient batch processing using updateEach and readEach.

Tokens
44.6K
Snippets
137
Records
179
Agent score
71%

What's inside koota

  1. How change detection works with objects and arrays

    main

    Koota performs shallow comparison for change detection, similar to React.

    • Objects and Arrays: A change is only detected if the reference changes (i.e., a new object or array is assigned to the store).
    • Mutations: Mutating an existing array or object (e.g., using .push()) will not be detected by the shallow comparison because the reference remains the same.

    To ensure changes are detected, you can either:

    1. Use Immutability: Replace the existing object/array with a new one (e.g., using the spread operator).
    2. Manual Flagging: Mutate the object for better performance and then manually call entity.changed() to signal that a change has occurred.
    // ❌ Mutation is NOT detected (shallow comparison passes)
    world.query(Inventory).updateEach(([inventory]) => {
      inventory.items.push(item)
    })
    
    // ✅ Immutability IS detected (new array reference)
    world.query(Inventory).updateEach(([inventory]) => {
      inventory.items = [...inventory.items, item]
    })
    
    // ✅ Manual flagging IS detected (mutation + manual signal)
    world.query(Inventory).updateEach(([inventory], entity) => {
      inventory.items.push(item)
      entity.changed()
    })
  2. Understand the lifecycle of adding a trait

    main

    When entity.add(Name) is called, the following lifecycle occurs:

    1. Resolve Trait Instance: The system looks up the per-world TraitInstance. If it's the first time the world has encountered this trait, it is lazily registered, allocating storage and assigning a bitflag and generation ID.
    2. Update Bitmask: The trait's bitflag is OR'd into the entity's bitmask. This bitmask serves as the source of truth for whether an entity has a specific trait.
    3. Mark Dirty: The entity is marked as dirty in all registered tracking masks (Added, Removed, Changed).
    4. Update Queries: The system checks all queries referencing this trait. If the entity's new bitmask matches a query, the entity is added to that query; otherwise, it is removed.
    5. Initialize Values: Trait data is initialized using schema defaults merged with any user-provided parameters via setTrait(world, entity, trait, { ...defaults, ...params }, false). Note that the triggerChanged flag is set to false because this is a structural addition, not a mutation.
    6. Execute Hooks: Any onAdd subscriptions (hooks) are fired. These run after values are initialized, allowing listeners to access the newly set data.
  3. Define traits in Koota

    main

    Traits are the fundamental building blocks of state in Koota. They represent specific slices of data. You can define traits with default values, initial value callbacks, or as empty tags.

    • Basic Trait: Define a schema with default values.
    • Callback Trait: Use a function to provide an initial value (the return value must be an object).
    • Tag Trait: A trait with no data, used for marking entities (e.g., IsActive).
    import { trait } from 'koota'
    
    // Basic trait with default values
    const Position = trait({ x: 0, y: 0 })
    const Velocity = trait({ x: 0, y: 0 })
    
    // Trait with a callback for initial value (Must return an object)
    const Mesh = trait(() => new THREE.Mesh())
    
    // Tag trait (no data)
    const IsActive = trait()
  4. Define traits

    main

    Traits are the building blocks of your state, representing slices of data with specific meanings. You can define basic traits with default values, traits with initial value callbacks (must return an object), or tag traits which contain no data.

    import { trait } from 'koota'
    
    // Basic trait with default values
    const Position = trait({ x: 0, y: 0 })
    const Velocity = trait({ x: 0, y: 0 })
    
    // Trait with a callback for initial value
    // ⚠️ Must be an object
    const Mesh = trait(() => new THREE.Mesh())
    
    // Tag trait (no data)
    const IsActive = trait()
  5. Understand the World abstraction

    main

    The World is the central data store in Koota. While entities appear to have methods, they are actually proxies that operate on the connected World. Each World maintains its own isolated set of entities. In most applications, you only need a single World instance.

    Key characteristics:

    • Isolation: Entities in one world do not overlap with entities in another.
    • Singletons: Worlds can host 'World Traits', which act as singletons for global resources (e.g., a clock or global configuration).
    • Lifecycle: A world can be reset() (clearing data while preserving the ID) or destroy() (nuking the world and releasing its ID).
  6. Implement Systems to query and update entities

    main

    Systems are reactive orchestrators that observe state changes and coordinate work. They are pure TypeScript functions (no React imports) that always take world: World as their first parameter. Systems are typically called from a frameloop or event handlers.

    Common Patterns

    • Query and update each: Use updateEach when you need to mutate the traits being queried.
    • Read and access entity: Use readEach if you need to access the entity object itself (e.g., for calling .set() or .remove()) in addition to the queried data.
    • Singleton traits: Use world.get(TraitName) to access global state like Time or Pointer.
    import type { World } from 'koota'
    import { Position, Velocity, Time } from '../traits'
    
    export function updateMovement(world: World) {
      const { delta } = world.get(Time)!
    
      world.query(Position, Velocity).updateEach(([pos, vel]) => {
        pos.x += vel.x * delta
        pos.y += vel.y * delta
      })
    }
  7. Core Principle: Decompose classes into traits and actions

    main

    When designing your application with Koota, avoid using large, monolithic classes. Instead, follow the core principle of decomposing logic into:

    1. Traits: Pure data structures.
    2. Actions: Behaviors that modify state.

    This separation allows for better composability and aligns with the Entity Component System (ECS) pattern used by Koota.

  8. Manage Entity Lifetime in React

    main

    Koota entities are managed outside of React's state. Because entities can be destroyed by systems at any time, never store entities in useState or useRef, as these will hold stale references to destroyed entities.

    • Startup Component: Use a dedicated component to spawn initial entities and clean them up in a useEffect return function.
    • Querying: To access an entity within a component, use useQueryFirst or pass the entity as a prop from a parent renderer.
    • Effects: If spawning inside a standard component, always return a cleanup function that calls entity.destroy().
    // ✅ Correct: Effect with cleanup
    useEffect(() => {
      const entity = world.spawn(Foo)
      return () => entity.destroy()
    }, [world])
    
    // ✅ Correct: Querying for an entity
    const player = useQueryFirst(IsPlayer)
  9. Distinguish between Actions and Systems

    main

    Choosing between an Action and a System depends on the intent of the logic:

    • Actions: Discrete, synchronous data mutations (create, read, update, destroy). They are reusable from any context (systems, UI handlers, tests). Use them for direct mutations like createEnemy or applyDamage.
    • Systems: Reactive orchestrators. They observe state changes (using createAdded, createChanged, etc.) and coordinate behavior. Use them for "when X happens, do Y" logic, such as reacting to a Poisoned trait change to apply damage over time.
  10. Perform efficient queries

    main

    Queries are used to find and process entities in batches.

    • Inline Queries: world.query(TraitA, TraitB) is convenient but incurs a small hashing overhead each time it is called.
    • Cached Queries: For performance-critical loops, use createQuery(TraitA, TraitB) to create a query reference once, then pass that reference to world.query(queryRef).
    • Excluding Entities: To prevent an entity from appearing in any query, add the built-in IsExcluded tag to it.
    • Querying All: world.query() returns all queryable entities (excluding internal system entities).
    // Fast cached query
    const movementQuery = createQuery(Position, Velocity);
    
    function update(world) {
      world.query(movementQuery).updateEach(([pos, vel]) => {
        // update logic
      });
    }
    
    // Excluding an entity
    entity.add(IsExcluded);
  11. How Entities work in Koota

    main

    An entity is a number encoded with a world, generation, and ID.

    Every entity is unique even if they share the same ID because they will have different generations. This mechanism allows for automatic recycling of IDs without causing reference errors (stale references to a previous entity with the same ID will fail because the generation won't match).

    Important: Because the entity is an encoded number, the raw number itself is not the ID. To retrieve the actual ID, you must use entity.id().

  12. Handle events using Capture or Transition strategies

    main

    Koota supports two primary strategies for handling events:

    1. Capture for frameloop: Store event data in singleton traits (like Pointer) so that systems running in the frameloop can read them. This is ideal for continuous input like mouse movement or keyboard state.
    2. Run on transition: Execute logic immediately when an event fires. This is ideal for discrete events like state machine transitions, network messages, or entity lifecycle events (onAdd, onRemove, onChange).
    // Strategy 1: Capture for frameloop
    useEffect(() => {
      const handler = (e: PointerEvent) => {
        world.set(Pointer, { x: e.clientX, y: e.clientY })
      }
      window.addEventListener('pointermove', handler)
      return () => window.removeEventListener('pointermove', handler)
    }, [world])
    
    // Strategy 2: Run on transition (Entity Lifecycle)
    useEffect(() => {
      return world.onAdd(Position, (entity) => {
        // Runs immediately when entity gains Position
      })
    }, [world])