Becsy ECS

repository·main·Indexed 43 days ago

https://github.com/lastolivegames/becsy

A high-performance Entity Component System (ECS) framework for TypeScript and JavaScript, optimized for multi-threaded development. Version 0.16.0 features efficient memory management via array buffers, strict field types, and flexible component storage strategies (sparse, packed, and compact).

Tokens
28.6K
Snippets
84
Records
104
Agent score
54%

What's inside Becsy

  1. What is Becsy?

    main

    Becsy is an Entity Component System (ECS) framework designed for TypeScript and JavaScript. It aims to provide an ergonomic, high-level API while prioritizing the ability to run applications on multiple threads with minimal modifications.

    Note on current status: Becsy is in 0.x status. While reasonably well-tested, the API is subject to frequent changes, and multi-threading is not yet implemented.

  2. What is the Becsy ECS architecture?

    main

    Becsy implements the Entity Component System (ECS) architectural pattern. In this pattern, computation is decoupled from data through four primary abstractions:

    • Entities: Objects with unique IDs that act as containers for components.
    • Components: Pure data structures that represent facets of an entity (e.g., geometry, physics, hit points).
    • Systems: Logic units that process entities and modify their components.
    • Queries: Filters used by systems to select specific entities based on their component 'shapes' (the set of components they possess).
    • World: The top-level container that manages entities, components, systems, and queries.

    This architecture allows for high performance and modularity by separating data (Components) from logic (Systems).

  3. Understand the System lifecycle hooks

    main

    Systems follow a specific lifecycle that you can hook into by overriding methods. Note that in multi-threaded scenarios, a system may be instantiated multiple times, so avoid side-effects in the constructor.

    1. prepare() (async): The only asynchronous hook. Use this for loading external data or setting up external context. You cannot create or access entities in this phase. Save results to system properties for later use.
    2. initialize() (sync): Use this to seed the world with initial entities or add event listeners. Queries are not yet accessible here.
    3. execute() (sync): The main loop called every frame. Use this to iterate over queries, mutate components, and drive external systems (like renderers). You have access to this.delta (time since last frame) and this.time (current time).
    4. finalize() (sync): Called when the world is explicitly terminated. Use this to clean up external resources or remove listeners.
    @system class MySystem extends System {
      async prepare(): Promise<void> {}
      initialize(): void {}
      execute(): void {}
      finalize(): void {}
    }
  4. How Becsy ECS principles work

    main

    Becsy is an Entity Component System (ECS) framework that uses composition over class hierarchies. The core mental model consists of:

    • Entities: Objects with a unique ID that can have multiple components attached.
    • Components: Data-only facets of an entity (e.g., position, velocity).
    • Systems: Logic that processes entities and modifies their components.
    • Queries: Filters used by systems to select specific entities based on their components.
    • World: The container for all entities, components, systems, and queries.

    Standard Workflow:

    1. Define component types (data shapes).
    2. Create entities and attach components.
    3. Create systems that use queries to find and transform component data.
    4. Execute systems every frame.
  5. Manage multiple Worlds and component limitations

    main

    While you can have multiple worlds, a single component type can only be used in one world at a time (except during unit tests where NODE_ENV=test).

    To use a component type in a new world, you must call world.terminate() on the previous world to make its component types available again. If you need multiple worlds to run concurrently, you should instead use partial execution techniques (Start/Stop or Custom Executors) within a single world.

  6. Establish entity relationships with Type.ref and Type.backrefs

    main

    Becsy provides built-in types for managing relationships between entities. Never reference entities via IDs or by storing Entity objects in Type.object properties.

    Type.ref

    Used for 1-to-1 relationships. A Type.ref field holds a reference to a single entity or null.

    • If the target entity is deleted, the reference is automatically nulled out.
    • The previous value remains accessible via System.accessRecentlyDeletedData until overwritten or purged.

    Type.backrefs

    Used for 1-to-N relationships. A Type.backrefs field automatically maintains a list of references to the entity where the component resides.

    • The list is managed automatically by Becsy; it cannot be modified manually.
    • The order of entities in the list is arbitrary.
    • Configuration Options:
      • Default: Includes all references to the entity (cheapest).
      • Component Type: Only includes references from components of a specific type.
      • Component Type + Field Name: Only includes references from a specific field within a specific component type (safest/most precise).

    Note on Writes: A system modifying ref properties implicitly requires write entitlements to all component types that have backrefs pointing to that entity, as those lists will change.

    N-to-N Relationships

    To model N-to-N relationships (like friendships), you must reify the relationship as its own entity containing Type.ref fields.

    // Example: 1-to-N relationship (Inventory)
    @component class Packed {
      @field.ref declare holder: Entity;
    }
    
    @component class Inventory {
      @field.backrefs(Packed, 'holder') declare contents: Entity[];
    }
    
    world.build(sys => {
      const player = sys.createEntity(Inventory, Health);
      const potion = sys.createEntity(Potion, {healing: 200});
    
      // Add item to player's inventory
      potion.add(Packed, {holder: player});
      
      // Access contents
      const items = player.read(Inventory).contents; // [potion]
    
      // Remove item
      potion.remove(Packed);
      player.read(Inventory).contents; // []
    });
  7. Manage multi-frame behaviors with Coroutines

    main

    Coroutines allow you to write sequential logic that spans multiple frames (e.g., animations or timed delays) without manually managing state machines in components. A coroutine is a flow of execution attached to a system with its own call stack and context.

    Implementation:

    • TypeScript: Declare generator methods with the @co decorator. Invoke them directly from lifecycle methods.
    • JavaScript: Declare generator methods. Use this.start(method, ...args) to begin execution from a lifecycle method.

    Execution & Lifecycle:

    • Coroutines execute each frame immediately after the system's execute method, in the reverse order they were started.
    • You can suspend execution using yield with co utilities like co.waitForSeconds(n) or co.waitForFrames(n).
    • Coroutines return a handle that provides a cancellation API. You can also access the current coroutine context via co inside the generator.

    Cancellation:

    • Use .cancel() on the handle returned by start or this.run...().
    • Use .cancelIf(predicate) to cancel based on a condition evaluated every frame.
    • Inside a coroutine, use co.cancel() to stop immediately.
    @system class IntroSlideshow extends System {
      private slide = this.singleton.write(Slide);
    
      initialize(): void {
        this.runSlideshow(1.5);  // start a coroutine
      }
    
      @co *runSlideshow(delay: number) {
        this.slide.value = 1;
        yield co.waitForSeconds(delay);
        this.slide.value = 2;
        yield co.waitForFrames(2);
        this.slide.value = 3;
        yield co.waitForSeconds(delay);
        this.slide.value = 0;
      }
    }
  8. Query entities in a Becsy System

    main

    Inside a System, you define which entities to process using this.query(). The query builder allows you to specify requirements for components and their access modes.

    Query Patterns:

    • with(Component): Requires the entity to have the specified component.
    • .read: Specifies that the component should be accessed in read-only mode.
    • .write: Specifies that the component will be modified (read/write access).
    • .and: Chains multiple component requirements.
    • .current: Accesses the current set of entities matching the query.

    Accessing Data:

    • entity.read(Component): Returns a read-only view of the component.
    • entity.write(Component): Returns a mutable view of the component.
    • this.delta: Provides the time elapsed since the last execution (useful for frame-rate independent movement).
    class MovableSystem extends System {
      // Query for entities with Acceleration (read) AND Position (write)
      entities = this.query(
        q => q.current.with(Acceleration).read.and.with(Position).write
      );
    
      execute() {
        for (const entity of this.entities.current) {
          const acceleration = entity.read(Acceleration).value;
          const position = entity.write(Position);
          position.x += acceleration * this.delta;
        }
      }
    }
  9. Define components in Becsy

    main

    A component is a data-only object stored within an entity. In Becsy, component types are defined as classes with a schema that specifies the type and default value of each field. This schema allows Becsy to allocate efficient primitive-valued array buffers for storage.

    Key Rules:

    • No Behavior: Components should ideally contain no logic (logic belongs in systems), though generic getters/setters for data wrangling are permitted.
    • Schema Strictness: Only fields declared in the schema will be stored; any other properties added to instances will be dropped.
    • Tag Components: If a component is used only as a tag (no data), omit the schema to enable engine optimizations.
    • TypeScript Usage: In TypeScript, use the declare keyword for fields so Becsy can manage the property definitions via its internal buffers.
    • Access Requirements: To interact with components (add, read, write, remove), your systems must declare access entitlements in their queries.

    Naming Convention Tip:

    For components with a single field, avoid naming the field the same as the component class to prevent awkward access patterns like entity.read(Acceleration).acceleration. Instead, name the field value (e.g., entity.read(Acceleration).value).

    import {component, field, Type} from '@lastolivegames/becsy';
    
    @component 
    class ComponentA {
      @field.boolean declare booleanValue: boolean;
      @field({type: Type.uint32, default: 10}) declare integerValue: number;
      @field.dynamicString(32) declare stringValue: string;
    }
  10. Declare component entitlements for systems

    main

    Queries serve a secondary purpose: declaring component entitlements (read, write, create, update). These declarations are combined from all queries in a system and are required to perform operations on those component types.

    • write: Implicitly includes read, create, and update.
    • read: Automatically applied to any component used in a with or without clause, but can be explicitly declared.
    • using(...): Allows declaring an entitlement for a component type that is not part of the query constraints (e.g., q.using(Component).write).

    Note: You can only read, write, create, or update component types for which you have declared entitlements. read and write also grant access to the has family of methods.

    @system class Namer extends System {
      // Select all Players that don't have a Name component yet.
      // The .write suffix declares entitlement to write to the Name component.
      private uninitializedPlayers =
        this.query(q => q.current.with(Player).but.without(Name).write);
    
      execute(): void {
        for (const player of this.uninitializedPlayers.current) {
          player.add(Name, {value: 'New Name'});
        }
      }
    }
  11. Use numeric vectors in components

    main

    When a component needs to hold multiple numeric values of the same type (e.g., coordinates), use a vector field instead of separate fields. This improves organization, cache locality, and performance.

    Becsy vectors are array-like objects with a length property and indexed accessors, but they are not standard JavaScript arrays (they lack methods like .push() or .map()).

    Key Features:

    • Named Elements: You can provide an array of strings to name elements (e.g., ['x', 'y', 'z']), allowing both index-based (vec[0]) and name-based (vec.x) access.
    • Typed Array Access: Use .asTypedArray() to get a lightweight view onto the underlying data for low-level APIs.
    • Custom Classes: You can specify a custom class to act as a thin veneer over the vector data, allowing you to add methods (like .add()) while maintaining Becsy's multithreading-compatible storage.

    Warnings:

    • Only access the typed array while the corresponding entity handle is valid.
    • Do not write to a typed array obtained from a read-only handle; this causes undefined behavior.
    // Example: Vector with named elements and a custom class
    class Vector3 {
      x: number; y: number; z: number;
      add(that: Vector3): void { this.x += that.x; this.y += that.y; this.z += that.z; }
    }
    
    const v3Type = Type.vector(Type.float64, ['x', 'y', 'z'], Vector3);
    
    @component class MovingEntity {
      @field(v3Type) declare position: Vector3;
      @field(v3Type) declare velocity: Vector3;
    }
    
    world.build(sys => {
      const player = sys.createEntity(MovingEntity, {position: [10, 0, 10], velocity: [1.5, 0.2, 0.1]});
      const mover = player.write(MovingEntity);
      mover.position.add(mover.velocity);
    });