@kadiryazici/ecs
TypeScript icon, indicating that this package has built-in type declarations

1.1.1 • Public • Published

An Entity-Component-System inspired by Bevy Engine.

This was a challange for me, I have never created an ECS before and wanted to test myself. It doesn't provide THE BEST PERFORMANCE but I think it is easy to understand.

Installation

npm install @kadiryazici/ecs
pnpm add @kadiryazici/ecs
yarn add @kadiryazici/ecs

Demo

You can view a demo in /demo/main.ts and Online

Components

To create component we use defineComponent function. This function can take undefined or a function that returns an object as parameter. The reason why parameter is a function is not to have reference issues with default states because when a component created, default values are placed if missing.

import { defineComponent } from '@kadiryazici/ecs';

const Velocity = defineComponent(() => ({
   x: 0,
   y: 0,
}));

// Can be used to tag entities, has no state.
const Player = defineComponent();

We created a component, now it's time to create an instance of it, then we will use it in Entities.

// if you give undefined or {}, Velocity will have default state { x: 0, y,: 0 }
const baseVelocity = Velocity.create();
baseVelocity.state; // { x: 0, y: 0 }

// You can override some default values
const baseVelocity = Velocity.create({ x: 50 });
baseVelocity.state; // { x: 50, y: 0 }

You can create a new component instance and state like this but we won't use them like that.

Entities

Entities contain components for systems to query them. To create an entity we use createEntity function.

import { defineComponent, createEntity } from '@kadiryazici/ecs';

const Velocity = defineComponent(() => ({
   x: 0,
   y: 50,
}));

const Name = defineComponent(() => ({
   value: '',
}));

const Player = createEntity()
   .add(Velocity.create({ x: 25 }))
   .add(Name.create({ value: 'Player' }));

Nice! we have created an entity with Name and Velocity component.

But it is better to return it from a function not to create multiple references to the same state.

const createPlayer = () =>
   createEntity()
      .add(Velocity.create({ x: 25 }))
      .add(Name.create({ value: 'Player' }));

We can add or remove component after an entity created.

const Player = createPlayer();
Player.add(Name.create({ value: }));
Player.remove(Name);

World

World is a store of entities. It stores every unique entity in a Set.

import { createWorld } from '@kadiryazici/ecs';

const world = createWorld();

To add an entity to our world we can use add function.

world
   .add(createPlayer());
   .add(
      createEntity()
         .add(Velocity.create())
         .add(Name.create({ value: 'Enemy' })),
   );

Nice! now we know how to create Entities, Worlds and Components, now it's time to learn how to create and run queries.

Query

Queries filter a world of entities by given components and then return States Tuple of Components of found Entities.

You can mutate components' state after iterating query result, that's why components only accept object. It's for mutation references.

import { createQuery } from '@kadiryazici/ecs';

// This query will search for entities that has Name component, and will return an array of tuple: [name][].
const VelocityQuery = createQuery([Name]);

function somethingSystem() {
   /*
      query is an array of tuple of Name components: 
      
      [
         [{ value: 'Player' }],
         [{ value: 'Enemy' }],
      ]
   */
   const query = VelocityQuery.exec(world);

   for (const [name] of query) {
      name.value = 'Now your name is xXxMurdererxXx2010';
   }

   // You can use forEach too, it's just an array.
   // But personally I prefer for...of.
   query.forEach(([name]) => {
      name.value = 'Or you can use forEach, but for...of better.';
   });
}

You can search for multiple components as well.

import { createQuery } from '@kadiryazici/ecs';

const VelocityNameQuery = createQuery([Name, Velocity]);

function somethingSystem() {
   const query = VelocityNameQuery.exec(world);

   for (const [name, velocity] of query) {
      console.log(name.value);
      velocity.x += 3;
      velocity.y -= 3;
   }
}

With

So far we only queried components we want to receive, what if we want to receive Name component of entities that has Velocity component.

For this we can use With modifier.

import { defineComponent, createQuery, With } from '@kadiryazici/ecs';

// Lets create a third component for our queries.
const Bounds = defineComponent(() => ({
   width: 0,
   height: 0,
}));

/*
   First parameter should always be a tuple/array of components we want to receive.
   Other parameters are just spread, you can give as much modifiers as you want.
*/
const NameQueryWithVelocity = createQuery([Name], With(Velocity));

With modifier can get infinite number of component arguments.

import { createQuery, With } from '@kadiryazici/ecs';

const NameQueryWithVelocity = createQuery([Name], With(Velocity, Bounds));

function somethingSystem() {
   const query = NameQueryWithVelocity.exec(world);
   for(const [name] of query) {
      ...
   }
}

Multiple received components and With modifier.

import { createQuery, With } from '@kadiryazici/ecs';

const NameBoundsQuery = createQuery([Name, Bounds], With(Velocity, SomeComponent));

function somethingSystem() {
   const query = NameBoundsQuery.exec(world);
   for (const [name, bounds] of query) {
      something(name.value);
      draw(bounds.width, bounds.height);
   }
}

If you want you can repeat modifiers, they will be merged when the query executes.

It will be With(Shadow, Light, Foot, Head).

import { createQuery } from '@kadiryazici/ecs';

const Query = createQuery([Name, Velocity, Color], With(Shadow, Light), With(Foot, Head));

Without

We know about With modifier and how to use it, what if we want to receive Name component of entities that don't have Velocity and Bounds component.

import { createQuery, Without, With } from '@kadiryazici/ecs';

const NameQuery = createQuery([Name], Without(Velocity, Bounds));

// Can be used multiple times as well
// Will be converted into `Without(Bounds, Velocity)`
const NameQuery = createQuery([Name], Without(Velocity), Without(Bounds));

// Can be mixed with With modifier.
const NameQuery = createQuery([Name], With(RigidBody, Velocity, Bounds), Without(Shadow));

function somethingSystem() {
   const query = NameQuery.exec(world);

   for (const [name] of query) {
      console.log(name.value);
   }
}

Systems

Systems are just functions that run queries and manages their states. You actually learned how to create systems above.

Let's create a System that updates Position by Velocity of Entities that has RigidBody component but don't have FixedBody.

import { createQuery, With, Without, createWorld, createEntity } from '@kadiryazici/ecs';
import type { World } from '@kadiryazici/ecs';

const PositionVelocityQuery = createQuery([Position, Velocity], With(RigidBody), Without(FixedBody));

// Systems are just functions, you can pass them whatever you want.
function movementSystem(world: World, delta: number) {
   const query = PositionVelocityQuery.exec(world);

   for (const [position, velocity] of query) {
      position.x += velocity.x * delta;
      posiyion.y += velocity.y * delta;
   }
}

const world = createWorld();

world.add(
   createEntity()
      .add(Position.new({ x: 50, y: 75 }))
      .add(Velocity.new())
      .add(RigidBody.new()),
);

movementSystem(world, Game.getDeltaTime());

Special Component EntityId

If you also want to receive Entity ID from query you can use this special component.

For example if you want to remove enemies that player shot you need to remove entity from the world.

import { EntityId, World} from '@kadiryazici/ecs';

const BulletPositionsQuery = createQuery([Position], With(Bullet));
const EnemyPositionsQuery = createQuery([Position, EntityId], With(Enemy));

function collisionSystem(world: World) {
   const bulletPositions = PlayerPositionQuery.exec(world);
   const enemyPositions = EnemyPositionsQuery.exec(world);

   for (const [bulletPosition] of bulletPositions) {
      for (const [enemyPosition, enemyEID] of enemyPositions) {
         if (isColliding(bulletPosition.value, enemyPosition.value)) {
            // Bullet hit th enemy, so we remove it.
            world.remove(enemyEID);
         }
      }
   }
}

function gameLoop() {
   ...
   somethingSystem();
   // you can make systems with arguments, you don't have to keep a reference to the world all the time.
   collisionSystem(world);
   ...

   gameLoop();
}

Marking component optional with Optional(component: Component)

Lets assume that you want to receive Name and Velocity states but optionally Player component to check if it is Player. You can do this by creating two separate queries but it isn't nice to use.

For this we have an Optional modifier, you can mark a component as optional and you receive null or state of component.

import { type World, createEntity, defineComponent, Optional } from '@kadiryazici/ecs';

const Name = defineComponent(() => ({ value: '' }));
const Velocity = defineComponent(() => ({ value: new Vec2(0, 0) }));
const Player = defineComponent();

const NameAndVelocityQuery = createQuery([Name, Velocity, Optional(Player)]);

function loggerSystem(world: World) {
   const query = NameAndVelocityQuery.exec(world)

   // player will be undefined or an empty object.
   for (const [name, velocity, player] of query) {
      if(player !== undefined) // Player exists for this entity.
   }
}

Example Project

You can see a little complicated example in my other repo: Bomberman Clone

Readme

Keywords

none

Package Sidebar

Install

npm i @kadiryazici/ecs

Weekly Downloads

0

Version

1.1.1

License

none

Unpacked Size

23.1 kB

Total Files

14

Last publish

Collaborators

  • kadiryazici