Themis Entity Component System
A modern, lightweight and easy to use ECS for Typescript
Themis ECS aims to be a modern, performant, lightweight, zero-dependencies and easy to use Entity Component System with a clean and intuitive API written in TypeScript.
To get started with Themis there is little to do:
Install Themis ECS in your TypeScript project using npm install themis-ts reflect-metadata
and add the following to your tsconfig.json
file
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
In Themis ECS you define your world in modules. There are two types of modules, top level modules and submodules. A module is used to organize your code, it contains your systems, providers and submodules. Systems are an ECS specific feature, whereas providers are used for dependency injection. Did you know? Themis helps you write better code by providing you with a lightweight dependency injection framework. In a top level module, Themis automatically creates a pipeline which contains all the systems you have registered in the top level module and all of its submodules. A bit confused? Well, let's hop into some code to see everything in action and work on the details later:
Let's create a simple system:
@System()
class MySystem implements OnInit, OnUpdate {
init(): void {
console.log('hello from MySystem');
}
update(dt: number): void {
console.log('hello from update, dt is ', dt);
}
}
Now let's create our first module:
@Module({
systems: [MySystem],
providers: [],
imports: []
})
class MyModule {
init(pipeline: Pipeline): void {
console.log('hello from MyModule');
setInterval(() => pipeline.update(42), 1000);
}
}
The last thing we need to do now, is to create our Themis World using the WorldBuilder
class. We will register
our newly created module MyModule
new WorldBuilder().module(MyModule).build();
As you can see in the example above, the class MyModule
uses Pipeline
in its update method. As stated before,
a pipeline is automatically setup by Themis for you and contains all the systems you have defined in the top level module
and all its nested submodules. You can use the pipeline object for periodic updates to your systems, which will result
in calling all defined update methods in your systems.
Let us head back to our system MySystem and inject the World
interface using the constructor. We can then use this interface to create
an entity and add components to the entity.
class MyComponentA {
value: number = 13;
}
class MyComponentB {
value: string = 'the brown fox is not quick today';
}
@System()
class MySystem implements OnInit, OnUpdate {
constructor(private world: World) {}
init(): void {
console.log('hello from MySystem');
const entity = this.world.createEntity();
entity.addComponent(MyComponentA);
entity.addComponent(MyComponentB);
}
update(dt: number): void {
console.log('hello from update, dt is ', dt);
}
}
Most of the time, when using an ECS, we are interested in entities, which match a specific query described by the
components present or absent on these entities. In Themis you can define a query using the ComponentQuery
decorator. Let us create a new system which queries for all entities, which contain MyComponentA
and
MyComponentB
:
@System()
class MyComponentQuerySystem implements OnUpdate {
@ComponentQuery(all(MyComponentA, MyComponentB))
private query!: Query;
update(): void {
this.query.entities.forEach((entity) => {
console.log(entity.getComponent(MyComponentA).value); // 13
console.log(entity.getComponent(MyComponentB).value); // 'the brown fox is not quick today'
});
}
}
Here we use the all
function, which means, that all components have to be present on the entity to match the query.
There are two more predefined functions any
and none
, which means that only one or none of the components.
To use the System, simply add it to the systems array of MyModule
.
In the example above, we have added all of our systems directly to the top level module. It is more convenient to create one or multiple submodules for your systems and add those submodules to the import array of your top level module. Especially if your codebase grows, this will help you to keep everything modular and well organized.
@Module({
systems: [MySystem, MyComponentQuerySystem],
providers: [],
imports: []
})
class MySubModule {
init(): void {
console.log('hello from MySubModule');
}
}
@Module({
systems: [],
providers: [],
imports: [MySubModule]
})
class MyModule {
init(pipeline: Pipeline): void {
console.log('hello from MyModule');
setInterval(() => pipeline.update(42), 1000);
}
}
Notice how the submodule does not have the pipeline parameter? As stated before, this is only available in top level modules. But do not worry, your systems, which were defined in the submodule, are still present in the pipeline of the top level module. Themis merges them. How they are merged and in which order are they performed you ask? Well that is easy to answer:
Say we have a top level module named A, and submodules named a, b and c. Module A defines systems A1 and A2, the submodule a defines systems a1, a2 and submodules b and c define systems b1, b2, b3 and c1.
They are registered in the following order:
@Module({
systems: [a1, a2]
})
class a {}
@Module({
systems: [b1, b2, b3]
})
class b {}
@Module({
systems: [c1]
})
class c {}
@Module({
systems: [A1, A2],
imports: [a, b, c]
})
class A {
init(pipeline: Pipeline): void {
// ...
}
}
The order of execution will then be:
a1
a2
b1
b2
b3
c1
A1
A2
This order also applies for nested submodules. In fact, you traverse the tree by depth-first post-order.
Themis will automatically inject modules and systems.
You can register custom dependencies to Themis by using the Provider
interface. There are 3 types of providers:
@Injectable()
export class SomeClass {
// property injection
@Inject()
private myClass: MyClass;
// constructor injection
constructor(private myService: MyService) {}
}
Be sure to have the Injectable
decorator present on your classes and make sure you have a provider registered
in your module for Themis to be able to detect the dependencies.
@Injectable()
class MyService {
constructor() {
// ...
}
}
@Injectable()
class MyClass {
constructor(service: MyService) {
// ...
}
}
@Module({
systems: [],
providers: [MyService, MyClass],
imports: [],
exports: [MyClass] // this will make the defined provider available to modules that import this module
})
class MyModule {}
providers: [MyClass]
is a shorthand notation for a class provider (see below) which is the same as
providers: [{ provide: MyClass, useClass: MyClass }]
Providers are limited to their module scope. If you want them to become available in modules that import your module,
you will need to export them using the exports
array of your module. Exporting your providers is totally optional,
in fact, it is best practice to only export what is really needed to have good encapsulation and modularization of
your dependencies.
To change the strategy Themis uses when resolving dependencies, you can use the following provider types in your modules:
const classProvider = { provide: MyClass, useClass: MyClassImpl };
@Module({
systems: [],
providers: [classProvider],
imports: [],
exports: [MyClass] // this will make the defined provider available to modules that import this module
})
class MyModule {
@Inject()
private myClass!: MyClass; // Themis will resolve MyClassImpl and inject the instance
}
const valueProvider = { provide: 'MyValue', useValue: 42 };
@Module({
systems: [],
providers: [valueProvider],
imports: [],
exports: ['MyValue'] // this will make the defined provider available to modules that import this module
})
class MyModule {
@Inject('MyValue')
private myValue!: number; // Themis will inject 42
}
const factoryProvider = { provide: MyClass, useFactory: () => new MyClassImpl() }
@Module({
systems: [],
providers: [factoryProvider],
imports: [],
exports: [MyClass] // this will make the defined provider available to modules that import this module
})
class MyModule {
@Inject()
private myClass!: MyClass; // Themis will call the factory and inject the returned value
}
Themis offers a variety of features to help you write better code.