@voces/miniplex
v0.7.3
Published
A developer-friendly entity management system for games and similarly demanding applications, based on ECS architecture.
Downloads
3
Maintainers
Readme
Miniplex
🚨 WORK IN PROGRESS! Miniplex is mostly feature complete, but parts of the API are still being fine-tuned. Feel free to poke around, but please be ready for breaking changes!
Introduction
Miniplex is an entity management system for games and similarly demanding applications. Instead of creating separate buckets for different types of entities (eg. asteroids, enemies, pickups, the player, etc.), you throw all of them into a single store, describe their properties through components, and then write code that performs updates on entities of specific types.
If you're familiar with Entity Component System architecture, this will sound familiar to you -- and rightfully so, for Miniplex is, first and foremost, a very straight-forward ECS implementation.
If you're hearing about this approach for the first time, it may sound counter-intuitive -- but once you dive into it, you will understand how it can help you decouple concerns and keep your codebase well-structured and maintainable. This post has a nice summary:
An ECS library can essentially thought of as an API for performing a loop over a homogeneous set of entities, filtering them by some condition, and pulling out a subset of the data associated with each entity. The goal of the library is to provide a usable API for this, and to do it as fast as possible.
For a more in-depth explanation, please also see Sander Mertens' wonderful Entity Component System FAQ.
Headline Features
- A very strong focus on developer experience. Miniplex aims to be the most convenient to use ECS implementation while still providing great performance.
- Tiny package size and zero dependencies. (Yay!)
- Comes with React glue, but works with any framework, and of course vanilla JavScript.
- Can power your entire project or just parts of it.
- Written in TypeScript, with full type checking for your entities.
Main differences from other ECS implementations
If you've used other Entity Component System implementations before, here's how Miniplex is different from some of them:
Entities are just normal JavaScript objects
Entities are just plain JavaScript objects, and components are just properties on those objects. Component data can be anything you need, from primitive values to entire class instances, or even reactive stores. Miniplex aims to put developer experience first, and the most important way it does this is by making its usage feel as natural as possible in a JavaScript setting.
Miniplex does not expect you to programmatically declare component types before using them, but if you're using TypeScript, you can provide a type describing your entities and Miniplex will provide full edit- and compile-time type hints and safety.
Miniplex does not have a built-in notion of systems
Unlike most other ECS implementations, Miniplex does not have any built-in notion of systems, and does not perform any of its own scheduling. This is by design; your project will likely already have an opinion on how to schedule code execution, and instead of providing its own and potentially conflicting setup, Miniplex will neatly snuggle into the one you already have.
Systems are extremely straight-forward: just write simple functions that operate on the Miniplex world, and run them in whatever fashion fits best to your project (setInterval
, requestAnimationFrame
, useFrame
, your custom ticker implementation, and so on.)
Archetypal Queries
Entity queries are performed through archetypes, with archetypes representing a subset of your world's entities that have a specific set of components. More complex querying capabilities may be added at a later date.
Focus on Object Identities over numerical IDs
Most interactions with Miniplex are using object identity to identify entities or archetypes (instead of numerical IDs). However, entities do automatically get a built-in id
component with an auto-incrementing numerical ID once they're added to the world; this is mostly meant as a convenience for situations where you need to provide a unique scalar reference (eg. as the key
prop when rendering a list of entities as React components.)
Basic Usage
Miniplex can be used in any JavaScript or TypeScript project, regardless of which extra frameworks you might be using. Some React glue is provided out of the box, but let's talk about framework-less usage first.
Typing your Entities (optional)
If you're using TypeScript, you can define a type that describes your entities:
type Entity = {
position: { x: number; y: number; z: number }
velocity?: { x: number; y: number; z: number }
health?: number
} & IEntity
Creating a World
Miniplex manages entities in worlds, which act as a containers for entities as well as an API for interacting with them. You can have one big world in your project, or several smaller worlds handling separate concerns.
Note for TypeScript users: When you provide a type like we do here, every interaction with the world will provide full type hints:
import { World } from "miniplex"
const world = new World<Entity>()
Creating Entities
The main interactions with a Miniplex world are creating and destroying entities, and adding or removing components from these entities.
Let's create an entity. Note how we're immediately giving it a position
component:
const entity = world.createEntity({ position: { x: 0, y: 0, z: 0 } })
Adding Components
Now let's add a velocity
component to the entity:
world.addComponent(entity, "velocity", { x: 10, y: 0, z: 0 })
Now the entity has two components: position
and velocity
.
Querying Entities
We're going to write some code that moves entities according to their velocity. You will typically implement this as something called a system, which, in Miniplex, are typically just normal functions that fetch the entities they are interested in, and then perform some operation on them.
Fetching only the entities that a system is interested in is the most important part in all this, and it is done through something called archetypes that can be thought of as something akin to database indices.
Since we're going to move entities, we're interested in entities that have both the position
and velocity
components, so let's create an archetype for that:
const movingEntities = world.archetype("position", "velocity")
Implementing Systems
Now we can implement our system, which is really just a function -- or any other piece of code -- that uses the archetype to fetch the associated entities and then iterates over them:
function movementSystem(world) {
for (const { position, velocity } of movingEntities.entities) {
position.x += velocity.x
position.y += velocity.y
position.z += velocity.z
}
}
Note: Since entities are just plain JavaScript objects, they can easily be destructured into their components, like we're doing above.
Destroying Entities
At some point we may want to remove an entity from the world (for example, an enemy spaceship that got destroyed by the player):
world.destroyEntity(entity)
This will immediately remove the entity from the Miniplex world and all associated archetypes.
Queued Commands
All functions that modify the world (createEntity
, destroyEntity
, addComponent
and removeComponent
) also provide an alternative function that will not perform the action immediately, but instead put it into a queue:
world.queue.destroyEntity(bullet)
Once you're ready to execute the queued operations, you can flush the queue likes this:
world.queue.flush()
Note: Please remember that flushing the queue is left to you. You might, for example, do this in your game's main loop, after all systems have finished executing.
Usage with React
🚨 Warning: the React glue provided by this package is still incomplete and should be considered unstable. (It works, but there will be breaking changes!)
Even though Miniplex can be used without React (it is entirely framework agnostic), it does ship with some useful React glue, available in the miniplex/react
module.
import { createECS } from "miniplex/react"
This will create an object containing a newly created Miniplex world as well as a collection of useful React components and hooks. It is recommended that you invoke this function from a module in your application that exports the generated object, and then have the rest of your project import that module.
export default createECS()
world
createECS
returns a world
property containing the actual ECS world. You can interact with it like you would usually do to imperatively create, modify and destroy entities (see the chapters above.)
useArchetype
The useArchetype
hook lets you get the entities of the specified archetype (similar to the world.get
above) from within a React component. More importantly, this hook will make the component re-render every time entities are added to or removed from the archetype. This is useful for implementing systems as React components, or writing React components that render entities:
const MovementSystem = () => {
const { entities } = useArchetype(movingEntities)
useFrame(() => {
for (const { position, velocity } of entities) {
position.x += velocity.x
position.y += velocity.y
position.z += velocity.z
}
})
return null
}
createECS
also provides Entity
and Component
React components that you can use to declaratively create (or add components to) entities:
const Car = () => (
<Entity>
<Component name="position" data="{ x: 0, y: 0, z: 0 }" />
<Component name="position" data="{ x: 10, y: 0, z: 0 }" />
<Component name="sprite" data="/images/car.png" />
<Entity>
)
Performance Hints
Use for instead of forEach
You might be tempted to use forEach
in your system implementations, like this:
function movementSystem(world) {
movingEntities.entities.forEach(({ position, velocity }) => {
position.x += velocity.x
position.y += velocity.y
position.z += velocity.z
})
}
This might incur a modest, but noticeable performance penalty, since you would be calling and returning from a function for every entity in the archetype. It is typically recommended to use either a for/of
loop:
function movementSystem(world) {
for (const { position, velocity } of movingEntities.entities) {
position.x += velocity.x
position.y += velocity.y
position.z += velocity.z
}
}
Or a classic for
loop:
function movementSystem(world) {
for (let i = 0; i < movingEntities.entities.length; i++) {
const { position, velocity } = movingEntities.entities[i]
position.x += velocity.x
position.y += velocity.y
position.z += velocity.z
}
}
If your system code will under some circumstances immediately remove entities, you might even want to go the safest route of iterating through the collection in reversed order:
const withHealth = world.archetype("health")
function healthSystem(world) {
/* Note how we're going through the list in reverse order: */
for (let i = withHealth.entities.length; i >= 0; i--) {
const entity = withHealth.entities[i]
/* If health is depleted, destroy the entity */
if (entity.health <= 0) {
world.destroyEntity(entity)
}
}
}
Reuse archetypes where possible
The archetype
function aims to be idempotent and will reuse existing archetypes for the same categories of entities, so you will never risk accidentally creating multiple indices of the same archetypes. It is, however, a comparatively heavyweight function, and you are advised to, wherever possible, reuse previously created archetypes.
For example, creating your archetypes within a system function like this will work, but unnecessarily create additional overhead, and is thus not recommended:
function healthSystem(world) {
const movingEntities = world.archetype("position", "velocity")
for (const { position, velocity } of movingEntities.entities) {
position.x += velocity.x
position.y += velocity.y
position.z += velocity.z
}
}
Instead, create the archetype outside of your system:
const movingEntities = world.archetype("position", "velocity")
function healthSystem(world) {
for (const { position, velocity } of movingEntities.entities) {
position.x += velocity.x
position.y += velocity.y
position.z += velocity.z
}
}
Questions?
Find me on Twitter or the Poimandres Discord.