MECS - Monomorph ECS - A high-performance Entity Component System for TypeScript and JavaScript projects, designed for games and simulations.
npm install @perplexdotgg/mecs



MECS is an ECS library for Typescript and Javascript games and simulations, built around Monomorph, a library for performant objects. The API is inspired by Miniplex, though the implementation is quite different.
Although using Monomorph classes as components gives you some extra features, it is not a requirement or dependency of MECS. You can use primitives (number, boolean, etc) or your own classes, and customize their lifecycle with component config objects. See the "Non-Monomorph Components" and "Customizing Components" sections below for more details.
MECS focuses on the developer's experience in their IDE, especially around auto-complete and compile-time type-checking, while maintaining very high performance, about as high as you can achieve with an AoS design.
Side note: Why not use SoA design?
While SoA can achieve better performance in the best cases, it tends to be unwieldy, especially for nested fields, and generally ECS implementations lack auto-complete for components and properties. For larger game projects, remembering all component names and their properties is quite difficult. Using JS getters with SoA (to achieve auto complete) gives much worse overall performance than AoS.
---
For convenience, MECS automatically puts entities in a central pool by default, so they can be re-used. However you can also create multiple pools for an entity class, or have multiple entity classes with different schemas and pools.
MECS has no built-in concept of "systems" in the ECS sense, instead it provides queries to help you quickly write efficient code that processes specific archetypes of entities. Your systems can just be functions that iterate over entities matching a query, and your systems can also subscribe to entities newly matching, or no longer matching, a query.
MECS has been created primarily around having a single entity schema/pool, where most entities only have a subset of the possible components. For example, some but not all of your entities might be visible, they may or may not have physics properties, some might be controlled by input, and so on. If all your entities have all the same components, it may be simpler to use Monomorph on its own.
``bash`
npm install @perplexdotgg/mecs
The following is JavaScript code. The Typescript version is below this.
`js
import { createClass } from 'monomorph';
import { createEntityClass } from '@perplexdotgg/mecs';
// imagine some monomorph classes in your project
// see the monomorph docs for more on those:
// https://codeberg.org/perplexdotgg/monomorph
class Projectile extends createClass(projectileProps) {}
class Transform extends createClass(transformProps) {}
// define all the components you want entities to potentially have
const components = {
// defining components when they are monomorph classes:
localTransform: Transform,
globalTransform: Transform,
Projectile, // this is shorthand for: projectile: Projectile
};
// define queries you want to do. these always stay up-to-date, efficiently
const queries = {
projectiles: {
// an entity must have all of these components to match this query
with: ['globalTransform', 'projectile'],
// AND it can have none of these components to match this query
without: ['localTransform'],
// listeners can added now, or later (see the Queries docs below)
afterEntityAdded: (entity) => {
// this is called when an entity newly matches this query
},
beforeEntityRemoved: (entity) => {
// this is called when an entity no longer matches this query,
// just before a relevant component is removed from the entity
// and/or before an entity is destroyed
},
},
query2: {
with: ['localTransform', 'globalTransform'],
without: [],
},
};
// additional Entity class options, see "Entity Class Options" below
const extraOptions = {};
// automatically create our entity class from the schemas above.
// note that there are two function calls after createEntityClass
// to circumvent a typescript limitation (sorry, vanilla JS folks!)
class Entity extends createEntityClass(extraOptions)(components, queries) {
// most methods will likely exist on your components rather than your entities,
// but you can also extend the generated entity class
myEntityMethod() {
// your code here! use 'this' like you would expect
if (this.hasComponent('example')) {
this.example.whatever = 5;
}
}
}
`
Typescript version
`ts
import { createClass } from 'monomorph';
import { createEntityClass, type QueryMap, type ComponentMap, type ComponentConfig } from '@perplexdotgg/mecs';
// imagine some monomorph classes in your project, see the monomorph docs for more on those:
// https://codeberg.org/perplexdotgg/monomorph
class Projectile extends createClass
class Transform extends createClass
// non-monomorph components are also supported, see "Non-Monomorph Components" below
// define all the components you want entities to potentially have
const components = {
// defining components when they are monomorph classes:
localTransform: Transform,
globalTransform: Transform,
Projectile, // this is shorthand for: projectile: Projectile
} as const satisfies ComponentMap;
// define queries you want to do. these always stay up-to-date, efficiently
const queries = {
projectiles: {
// an entity must have all of these components to match this query
with: ['globalTransform', 'projectile'],
// AND it can have none of these components to match this query
without: ['localTransform'],
// listeners can added now, or later (see the Queries docs below)
// note that in this context, the entity type doesn't have Entity methods that you've added
// if you need those, either ts-ignore when calling them here, or add listeners later in your system code using:
// Entity.queries().yourQueryKey.afterEntityAdded.addListener((entity) => { / your code here will get the right entity type / }))
afterEntityAdded: (entity) => {
// this is called when an entity newly matches this query
},
beforeEntityRemoved: (entity) => {
// this is called when an entity no longer matches this query,
// just before a relevant component is removed from the entity
},
},
query2: {
with: ['localTransform', 'globalTransform'],
without: [],
},
} as const satisfies QueryMap
// additional Entity class options, see "Entity Class Options" below
const extraOptions = {};
// automatically create our entity class from the schemas above.
// note that there are two function calls after createEntityClass
// to circumvent a typescript limitation (TS issue 10571)
class Entity extends createEntityClass
// most methods will likely exist on your components rather than your entities,
// but you can also extend the generated entity class
myEntityMethod() {
// your code here! use 'this' like you would expect
if (this.hasComponent('example')) {
this.example.whatever = 5;
}
}
}
`
`ts
const entity1 = Entity.create({
globalTransform: {
position: [0, 0, 0],
orientation: [0, 0, 0],
scale: 1,
},
projectile: {
speed: 4,
direction: [1, 0, 0],
},
});
// now entity1 is of the Entity class, and entity1.globalTransform is of the Transform class
// so you can call methods on it like so:
entity1.globalTransform.composeMatrix();
const entity2 = Entity.create({
projectile: {
speed: 4,
direction: { x: 1, y: 0, z: 0 },
},
});
// you can use destroy() to return an entity to the pool, it will automatically be re-used later
// this iterates on all components to remove them first, and triggers queries just
// before the component(s) needed for the query are deleted
// each destroyed component also gets recycled in its own component pool
entity2.destroy();
`
, an object with the following properties:
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| skipSafetyChecks | boolean | false | Set to true to skip safety checks when adding/removing components, improving performance. It is recommended to keep this false during development and true in production builds. |
| logCode | boolean | false | Set to true to console.log the code that was automatically generated for the base Entity class. |
| logComponents | boolean | false | Set to true to console.log every time a component is added or removed from an entity. (note: this affects the generated Entity code) |
| logQueries | boolean | false | Set to true to console.log every time an entity is added or removed from a query. (note: this affects the generated Entity code) |
$3
For components that are not monomorph classes, such as your own classes or primitives like numbers and booleans,
you can quickly define them with a default value (such as null) as the component config. You are then responsible for creating and managing these components, including any necessary cleanup before they are removed. Below is a simple example; see the "Customizing Components" section below for more advanced options.This is the Javascript versiom, the Typescript version is below.
`js
// some non-monomorph class
class MyClass {}const components = {
// allows entity.addComponent('isVisible') without specifying the second parameter, in this case
// entity.isVisible will be true. this is how you can use components as simple flags
isVisible: true,
// for non-monomorph classes, you can use a key with value null, or a default value of your choice.
// you are responsible for creating and managing this field, including any needed clean-up
// before it is removed. the
null here is the default value that will be used when
// entity.addComponent('myClass') is called without a second parameter, and it is also the value set on
// the entity when the component is removed
myClass: null, // you have much more control over the component's lifecycle, by passing a component config object,
// see the "Customizing Components" section below for details
myCustomComponent: {
// component config options here, see "Customizing Components" below
},
};
const queries = {};
class Entity extends createEntityClass()(components, queries) {}
const entity = Entity.create({
myClass: new MyClass(),
isVisible: true,
});
// now entity.myClass is an instance of MyClass, and entity.isVisible is true
entity.destroy();
// now entity.myClass is null, but note that entity.isVisible is still true, based on the value in the
components config above
`
TypeScript Version
`ts
// some non-monomorph class
class MyClass {}const components = {
// allows entity.addComponent('isVisible') without specifying the second parameter, in this case
// entity.isVisible will be true. this is how you can use components as simple flags
isVisible: true as unknown as ComponentConfig,
// for non-monomorph classes, you can use a key with value null, or a default value of your choice.
// you are responsible for creating and managing this field, including any needed clean-up
// before it is removed. the
null here is the default value that will be used when
// entity.addComponent('myClass') is called without a second parameter, and it is also the value set on
// the entity when the component is removed
myClass: null as unknown as ComponentConfig, // you have much more control over the component's lifecycle, by passing a component config object,
// see the "Customizing Components" section below for details
myCustomComponent: {
// config options here, see below
} as const satisfies ComponentConfig,
} as const satisfies ComponentMap;
const queries = {};
class Entity extends createEntityClass()(components, queries) {}
const entity = Entity.create({
myClass: new MyClass(),
isVisible: true,
});
// now entity.myClass is an instance of MyClass, and entity.isVisible is true
entity.destroy();
// now entity.myClass is null, and entity.isVisible is still true, based on the values in the
components object above
`$3
`js
// calling component methods
entity1.projectile.checkCollisions();// calling entity methods
entity1.myEntityMethod();
// adding amd accessing a monomorph-based component using json notation
entity1.addComponent('globalTransform', {
position: { x: 1, y: 2, z: 3, },
orientation: { x: 0, y: 0, z: 0, w: 1 },
scale: 1,
});
entity1.globalTransform.position.x += 5;
// add a component, using another component as input data
entity2.addComponent('localTransform', entity1.localTransform);
// ^^ they will not be the same objects, they will just have the same data,
// this is also true for nested objects. i.e:
// entity2.localTransform will be a deep copy of entity1.localTransform
// remove a component
entity1.removeComponent('projectile');
// checking if a component exists on an entity:
if (entity1.hasComponent('globalTransform')) { / / }
`$3
`ts
// all entities
for (const entity of Entity.pool) {}
// only for monomorph-class components, iterating all instances of a component
for (const transform of Entity.components.localTransform) {
}
// only for monomorph-class components, iterating all instances of a component,
// along with the entity for each
for (const [transform, entity] of Entity.componentsWithEntities.localTransform) {
}
`$3
For defining queries, see the entity schema definition section above
`ts
// all entities matching a query
for (const entity of Entity.queries.projectiles) {}
// in addition to being able to define
afterEntityAdded and beforeEntityRemoved functions in
// the schema, you can dynamically add a new listener for entities matching a query,
// useful if you want to put this logic in your system code for example
function afterProjectileExists(entity) {
// do whatever
}Entity.queries.projectiles.afterEntityAdded.addListener(afterProjectileExists);
// you can also remove listeners
Entity.queries.projectiles.afterEntityAdded.removeListener(afterProjectileExists);
Entity.queries.projectiles.beforeEntityRemoved.addListener(someListener);
Entity.queries.projectiles.beforeEntityRemoved.removeListener(someListener);
`$3
You can have components that reference other entities, by using Monomorph's LazyReferenceType and LazyReferenceListType. This will usually 'just work', but in some situations you may also need to use MECS's LazyComponent. See the Typescript tests in tests/componentsWithEntityReferences.test.ts and tests/lazyComponentsWithEntityReferences.test.ts for examples. Below is a simple Javascript example:`js
const turretProps = {
targetEntity: LazyReferenceType(() => Entity), // or for multiple referenced entities:
targetEntities: LazyReferenceListType(() => Entity),
};
class Turret extends createClass(turretProps) {}
const components = {
turret: Turret,
// or the lazy loaded version:
// turret: LazyComponent(() => Turret),
// alternatively, you can use a component config object for further customization:
turret: {
monomorphClass: Turret,
// or the lazy loaded version:
// monomorphClass: LazyComponent(() => Turret),
// other config options here, for example:
afterComponentAdded: (turretInstance, entity, componentKey) => {
if (turretInstance.targetEntity === null) {
// example maybe automatically select a target in this situation
}
},
},
};
class Entity extends createEntityClass()(components, queries) {}
`
$3
For both monomorph and non-monomorph types, you can pass a component config object, allowing you to customize the component's lifecycle. A brief example is below with a few of the options, for many more option examples, see the tests in
tests/customComponents.test.ts.This is the Javascript version, the Typescript version is below.
`js
class MyClass {}const components = {
myClass: {
// called after the component is added to an entity
afterComponentAdded: (myClassInstance, entity, componentKey) => {
// do any additional actions on myClassInstance here
// componentKey will be the string 'myClass' in this case
},
// called before the component is removed from an entity
beforeComponentRemoved: (myClassInstance, entity, componentKey) => {
// do any needed cleanup on myClassInstance here
},
// this function will be called to process the input data when adding this component
processDataOnAdd: (data) => {
if (data === null) {
// provide a default value if needed
data = new MyClass();
}
// modify and return the data as needed
return data;
},
},
myCustomMonomorphComponent: {
monomorphClass: SomeMonomorphClass,
// all other config options are available, see the tests for more examples
},
};
const queries = {};
class Entity extends createEntityClass()(components, queries) {}
const entity = Entity.create({
myClass: null, // this will automatically be processed by processDataOnAdd
});
// entity.myClass is now an instance of MyClass
const entity2 = Entity.create({
myClass: new MyClass(), // this still works, because of the (data === null) check in processDataOnAdd
});
`
TypeScript Version
`ts
class MyClass {}const components = {
myClass: {
// called after the component is added to an entity
afterComponentAdded: (myClassInstance, entity, componentKey) => {
// do any additional actions on myClassInstance here
// componentKey will be the string 'myClass' in this case
},
// called before the component is removed from an entity
beforeComponentRemoved: (myClassInstance, entity, componentKey) => {
// do any needed cleanup on myClassInstance here
},
// this function will be called to process the input data when adding this component
processDataOnAdd: (data) => {
if (data === null) {
// provide a default value if needed
data = new MyClass();
}
// modify and return the data as needed
return data;
},
// ComponentConfig means that entity.myClass will be of type MyClass,
// and when adding the component, the input data can be either MyClass or null
} as const satisfies ComponentConfig,
myCustomMonomorphComponent: {
monomorphClass: SomeMonomorphClass,
// all other config options are available, see the tests for more examples
} as const satisfies ComponentConfig,
} as const satisfies ComponentMap;
const queries = {};
class Entity extends createEntityClass()(components, queries) {}
const entity = Entity.create({
myClass: null, // this will automatically be processed by processDataOnAdd
});
// entity.myClass is now an instance of MyClass
const entity2 = Entity.create({
myClass: new MyClass(), // this still works
});
`---
Here are all the available component config options:
| Option | Type | Description |
| --- | --- | --- |
|
monomorphClass | A Monomorph class | If you want to customize the component lifecycle for a monomorph class, set this to the monomorphClass. Otherwise do not set this field. |
| afterComponentAdded | Function: (componentInstance, entity, componentKey) => void | A function that will be called after the component is added. This will happen before any query membership-related triggers occur. |
| afterComponentAddedCode | String or Function that returns a string | Like the above, but you provide the code that will be injected directly into the generated Entity code for this component. See the tests for more details. |
| beforeComponentRemoved | Function: (componentInstance, entity, componentKey) => void | A function that will be called before the component is removed. This will happen after any query membership-related triggers occur. |
| beforeComponentRemovedCode | String or Function that returns a string | Like the above, but you provide the code that will be injected directly into the generated Entity code for this component. See the tests for more details. |
| processDataOnAdd | Function: (data) => processedData | A function that will be called to process the input data when adding this component. The returned value will be used as the component's value for non-monomorph components. For monomorph components, the processed value will be passed to the class's create method. |
| processDataOnAddCode | String or Function that returns a string | Like the above, but you provide the code that will be injected directly into the generated Entity code for this component. See the tests for more details. |
| nullComponent | Function: (entity, componentKey) => void | By default, i.e. when this option is not specified, the component property on the entity gets set to null when the component is removed from the entity. To override that behavior, you can provide a function that does whatever you prefer (pass a noop function to leave the value as is) |
| nullComponentCode | String or Function that returns a string | Like the above, but you provide the code that will be injected directly into the generated Entity code for this component. See the tests for more details. |
| beforeEntityClassCode` | String or Function that returns a string | Code that will be injected at the start of the generated code, before the Entity class's code. Useful for defining helper functions or values needed by all the options above. See the tests for more details. |- Example project/template coming soon
- Basic benchmarks
- Entity/component data debugging tool(s)
- Want to see something here? Please create an issue on codeberg
If you like this project and would like to support our work, please consider contributing code via pull requests, or donating via open collective. Contributions are greatly appreciated!