
Security News
Attackers Are Hunting High-Impact Node.js Maintainers in a Coordinated Social Engineering Campaign
Multiple high-impact npm maintainers confirm they have been targeted in the same social engineering campaign that compromised Axios.
tick-knock
Advanced tools
Small and powerful, type-safe and easy-to-use Entity-Component-System (ECS) library written in TypeScript
š Buy me a coffee
yarn add tick-knocknpm i --save tick-knockTick-Knock was inspired by several ECS libraries, mostly by Ash ECS.
The main approach was re-imagined to make it lightweight, easy-to-use, and less boiler-plate based.
In this part, you will learn all basics of Tick-Knock step by step.
Engine is a "world" where entities, systems, and queries interact with each other.
Since the Engine is the initial entry point for development with Tick-Knock, it is from this point that the creation of your world starts. Usually, the Engine exists in just one instance, and it does nothing but orchestrating everything added to it.
To begin with, you can add the most usual "inhabitants" to it.
const engine = new Engine();
const entity = new Entity()
.add(new Hero())
.add(new health(10))
engine.addEntity(entity);
Or you can take it out:
engine.removeEntity(entity);
The second main "inhabitant" is System. It is responsible for processing Entities and their components. We will learn about them in detail later.
engine.addSystem(new ViewSystem(), 1);
engine.addSystem(new PhysicsSystem(), 2);
As you may have noticed, we pass two parameters: system instance, and the second is update priority. The higher the priority number is, the later the system will be processed.
The third type of resident is Query, which is responsible for mapping entities within the Engine and returns a list of already filtered and ready-to-use entities.
const heroesQuery = new Query((entity) => entity.has(Hero));
engine.addQuery(heroesQuery);
The main task of the engine is to start the world update process and to report on the ongoing changes to Queries.
These changes can be: additions to and removal of entities from the Engine, and changes in the components of specific
Entities.
To perform the update step, we must call the update method and pass as a parameter the time elapsed since the previous
update.
Every time we start an update, the systems take turns, in order of priority, executing their own update methods.
// Half a second has passed from the previous step.
engine.update(0.5);
An additional - one of the Engine's responsibilities - transferring the messages from systems to the user. This can be very useful when, for example, you want to report that the round in your game is over.
engine.subscribe(GameOver, (message: GameOver) => {
if (game.win) {
this.showWinMessage();
} else {
this.showLoseMessage();
}
});
You can use not only class type as an argument but any value. For example, it could be a string or number.
const GAME_OVER = 'gameOver';
engine.subscribe(GAME_OVER, () => {
this.showGameOver();
});
Details of implementation
When the
dispatchmethod is called in the system, then to get the right listeners, the compliance of themessageTypefor each subscription will be checked.
- If
typeof subscription.messageTypeis a'function', then the matching will be performed usinginstanceOf.- Otherwise, the matching will be done through strict equality
message === subscription.messageType.
It is a data object, its purpose - to represent a single aspect of your entity. For example, position, velocity, acceleration.
Let's write your first component:
class Position {
public constructor(
public x: number = 0,
public y: number = 0
) {}
}
Yes, this is a component! š
It is still a data class, but it is made to solve the problem when you need to have multiple components of the same type.
Let's assume that you have a Damage component in your game. Several enemies attack the Hero simultaneously by adding the Damage component to it. What will happen? Only the last Damage component will be added to the Hero Entity because every previous one will be removed.
To solve this problem - you need to implement ILinkedComponent interface in your Damage component and "append" instead of "add" the Damage component to the entity. That will do the job. After that, in DamageSystem you can find all damage sources:
class Damage extends LinkedComponent {
public constructor(
public readonly value: number
) {
super()
}
}
hero.append(new Damage(100));
hero.append(new Damage(5));
class DamageSystem extends IterativeSystem {
public constructor() {
super((entity) => entity.hasAll(Damage, Health));
}
public updateEntity(entity: Entity) {
const health = entity.get(Health)!;
while (entity.has(Damage)) {
const damage = entity.withdraw(Damage);
health.value -= damage.value;
}
}
}
It also can be called a "label". It's a simplistic way to help you not "inflate" your code with classes without data. For instance, you want to mark your entity as Dead. There are two ways:
class Dead {}string or number.Using tags is much easier and consumes less memory if you do not have additional component data.
Example:
const ENEMY = 'enemy';
const HERO = 100500;
Keep it simple! š
It is a general-purpose object, which can be marked with tags and can contain different components.
Position components to the entity, the
second one will replace the first one.This is how it works:
const entity = new Entity()
.add(new Position(100, 100))
.add(new Position(200, 200))
.add(HERO);
console.log(entity.get(Position)); // Position(x = 200, y = 200)
Looks easy? Yes, it is!
Systems are logic bricks in your application. If you want to manipulate entities, their components, and tags - it is the right place.
Please, keep in mind that the complexity of the system mustn't be too high. When you find that your system is doing too much in the "update" method, you need to split it into several systems.
Responsibility of the system should cover no more than one logical aspect.
The system always has the following functionality:
engine will give you access to the engine itself and its entities. But be aware - you can't access
an engine if the system is not connected to it. Otherwise, you'll get an error.onAddedToEngine and onRemovedFromEngine will be called in the cases described by their naming.dispatch, you can easily send a message outside of the system. It will be delivered through the
engine Subscription pipe. There are the same restrictions as for the engine. If the system is not
attached to the engine, then an attempt to send a message will throw an error.update. It will be called whenever Engine.update is
being invoked. Update method - the right place to put your logic.Example:
It's time to write our first and straightforward system. It will iterate through all the entities that are in the
Engine, check if they have Position and Velocity components.
And if they do, then move our object.
class Velocity {
public constructor(
public x: number = 0,
public y: number = 0
) {}
}
class PhysicsSystem extends System {
public constructor() {
super();
}
public update(dt: number): void {
const {entities} = this.engine;
for (const entity of entities) {
if (entity.hasAll(Position, Velocity)) {
const position = entity.get(Position)!;
const velocity = entity.get(Velocity)!;
position.x += velocity.x * dt;
position.y += velocity.y * dt;
}
}
}
}
There you go! š In real life, you don't have to iterate through every entity in every system. It's completely uncomfortable and not optimal. In this library, there is a mechanism that can prepare a list of the entities that you need according to the criteria you set - it's called Query.
So what the "Query" is? It's a matching mechanism that can tell you which entities in the Engine are suitable for your needs.
For example, you want to write a system that is responsible for displaying sprites on your screen. To do this, you always need a current list of entities, each of which has three components - View, Position, Rotation, and you want to exclude those marked with the HIDDEN tag.
Let's write our first Query.
const displayListQuery = new Query((entity: Entity) => {
return entity.hasAll(View, Position, Rotation) && !entity.has(HIDDEN);
});
That's all!
Adding this Query to the Engine will always contain an up-to-date list of entities that meet the described requirements. Besides, you can always find out when a new entity has appeared in the Query, or an old entity has left it.
displayListQuery.onEntityAdded.connect(({current}: EntitySnapshot) => {
console.log("We've got a rookie here!");
container.addChild(current.get(View)!.view);
});
displayListQuery.onEntityRemoved.connect(({previous}: EntitySnapshot) => {
container.removeChild(previous.get(View)!.view);
console.log("Good bye, friend!");
});
Query builder is super simple. It has not much power, but you can use it for creating queries that must contain specific Components.
const query: Query = new QueryBuilder()
.contains(ComponentA, ComponentB)
.contains(TAG)
.build();
Now let's see how we can use Query on systems?
Let's write ViewSystem, which will be responsible for displaying our Entity on the screen.
When entities get to the list, the system will add them to the screen, and when they leave the list, the system will
remove them from the screen.
Example:
const query = new Query((entity: Entity) => {
return entity.hasAll(View, Position, Rotation) && !entity.has(HIDDEN);
});
class ViewSystem extends System {
public constructor(
private readonly container: Container
) { super(); }
public onAddedToEngine(): void {
// To make query work - we need to add it to the engine
this.engine.addQuery(query);
// And we need to add to the display list all entities that already
// exists in the Engine`s world and matches our Query
this.prepare();
// We want to know if new entities were added or removed
query.onEntityAdded.connect(this.onEntityAdded);
query.onEntityRemoved.connect(this.onEntityRemoved);
}
public onRemovedFromEngine(): void {
// There is no reason to update query after system was removed
// from the engine
this.engine.removeQuery(query);
// No reason for further listening of the updates
query.onEntityAdded.disconnect(this.onEntityAdded);
query.onEntityRemoved.disconnect(this.onEntityRemoved);
}
// We only want to update positions of the views on the screen,
// so there is no need for "dt" parameter, it can be omitted
public update(): void {
const entities = this.query.entities;
for (const entity of entities) {
this.updatePosition(entity);
}
}
private prepare(): void {
for (const entity of this.query.entities) {
this.onEntityAdded(entity);
}
}
private updatePosition(entity: Entity): void {
const {view} = entity.get(View)!;
const {x, y} = entity.get(Position)!;
const {rotation} = entity.get(Rotation)!;
view.position.set(x, y);
view.rotaion.set(rotation);
}
private onEntityAdded = ({current}: EntitySnapshot) => {
// Let's add new view to the screen
this.container.addChild(current.get(View)!.view);
// Don't forget to update it's position on the screen
this.updatePosition(current);
};
private onEntityRemoved = ({previous}: EntitySnapshot) => {
// Let's remove the view from the screen, because Entity no longer
// meets the requirements (might be it lost the View component
// or it was hidden)
this.container.removeChild(previous.get(View)!.view);
};
}
š I'm sure you saw the reference to
EntitySnapshotand wondering, "what the heck is that?". Please, be patient, I'll tell you about it a bit later. I think it looks good and clear for understanding!
In favor of reducing the time to write the boilerplate code - Tick-Knock provides two built-in systems. Each of them already knows how to work with Query, process the information coming from it, and allow access to this Query's entities.
All of the following built-in systems have the following features:
You can initialize those systems via three different items, which will be converted to Query eventually:
entities, which returns the current entities list of the Query.ReactionSystem can be considered as the system that has the ability to react to changes in Query. It is a basic built-in system. Exactly it will be used in most cases when developing your application.
Let's try to rewrite our ViewSystem, taking ReactionSystem as a basis, and take advantage of all the conveniences it provides.
Example:
class ViewSystem extends ReactionSystem {
public constructor(private readonly container: Container) {
super((entity: Entity) => {
return entity.hasAll(View, Position, Rotation) && !entity.has(HIDDEN);
});
}
public update(): void {
for (const entity of this.entities) {
this.updatePosition(entity);
}
}
protected prepare(): void {
for (const entity of this.entities) {
this.entityAdded(entity);
}
}
private updatePosition(entity: Entity): void {
const {view} = entity.get(View)!;
const {x, y} = entity.get(Position)!;
const {rotation} = entity.get(Rotation)!;
view.position.set(x, y);
view.rotaion.set(rotation);
}
protected entityAdded = ({current}: EntitySnapshot) => {
this.updatePosition(current);
this.container.addChild(current.get(View)!.view);
};
protected entityRemoved = ({previous}: EntitySnapshot) => {
this.container.removeChild(previous.get(View)!.view);
};
}
Now it's pretty simpler! š
This system has the same advantages as the ReactionSystem because it is inherited from the last one. š All it brings is a built-in iteration cycle for our Query inside the update method.
So, let's upgrade our ViewSystem a bit.
class ViewSystem extends IterativeSystem {
// almost everything remains the same, so I'll skip most of the code.
// The only difference regarding example with ReactionSystem - that we
// don't need to override `update` method.
// Instead of it we need to override updateEntity method.
// Also, we can safely omit the dt parameter because we do not use it.
protected updateEntity(entity: Entity, dt: number) {
this.updatePosition(entity);
}
}
It's possible to request removal of the system when you don't need it anymore. For example, the system is only needed to render the playing field, and trying to run it at every update cycle is wasteful.
Fortunately, you can request deletion right from the system:
class RenderBoardSystem extends System {
public update(dt: number): void {
// Your render board code
this.requestRemoval();
}
}
That's it. Your system will be removed right after update cycle.
As you may have noticed, when we are tracking changes in Query, we get in entityAdded and entityRemoved not Entity
but EntitySnapshot.
So what is a snapshot?
It is a container that displays the difference between the current state of Entity and its previous state. The entity
property always reflects the current state. Still, methods get and has methods of the snapshot return the data from
the previous state of the Entity before it was changed. So you can understand which components have been added and which
have been removed.
ā It is important to note that changes in the same entity components' data will not be reflected in the snapshot, even if a manual invalidation of the entity has been triggered.
Snapshots are very handy when you need to get a component or tag in Entity, but now it is missing. Let's take a closer
look at it with our ViewSystem example.
Example:
class ViewSystem extends IterativeSystem {
// ...
protected entityAdded = ({current}: EntitySnapshot) => {
// When entity added to the Query that means that it has `View`
// component - one hundred percent! So we just need its current
// state.
this.container.addChild(current.get(View)!.view);
this.updatePosition(current);
};
protected entityRemoved = ({previous}: EntitySnapshot) => {
// But when entity removed - we can't be sure that current state
// of the entity has `View` component. So we need to get it from
// the previous state. Previous state has it one hundred percent.
this.container.removeChild(previous.get(View)!.view);
};
// ...
}
In real life, there is often a need to have a single Entity that acts as a configuration for the whole world.
For example, you have a set of complex systems that involve both game logic and visualization, and animations. But for functional test purposes - you don't care about the visuals and animations. You face the situation of passing a specific flag in each system during initialization, which will be responsible for disabling animation and visualization.
Now imagine that you have several configuration parameters, and each of them you need to pass to all systems of your world.
To simplify handling such situations - you can use Engine.sharedConfig. Shared Config is an Entity available in all
systems after adding them to Engine.
Example:
const NO_VISUALS = 'no-visuals';
class ViewSystem extends IterativeSystem {
protected updateEntity(entity: Entity): void {
if (this.sharedConfig.has(NO_VISUALS)) {
return;
}
// Otherwise - update visuals
}
}
const engine = new Engine();
engine.sharedConfig.add(NO_VISUALS);
engine.addSystem(new ViewSystem());
ā Shared Config is the single instance connected to
Enginesince its initialization and can't be removed from it. It affects queries like any regularEntity.
Tick-knock provides an extended API for working with linked components since version 4.0.0.
Method withdraw removes the first LinkedComponent component of the provided type or existing standard component
Method pick removes provided LinkedComponent component instance or existing standard component.
Example You have a system responsible for checking boons (buffs) expiration, and you wish to remove expired boons from the hero:
enum BoonType {
PROTECTION,
AEGIS,
REGENERATION
}
class Boon extends LinkedComponent {
public constructor(
public readonly type: BoonType,
public value: number,
public duration: number
) { super(); }
}
class BoonExpirationTestSystem extends IterativeSystem {
public constructor() {
super((entity) => entity.has(Boon));
}
public updateEntity(entity: Entity, dt: number) {
// Let's update all boons
entity.iterate(Boon, (boon) => {
// Let's reduce boon remaining duration
boon.duration -= dt;
// If boon is expired
if (boon.duration <= 0) {
// Then we need to removed it from the Entity
// But `entity.remove` will remove all boons, so we need to cherry-pick
entity.pick(boon);
}
});
}
}
Method iterate iterates over instances of LinkedComponent and performs the action over each. Works for standard
components (action will be called for a single instance in this case).
š It's safe to
pickonly current entity during iteration.
Method find searches a component instance of the specified class. Works for standard components (predicate will be
called for a single instance in this case).
Method getAll returns a generator that can be used for iteration over all instances of specific type components.
Method lengthOf returns the number of existing components of the specified class.
Now you know the basics. Now let's look at some examples to help you understand when linked components are helpful and how to work with them.
We want to get a system that handles "Regeneration" buff on the hero. There can be more than one sources of regeneration, so we must handle all of them at the same time.
Regeneration has two effects:
Thus, our system should do the following:
class Regeneration extends LinkedComponent {
public constructor(
public instantHealValue: number,
public healPerSecond: number,
public duration: number
) { super(); }
}
class RegenerationSystem extends IterativeSystem {
public constructor() {
super((entity) => entity.has(Hero, Regeneration));
}
public updateEntity(entity: Entity, dt: number) {
const hero = entity.get(Hero)!
// Let's update all regeneration components on our hero and apply their effects
entity.iterate(Regeneration, (it) => {
// We need to heal hero
const healthPointsToAdd = Math.ceil(it.healPerSecond * dt);
hero.health += healthPointsToAdd;
// And then reduce regeneration duration
it.duration -= dt;
// If it's expired
if (it.duration <= 0) {
// Then we need to removed it from the Entity
// But `entity.remove` will remove all boons, so we need to cherry-pick
entity.pick(it);
}
});
}
protected entityAdded = ({current}: EntitySnapshot) => {
// When new entity appears in the queue, that means that it has Hero and Regeneration
// so we want to instantly heal the hero by existing Regeneration buffs
current.iterate(Regeneration, (regeneration) => {
this.instantlyHealHero(entity, regeneration);
})
// Also, if any additional Regeneration buff will appear in the entity, we will handle
// them as well and instantly heal the hero
current.onComponentAdded.connect(this.instantlyHealHero);
}
protected entityRemoved = ({current}: EntitySnapshot) => {
// We don't want to know if any new components were added to the entity when it left
// the queue already.
current.onComponentAdded.disconnect(this.instantlyHealHero);
}
private instantlyHealHero = (entity: Entity, regeneration: any) => {
// We need to filter components, because this function will called on every added
// component (not only Regeneration)
if (!(regeneration instanceof Regeneration)) return;
const hero = entity.get(Hero)!;
hero.health += regeneration.instantHealValue;
}
}
In real development, you'll definitely face a situation when you want to reuse Query.
For example, when developing a game with heroes and enemies, you will surely always need two queries:
Simplified version
const heroes = new Query(entity => entity.has(Hero));
const enemies = new Query(entity => entity.has(Enemy));
And you will want to use them in different systems. But the systems use local Queries. This means that after excluding a system from Engine, the Query in it will no longer be updated.
To prevent this from happening, you need to use the shared queries approach. To do this, you only need to add the query manually after initializing the Engine.
shared-queries.ts
export const heroes = new Query(entity => entity.has(Hero));
export const enemies = new Query(entity => entity.has(Enemy));
import {heroes, enemies} from 'shared-queries';
// ...
engine.addQuery(heroes);
engine.addQuery(enemies)
Now you can use these Queries in any other system.
Example:
import {heroes, enemies} from 'shared-queries';
class DamageSystem extends IterativeSystem {
// ...
protected updateEntity(entity: Entity) {
const damage = entity.remove(Damage)!
const isHero = heroes.has(entity);
if (damage.type === DamageType.SPLASH) {
const neighbours = getNeighbours(isHero ? heroes : enemies);
// ...
}
}
}
There are limitations for Query that do not allow you to track changes made inside components automatically.
Suppose that you want Query to track entities with an X position of 10.
const query = new Query((entity) => entity.has(Position) && entity.get(Position).x === 10);
And you have changed the Position parameters accordingly:
entity.get(Position)!.x = 10;
The query will not know about these changes because the mechanism for tracking changes in component fields is redundant
and heavy, which will have a huge impact on performance. But to fix this, you can use an entity method
called invalidate, it will force Query to check this particular entity.
ā Try not to use this approach too often. It may affect the performance of your application.
This software released under MIT license! Good luck, folks.
FAQs
TypeScript Entity-Component-System library
The npm package tick-knock receives a total of 24 weekly downloads. As such, tick-knock popularity was classified as not popular.
We found that tick-knock demonstrated a not healthy version release cadence and project activity because the last version was released a year ago.Ā It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
Multiple high-impact npm maintainers confirm they have been targeted in the same social engineering campaign that compromised Axios.

Security News
Axios compromise traced to social engineering, showing how attacks on maintainers can bypass controls and expose the broader software supply chain.

Security News
Node.js has paused its bug bounty program after funding ended, removing payouts for vulnerability reports but keeping its security process unchanged.