An Entity Component System (ECS) implementation in TypeScript, extensible, working with any renderer, type safe and composable πΉοΈ
@typeonce/ecshas zero dependencies. It makes no assumptions about your game engine, renderer or any other library or framework.
It is designed to provide a solid and type-safe ECS implementation to keep the logic of your game organized and easy to understand.
How you choose to render, apply physics, manage input, etc. is up to you, @typeonce/ecs doesn't impose any constraints.
Install the package:
pnpm add @typeonce/ecsDefine your components (e.g. components.ts):
import { Component } from "@typeonce/ecs";
export class Position extends Component<"Position"><{
x: number;
y: number;
}> {}
export class Velocity extends Component<"Velocity"><{
dx: number;
dy: number;
}> {}Define your systems (e.g. systems.ts):
import { System } from "@typeonce/ecs";
export type SystemTags = "Movement";
const SystemFactory = System<SystemTags>();
export class MovementSystem extends SystemFactory<{}>("Movement", {
execute: ({ world }) => {
// Implement the system logic
},
}) {}Write the system logic by querying entities that have a set of components attached:
import { query, System } from "@typeonce/ecs";
import { Position, Velocity } from "./components";
const moving = query({ position: Position, velocity: Velocity });
export type SystemTags = "Movement";
const SystemFactory = System<SystemTags>();
export class MovementSystem extends SystemFactory<{}>("Movement", {
execute: ({ world }) => {
moving(world).forEach(({ position, velocity }) => {
// Do something with each entity and its `position` and `velocity` components
});
},
}) {}Update the components to apply changes to the game (e.g. movement):
import { query, System } from "@typeonce/ecs";
import { Position, Velocity } from "./components";
const moving = query({ position: Position, velocity: Velocity });
export type SystemTags = "Movement";
const SystemFactory = System<SystemTags>();
export class MovementSystem extends SystemFactory<{}>("Movement", {
execute: ({ world, deltaTime }) => {
moving(world).forEach(({ position, velocity }) => {
position.x += velocity.dx * deltaTime;
position.y += velocity.dy * deltaTime;
});
},
}) {}Create an instance of the game using ECS.create:
import { ECS } from "@typeonce/ecs";
import { type SystemTags } from "./systems";
const world = ECS.create<SystemTags>(() => {
// Initialize the game
});Initialize entities, components and systems inside ECS.create:
import { ECS } from "@typeonce/ecs";
import { MovementSystem, RenderSystem, type SystemTags } from "./systems";
import { Position, Velocity } from "./components";
const world = ECS.create<SystemTags>(
({ addComponent, createEntity, addSystem }) => {
addComponent(
createEntity(),
new Position({ x: 0, y: 0 }),
new Velocity({ dx: 0, dy: 0 })
);
addSystem(
new MovementSystem(),
new RenderSystem()
);
}
);Run the game loop by executing the update function from ECS:
You can use
requestAnimationFrame,setInterval, or any other renderer to run the game loop (libraries or frameworks likepixi.js, Phaser, etc.)
import { ECS } from "@typeonce/ecs";
import { MovementSystem, RenderSystem, type SystemTags } from "./systems";
import { Position, Velocity } from "./components";
const world = ECS.create<SystemTags>(
({ addComponent, createEntity, addSystem }) => {
addComponent(
createEntity(),
new Position({ x: 0, y: 0 }),
new Velocity({ dx: 0, dy: 0 })
);
addSystem(
new MovementSystem(),
new RenderSystem()
);
}
);
requestAnimationFrame((deltaTime) => {
world.update(deltaTime);
});The package is available on npm:
pnpm add @typeonce/ecsCreating a new ECS game uses the ECS.create function:
import { ECS } from "@typeonce/ecs";
const world = ECS.create(() => {
// Initialize the game
});Every call to
ECS.createcreates a new world. You can use multiple worlds for different scenes.
The function inside ECS.create is where you initialize the game:
- Add systems
- Create initial entities (player, tiles, etc.)
- Add components to entities
import { ECS } from "@typeonce/ecs";
const world = ECS.create(({ addComponent, addSystem, createEntity }) => {
// Add systems (accepts multiple systems at once)
addSystem(new MovementSystem(), new RenderSystem());
// Create an entity, returns its `EntityId`
const entityId = createEntity();
// Add a component to the entity (accepts multiple components at once)
addComponent(entityId, new Player(), new Position({ x: 0, y: 0 }));
});Components are defined using the Component function:
import { Component } from "@typeonce/ecs";
export class Position extends Component("Position")<{
x: number;
y: number;
}> {}
export class Player extends Component("Player")<{}> {}Systems use the System function to define a system's factory:
import { System } from "@typeonce/ecs";
// Derive a system factory from the `System` function
const SystemFactory = System<"Movement" | "Render">();
// Use the factory to create a system
export class RenderSystem extends SystemFactory<{}>("Render", {
execute: ({ world }) => {
// Implement the system logic
},
}) {}
// Use the factory to create a system
export class MovementSystem extends SystemFactory<{}>("Movement", {
execute: ({ world }) => {
// Implement the system logic
},
}) {}The execute function is where you implement the system logic. It provides a set of utility functions to manage entities, components, and systems in the game:
export class FoodSpawnSystem extends SystemFactory<{
width: number;
height: number;
}>("FoodSpawn", {
// Execute this system after the `Collision` system
dependencies: ["Collision"],
execute: ({
poll,
destroyEntity,
createEntity,
addComponent,
// π Extract the `width` and `height` from the input
input: { width, height },
}) => {
// Listen for the `FoodEaten` event
poll(FoodEatenEvent).forEach((event) => {
// π Destroy the entity that was eaten
destroyEntity(event.data.entityId);
// π Spawn a new food entity
addComponent(
createEntity(),
new Position({
x: Math.random() * width,
y: Math.random() * height,
}),
new Size({ size: 10 }),
new Food({ value: 10 }),
new Collidable({ entity: "food" }),
new Renderable({ color: "#D80032" }),
);
});
},
}) {}The created ECS instance provides an update function that you can call each frame to update the game, using whatever other library or framework you prefer:
Calling
updatewill execute all the systems in the world one time. You are expected to callupdateonce per frame (or whatever other frequency you prefer).
// Create a world for a snake game (add systems, create entities, etc.)
const world = ECS.create<SystemTags, GameEventMap>(
({ addComponent, createEntity, addSystem }) => {
addComponent(
createEntity(),
new Size({ size: 10 }),
new Position({
x: ctx.canvas.width / 2,
y: ctx.canvas.height / 2,
}),
new SnakeHead(),
new Collidable({ entity: "snake" }),
new Renderable({ color: "#2B2D42" }),
new Velocity({ dx: 0, dy: -1, speed: 0.1 }),
new FollowTarget({ x: 0, y: 0 })
);
addSystem(
new SnakeGrowSystem(),
new CollisionSystem(),
new MovementSystem(),
new FollowSystem(),
new TargetSystem({ followDelayCycles: undefined }),
new RenderSystem({ ctx }),
new SnakeControllerSystem({ inputManager }),
new FoodSpawnSystem({
width: ctx.canvas.width,
height: ctx.canvas.height,
})
);
}
);
// Apply any rendering logic by executing the `update` function from `ECS`
renderer((deltaTime) => world.update(deltaTime));Another example is using pixi.js to render the game by executing the update function inside app.ticker.add:
import * as PIXI from "pixi.js";
const app = new PIXI.Application();
await app.init({ width: 800, height: 600 });
document.body.appendChild(app.canvas);
// Create `world` and initialize game
app.ticker.add(({ deltaTime }) => {
world.update(deltaTime);
});In ECS you want to query all the entities that have (or not have) a specific set of components attached.
For that you can use the query and queryRequired functions.
queryRequiredrequires at least one entity to exist in the game, otherwise executing the query will throw.
A query is defined as a map of components. It can be defined outside a system and reused between them.
When executed it returns an array of entities that have the defined components attached (type-safe based on the keys of the map inside query):
// π Query all the entities with `Renderable`, `Position` and `Size` components
const renderPosition = query({
renderable: Renderable,
position: Position,
size: Size,
});
export class RenderSystem extends SystemFactory<{
ctx: CanvasRenderingContext2D;
}>("Render", {
execute: ({ world, input: { ctx } }) => {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
// π Extract the entities from the `world` using the query (type-safe)
renderPosition(world).forEach(({ renderable, position, size }) => {
ctx.fillStyle = renderable.color;
ctx.beginPath();
ctx.arc(position.x, position.y, size.size, 0, Math.PI * 2);
ctx.fill();
});
},
}) {}queryRequired can be used to extract a single entity:
const requiredHead = queryRequired({
snake: SnakeHead,
velocity: Velocity,
position: Position,
size: Size,
});
export class SnakeControllerSystem extends SystemFactory<{
inputManager: InputManager;
}>("SnakeController", {
execute: ({ world, input: { inputManager } }) => {
// π At least one entity is guaranteed to exist in the game (`[0]` is always defined)
const snakeHead = requiredHead(world)[0];
if (inputManager.isKeyPressed("ArrowUp")) {
snakeHead.velocity.dx = 0;
snakeHead.velocity.dy = -1;
} else if (inputManager.isKeyPressed("ArrowDown")) {
snakeHead.velocity.dx = 0;
snakeHead.velocity.dy = 1;
} else if (inputManager.isKeyPressed("ArrowLeft")) {
snakeHead.velocity.dx = -1;
snakeHead.velocity.dy = 0;
} else if (inputManager.isKeyPressed("ArrowRight")) {
snakeHead.velocity.dx = 1;
snakeHead.velocity.dy = 0;
}
},
}) {}Both query and queryRequired accept a second optional parameter which is a list of the components that should be excluded from the query:
// π Query all the entities with `Position` and `Velocity` components, and don't have `Collidable`
const moving = query({ position: Position, velocity: Velocity }, [Collidable]);Events are used to send messages between systems. Any system has access to the emit function to emit events:
Events are type-safe and must be defined in the
GameEventMaptype.
export const FoodEatenEvent = Symbol("FoodEaten");
export interface GameEventMap extends EventMap {
[FoodEatenEvent]: { entityId: EntityId };
}You can then emit an event using the emit function:
// π Apply the `GameEventMap` type to the `SystemFactory` function to make events type-safe
const SystemFactory = System<SystemTags, GameEventMap>();
export class CollisionSystem extends SystemFactory<{}>("Collision", {
execute: ({ emit }) => {
if (/* collision detected */) {
emit({
type: FoodEatenEvent, // π Emit the event from its unique symbol
data: { entityId: entity.entityId }, // π Pass the entity that was eaten
});
}
},
}) {}Other systems can use the poll function to extract events and react to them:
Important: events are cleaned up after each update cycle. If you want to ensure an event was emitted before executing a system you can use
dependencies(see below).
export class SnakeGrowSystem extends SystemFactory<{}>("SnakeGrow", {
dependencies: ["Collision"], // π Ensure the `Collision` system has been executed and events collected
execute: ({ poll }) => {
poll(FoodEatenEvent).forEach(({ entityId }) => {
// Do something with the event (`entityId`)
});
},
}) {}Sometimes you need to execute a system after another system.
For example, you might want to spawn food only after the snake has eaten it. This creates a dependency between the FoodSpawnSystem and the CollisionSystem: you first want to detect collisions, and then spawn food if a collision occurs.
You can define a dependency between two systems using the optional dependencies property:
export class FoodSpawnSystem extends SystemFactory<{}>("FoodSpawn", {
// Execute this system after the `Collision` system
dependencies: ["Collision"],
execute: ({ world }) => {
// Inside here all collisions are already detected from the `CollisionSystem`
},
}) {}You can specify multiple dependencies. The library takes care of resolving each system's dependencies and execute them in the correct order.
Defines a component class with a tag and properties.
In the example below, the component is tagged as "Position" and has two properties: x and y.
export class Position extends Component("Position")<{
x: number;
y: number;
}> {}You can then create instances of the component like any other class:
const position = new Position({ x: 10, y: 20 });You can also copy the properties of the component using the spread operator:
const position = new Position({ x: 10, y: 20 });
const newPosition = new Position({ ...position, x: 30 });Component classes are mutable, so you can change the properties of the component inside a system.
Defines a systems' factory. It accepts two generic parameters:
- A union of all the tags of the systems in the world
- An
EventMapof all the possible emitted events in the world
import { type EntityId, type EventMap, System } from "@typeonce/ecs";
export const FoodEatenEvent = Symbol("FoodEaten");
export interface GameEventMap extends EventMap {
[FoodEatenEvent]: { entityId: EntityId };
}
export type SystemTags =
| "Movement"
| "PostMovement"
| "Render"
| "Input"
| "Collision"
| "ApplyMovement";
const SystemFactory = System<SystemTags, GameEventMap>();SystemFactory is then used to create systems. A system is defined as a class:
- The generic parameter defines the input type required to create an instance of the system
- The first parameter is the tag of the class (must be included in the
SystemTagsused when creatingSystemFactoryfromSystem) - The second parameter requires an
executefunction and an optionaldependenciesexecuteis the implementation of the systemdependenciesdefines the tags of the systems that are required to execute before the current one
const SystemFactory = System<SystemTags, GameEventMap>();
export class CollisionSystem extends SystemFactory<{
// π Input required
gridSize: { width: number; height: number };
}>("Collision", {
dependencies: ["Movement"],
execute: (params) => {
// π System logic
},
}) {}params inside execute provide utility functions to manage entities, components, and systems in the game:
deltaTimeworld: Reference to current instance of the game worldaddSystem: Adds one or more systems to the gamecreateEntity: Creates an entity and returns itsEntityId(number)destroyEntity: Removes an entity from itsEntityIdaddComponent: Adds one or more components to an entity from itsEntityIdremoveComponent: Removes one or more components to an entity from itsEntityIdgetComponentRequired: Gets one or more components from an entity from itsEntityId. The components are expected to be found, otherwise the function will throw anErrorgetComponent: Gets one or more components from an entity from itsEntityId(not required, it may returnundefined)emit: Emits an event that something happened in the gamepoll: Reads events emitted by other systems during the current update cycle
An actual instance of World is created using the ECS class from ECS.create. You can provide two generic parameters (same as System):
- A union of all the tags of the systems in the world
- An
EventMapof all the possible emitted events in the world
You can implement a function to initialize the game using the following provided utility functions:
addSystemcreateEntityaddComponent
Defines a map of components used to query the world for all the entities that have the defined components attached.
It can be defined outside a system and reused between them.
// A query for all the entities with both `Position` and `Movement` components
const moving = query({ position: Position, movement: Movement });You can then provide an instance of World to extract all the entities:
const moving = query({ position: Position, movement: Movement });
export class MovementSystem extends SystemFactory<{}>("Movement", {
execute: ({ world }) => {
moving(world).forEach(({ position, movement, entityId }) => {
// Do something with each entity and its `position` and `movement` components
});
},
}) {}Defines a map of components used to query the world for all the entities that have the defined components attached (same as query).
It requires at least one entity to exist in the game, otherwise executing the query will throw (returns a non-empty array of entities).
This is useful to extract a single entity you know must exist in the game, for example a "player" entity.
It can be defined outside a system and reused between them.
// A query for all the entities with both `Movement` and `Player` components
const playerQuery = queryRequired({ movement: Movement, player: Player });You can then provide an instance of World to extract all the entities:
const playerQuery = queryRequired({ movement: Movement, player: Player });
export class InputSystem extends SystemFactory<{}>("Input", {
execute: ({ world }) => {
// π The first element in the array is guaranteed to exist (`[0]`)
const { movement, player, entityId } = playerQuery(world)[0];
},
}) {}