bitECS Documentation

repository·main·Indexed 23 days ago

https://github.com/natethegreatt/bitecs

A minimal, high-performance, data-oriented Entity Component System (ECS) library for TypeScript (v0.4.0). It supports both Structure of Arrays (SoA) and Array of Structures (AoS) patterns, featuring a declarative API, relational entity modeling, and built-in serialization strategies (Snapshots, Observer, and SoA) for multiplayer synchronization. Key capabilities include entity versioning, hierarchical depth tracking, complex querying with logical operators, and a lightweight footprint of approximately 5kb minzipped.

Tokens
22.1K
Snippets
39
Records
119
Agent score
80%

What's inside bitECS

  1. Overview of bitecs serialization types

    main

    The bitecs/serialization module provides decoupled APIs for handling different data transfer needs. Choose a serializer based on your data structure:

    • SoA (Structure of Arrays): Best for raw component data transfer (e.g., network replication of ECS data).
    • AoS (Array of Structures): Best for object-like storage patterns where each entity index holds an object or direct value.
    • Observer: Used for tracking add/remove entity or component events.
    • Snapshot: Used for complete state capture and restoration.
  2. Get started with bitECS

    main

    bitECS is a minimal, data-oriented Entity Component System (ECS) library for TypeScript. It supports both Structure of Arrays (SoA) and Array of Structures (AoS) patterns for component storage, making it highly flexible for different performance and modeling needs.

    Key features include:

    • Simple, declarative API
    • Lightweight (~5kb minzipped)
    • Powerful querying
    • Relational entity modeling
    • Thread-friendly (suitable for multithreading)
    • Built-in serialization
    import {
      createWorld,
      query,
      addEntity,
      removeEntity,
      addComponent,
    } from 'bitecs'
    
    // 1. Define components (SoA or AoS)
    const Health = [] as number[]
    
    const world = createWorld({
      components: {
        // SoA: Structure of Arrays
        Position: { x: [], y: [] },
        Velocity: { x: new Float32Array(1e5), y: new Float32Array(1e5) },
        // AoS: Array of Structures
        Player: [] as { level: number; experience: number; name: string }[]
      },
      time: {
        delta: 0, 
        elapsed: 0, 
        then: performance.now()
      }
    })
    
    const { Position, Velocity, Player } = world.components
    
    // 2. Create and configure an entity
    const eid = addEntity(world)
    addComponent(world, eid, Position)
    addComponent(world, eid, Velocity)
    addComponent(world, eid, Player)
    addComponent(world, eid, Health)
    
    // 3. Access data
    Position.x[eid] = 0
    Player[eid] = { level: 1, experience: 0, name: "Hero" }
    
    // 4. Define and run systems using query()
    const movementSystem = (world) => {
      const { Position, Velocity } = world.components
      for (const eid of query(world, [Position, Velocity])) {
        Position.x[eid] += Velocity.x[eid] * world.time.delta
        Position.y[eid] += Velocity.y[eid] * world.time.delta
      }
    }
    
    const update = (world) => {
      movementSystem(world)
      // ... other systems
    }
    
    // 5. Run the loop
    requestAnimationFrame(function animate() {
      update(world)
      requestAnimationFrame(animate)
    })
  3. Enable Entity ID Versioning

    main

    To prevent issues with recycled IDs (where an old system might accidentally reference a new entity that reused an old ID), you can enable versioning via createEntityIndex(withVersioning(bits)).

    Each ID carries a version number that increments upon recycling. This allows you to distinguish between different lifetimes of the same ID.

    Configuration:

    • Pass withVersioning(n) where n is the number of bits.
    • Default is 12 bits (4096 recycles).
    • 8 bits: 16M entities / 256 recycles
    • 10 bits: 4M entities / 1K recycles
    • 12 bits: 1M entities / 4K recycles
    • 14 bits: 262K entities / 16K recycles
    • 16 bits: 65K entities / 65K recycles

    ⚠️ Caution with TypedArrays: Versioning changes the entity ID by large amounts. If using the eid as an index into a sparse TypedArray, ensure the array is large enough to prevent out-of-bounds access, as JavaScript does not throw on out-of-bounds index access.

    const entityIndex = createEntityIndex(withVersioning(8))
    const world = createWorld(entityIndex)
    
    const eid1 = addEntityId(entityIndex)
    const eid2 = addEntityId(entityIndex)
    removeEntityId(entityIndex, eid1)
    const eid3 = addEntityId(entityIndex)
    
    assert(eid1 !== eid3) // With versioning, eid1 and eid3 will not be the same
  4. Configure SAB-backed components for shared memory

    main

    For efficient multithreading, component stores should be structured as SoA (Structure of Arrays) using TypedArrays backed by SharedArrayBuffer. This allows worker threads to read and write directly to component data that is shared with the main thread.

    const MAX_ENTS = 1e6
    const world = createWorld({
        // SAB-backed components
        components: {
            Position: {
                x: new Float32Array(new SharedArrayBuffer(MAX_ENTS * Float32Array.BYTES_PER_ELEMENT)),
                y: new Float32Array(new SharedArrayBuffer(MAX_ENTS * Float32Array.BYTES_PER_ELEMENT))
            }
        }
    })
  5. Map entity IDs during deserialization

    main

    When deserializing, you can pass an optional Map<number, number> to the deserializer function to map entity IDs from the source data to different IDs in the target world. This is useful for network replication or loading saved games where IDs must be regenerated.

    // Map entity 1 to 10
    const idMap = new Map([[1, 10]])
    
    // entity id 1 inside of the packet will have its data written to entity id 10
    deserialize(buffer, idMap)
  6. Use Snapshot Serialization for full state capture

    main

    Snapshot serialization captures the complete state of entities and components at a specific point in time. This is ideal for full state synchronization, save game systems, or debugging/replay functionality.

    Unlike the Observer serializer, the Snapshot serializer does not track changes over time but rather takes a 'picture' of the current world state for the specified components.

    import { createWorld, addEntity, addComponent, removeEntity, hasComponent } from 'bitecs'
    import { createSnapshotSerializer, createSnapshotDeserializer, f32, u8 } from 'bitecs/serialization'
    
    // Example using Snapshot serializer for full state capture
    const world = createWorld()
    const eid = addEntity(world)
    
    // Define components with tagged SoA data storage
    const Position = { x: f32([]), y: f32([]) }
    const Health = u8([])
    
    // Create serializers
    const serialize = createSnapshotSerializer(world, [Position, Health])
    const deserialize = createSnapshotDeserializer(world, [Position, Health])
    
    // Set up initial state
    addComponent(world, eid, Position)
    Position.x[eid] = 10
    Position.y[eid] = 20
    
    addComponent(world, eid, Health)
    Health[eid] = 100
    
    // Serialize full state
    const buffer = serialize()
    
    // Clear world state
    removeEntity(world, eid)
    Position.x[eid] = 0
    Position.y[eid] = 0
    Health[eid] = 0
    
    // Deserialize state back
    deserialize(buffer)
    
    // Verify state was restored
    console.assert(hasComponent(world, eid, Position))
    console.assert(hasComponent(world, eid, Health))
    console.assert(Position.x[eid] === 10)
    console.assert(Position.y[eid] === 20)
    console.assert(Health[eid] === 100)
  7. Define and use relationships between entities

    main

    Relationships allow entities to be linked via a Relation. Relations can store data and can be queried like components.

    Defining a Relation

    Relations can be defined using an options object or composables like withStore.

    // With data properties
    const Contains = createRelation(withStore(() => ({ amount: [] as number[] })))
    // or
    const Contains = createRelation({ store: () => ({ amount: [] as number[] }) })

    Adding and Querying Relationships

    Use addComponent with the relation and the target entity to establish a link.

    const inventory = addEntity(world)
    const gold = addEntity(world)
    
    // Link inventory to gold
    addComponent(world, inventory, Contains(gold))
    
    // Access relation data
    Contains(gold).amount[inventory] = 5
    
    // Query for entities that have this relationship
    const targets = query(world, [Contains(gold)])
  8. Handle Enter/Exit Logic with Observers and Queues

    main

    The enterQuery and exitQuery functions from 0.3.x are replaced by a pattern using Observers and Queues attached to the world object.

    1. Create arrays on your world object to act as queues (e.g., world.enteredMovers = []).
    2. Use observe with onAdd and onRemove to push entity IDs into these queues.
    3. In your systems, consume the queues using .splice(0) to retrieve and clear the accumulated entities.
    // 1. Setup queues on the world
    world.enteredMovers = []
    world.exitedMovers = []
    
    // 2. Set up observers once
    observe(world, onAdd(Position, Velocity), (eid) => world.enteredMovers.push(eid))
    observe(world, onRemove(Position, Velocity), (eid) => world.exitedMovers.push(eid))
    
    // 3. In your system, consume and clear the queues
    const entered = world.enteredMovers.splice(0)
    const exited = world.exitedMovers.splice(0)
    
    for (const eid of entered) {
      console.log(`Entity ${eid} started moving`)
    }
  9. Use Prefabs to create reusable entity templates

    main

    Prefabs in bitECS allow you to define reusable templates for entities. They can include components and relationships, making it easy to instantiate complex entities with predefined configurations. When an entity is instantiated from a prefab, it inherits all the components and their initial values.

    Note that prefabs themselves do not appear in queries; only entities instantiated from them are queryable.

    const Gold = addPrefab(world)
  10. Use Observer Serialization for tracking entity and component changes

    main

    The Observer serializer tracks the addition and removal of entities and components. It is designed to work in tandem with an SoA (Structure of Arrays) serializer for efficient network synchronization: the Observer serializer handles the presence/absence of entities and components, while the SoA serializer handles the actual component data.

    To use it, you must provide a networkTag component. Only entities possessing this tag will be tracked and included in the serialization process. The components array specifies which components' additions and removals should be monitored.

    import { addComponent, removeComponent, hasComponent, addEntity, createWorld } from 'bitecs'
    import { createObserverSerializer, createObserverDeserializer } from 'bitecs/serialization'
    
    const world = createWorld()
    const eid = addEntity(world)
    
    const Position = { x: [] as number[], y: [] as number[] }
    const Health = [] as number[]
    const Networked = {}
    
    // Create serializers
    const serializer = createObserverSerializer(world, Networked, [Position, Health])
    const deserializer = createObserverDeserializer(world, Networked, [Position, Health])
    
    // Add some components
    addComponent(world, eid, Networked)
    addComponent(world, eid, Position)
    addComponent(world, eid, Health)
    
    // Serialize changes
    const buffer = serializer()
    
    // Reset the state
    removeComponent(world, eid, Position)
    removeComponent(world, eid, Health)
    
    // Deserialize changes back
    deserializer(buffer)
    
    // Verify components were restored
    console.assert(hasComponent(world, eid, Position))
    console.assert(hasComponent(world, eid, Health))
  11. How entity versioning works

    main

    Versioning allows the system to recycle entity IDs while ensuring that old references to a recycled ID do not accidentally point to a new entity. When versioning is enabled, the entity ID contains both the raw index and a version number.

    • withVersioning(versionBits): Configures how many bits are reserved for the version (defaults to 8).
    • incrementVersion(index, id): Returns a new ID with the version incremented, useful during recycling.
    • getId(index, id): Returns the base entity ID without the version bits.
    • getVersion(index, id): Returns only the version component of the ID.
  12. Implement custom getters and setters with onSet and onGet

    main

    The onSet and onGet hooks allow you to implement custom data handling for components. These hooks operate at the component level. When using set with addComponent, the onSet hook is triggered with the entire data object.

    Common use cases include:

    • Data Persistence: Defining how data is stored in custom structures (e.g., Array of Structures or Structure of Arrays).
    • Validation/Computed Values: Modifying data as it is set (e.g., clamping a health value).
    • Cross-cutting concerns: Implementing logging, network synchronization, or validation without modifying core logic.
    // Example: Custom AoS data storage
    observe(world, onSet(Position), (eid, params) => { Position[eid] = params })
    observe(world, onGet(Position), (eid) => Position[eid])
    addComponent(world, eid, set(Position, { x: 10, y: 20 }))