Entity-Component-System for HTML5 Games

Building a ECS for 2D browser games with Typescript

A picture of the author, Jakob Maier
Jakob Maier
Apr 4, 2022

Developing games with Object Oriented Programming often leads to deep inheritance hierarchies. This can cause problems when trying to extend the game to implement new features when the existing hierarchies prove incorrect and inflexible.

One possible solution to this is using an Entity-Component-System (ECS), a software design pattern commonly used in game development. Instead of entities inheriting their behaviour and data from parent classes, we seperate data and behaviour into so called "components" and "systems". Components hold only raw data. The systems contain the logic and operate on one or more components, accessing them sequentially. Entities are now nothing more than a collection of components.

So say we would like to implement some balls bouncing around. First we need some simple Physics. We create the components "Position" and "Velocity" and a System "PhysicsSystem". "PhysicsSystem" will now perform calculations on these components. Below you can find how this could be implemented in my simple ecs, a TypeScript ECS for HTML5 games.

If we would now like to implement sprites, we simply create a "Sprite" component, holding the image data as well as it's width and height, and a "SpriteSystem". This sprite system now operates on the components "Sprite" and "Position". Below you can see how it turned out.

One neat part of Entity-Component-Systems is how easily behaviour can be added and removed from game entities: we just add or remove components.

import * as ECS from "lofi-ecs";

const canvas: HTMLCanvasElement = document.getElementById("canvas") as HTMLCanvasElement;
const context: CanvasRenderingContext2D = canvas.getContext("2d") as CanvasRenderingContext2D;

const GRAVITY = 500;

const SPRITE = new Image();
SPRITE.src = "assets/sprite.png";

function randomFloat(min: number, max: number): number {
	return Math.random() * (max - min) + min;
}

class Velocity extends ECS.Component {
	x: number;
	y: number;

	constructor(x: number, y: number) {
		super();
		this.x = x;
		this.y = y;
	}
}

class Position extends ECS.Component {
	x: number;
	y: number;

	constructor(x: number, y: number) {
		super();
		this.x = x;
		this.y = y;
	}
}

class Sprite extends ECS.Component {
	image: HTMLImageElement;
	width: number;
	height: number;
	constructor(image: HTMLImageElement, width: number, height: number) {
		super();
		this.image = image;
		this.width = width;
		this.height = height;
	}
}

class PhysicsSystem extends ECS.System {
	constructor() {
		super([Position, Velocity]); // necessary Components
	}

	updateEntity(entity: ECS.Entity, params: ECS.UpdateParams): void {
		const position = entity.getComponent(Position) as Position;
		const velocity = entity.getComponent(Velocity) as Velocity;

		position.x = position.x + params.dt * velocity.x;
		position.y = position.y + params.dt * velocity.y;

		if (position.y < params.canvas.height) {
			velocity.y += params.dt * GRAVITY;
		}

		if (position.y >= params.canvas.height) {
			velocity.y = -velocity.y;
		}

		if (position.x <= 0 || position.x >= params.canvas.width) {
			velocity.x = -velocity.x;
		}
	}
}

class SpriteSystem extends ECS.System {
	constructor() {
		super([Position, Sprite]);
	}

	updateEntity(entity: ECS.Entity, params: any): void {
		const sprite = entity.getComponent(Sprite) as Sprite;
		const coords = entity.getComponent(Position) as Position;

		params.context.drawImage(
			sprite.image,
			0,
			0,
			sprite.width,
			sprite.height,
			coords.x - Math.round(sprite.width / 2),
			coords.y - Math.round(sprite.height),
			sprite.width,
			sprite.height
		);
	}
}

const ecs = new ECS.ECS();

ecs.addSystem(new PhysicsSystem());
ecs.addSystem(new SpriteSystem());

for (let i = 0; i < 10; i++) {
	const entity = new ECS.Entity();
	entity.addComponent(new Sprite(SPRITE, 16, 16));
	entity.addComponent(new Velocity(randomFloat(-200, 200), 0));
	entity.addComponent(
		new Position(Math.floor(Math.random() * canvas.width), Math.floor(Math.random() * canvas.height))
	);
	ecs.addEntity(entity);
}

let dt: number = 0;
let then: number = 0;
function animate(now: number) {
	now *= 0.001;
	dt = now - then;
	then = now;
	if (dt > 1 / 30) dt = 1 / 30;

	context.clearRect(0, 0, canvas.width, canvas.height);
	context.fillStyle = "#000";
	context.fillRect(0, 0, canvas.width, canvas.height);

	ecs.update({ dt, canvas, context });
	requestAnimationFrame(animate);
}

animate(0);

↑ back to top

© 2024 Jakob Maier
kontakt & impressum
edit