Building a Game Engine with TypeScript: Part 1 — Getting Started
Getting started with the basics — How does it “all” work?
If you’re thinking about creating a game engine, you may be wondering where to start. I faced this challenge myself and initially struggled to find a solution. However, I eventually learned that the key is to start small and gradually build up the features of your engine.
Creating a fully-featured game engine from scratch is a daunting task, especially if you’re working alone. It requires a large team with a range of skills and expertise. That’s why I decided to explore a different approach: Entity-Component-System (ECS).
ECS is a way of separating logic and adding features to your game engine over time. It works by dividing your game into entities, which are made up of data components, and systems that operate on these components. ECS follows the principle of composition over inheritance, meaning that each entity is defined by its associated components rather than by a hierarchy of types. This allows for greater flexibility and modularity in your game development process.
To begin building your game engine, the first step is to set up a project in a code editor like VSCode. This involves creating a configuration and organizing your files in a logical folder structure. It’s important to keep a clear and organized folder structure to make it easier to navigate and work on your project.
The folder structure I am using while writing this “Dev Blog” is as follows:
src/
- core/
- types/
- utils/
- components/
- entities/
- systems/
main.ts
assets/
tests/
docs/
Once I had the main folder structure in place, I created an ECS class in the “core” folder that serves as the central part of the engine. This class is responsible for managing the overall functioning of the system. It is what allows the entire engine to operate as intended.
What is an entity?
A entity is just a type of number
export type Entity = number
Entity Component System Class
The ECS class needs to have two key properties:
private entities: Map<Entity, ComponentCollection>()
private systems: Map<System, Set<Entity>>()
Both of these properties should have public setters to allow for modification. The entities
property is a map that associates each entity with a collection of components, while the systems
property is a map that associates each system with a set of entities. These properties are essential for managing the components and systems within the ECS system.
The addEntity
setter is a method that is used to add a new entity to the ECS system. It is defined as follows:
public addEntity(): Entity {
let entity = this.nextId++;
this.entities.set(entity, new ComponentCollection());
return entity;
}
This method creates a new entity by incrementing a nextId
counter and then adding it to the entities
map along with a new ComponentCollection
. The ComponentCollection
is a class that is used to store and manage the components associated with the entity. The method returns the newly created entity, which can then be used to add components or register it with systems.
public addComponent(entity: Entity, component: Component): void {
this.entities.get(entity)?.add(component);
this.checkEntity(entity);
}
public addSystem(system: System): void {
system.ecs = this;
this.systems.set(system, new Set());
for (let entity of this.entities.keys()) {
this.checkEntitySystem(entity, system);
}
}
The addComponent
function takes two arguments: entity
, which is the entity to which the component will be added, and component
, which is the component object to be added.
The function uses the add
method of the Set
object to add the component to the set of components associated with the entity. It also calls the checkEntity
method, which we will cover later in the article.
The addSystem
function is another method of the ECS
class that allows you to add a new system to the ECS. The function takes a single argument: system
, which is the system object to be added.
The function begins by setting the ecs
property of the system
object to this
, which refers to the current instance of the ECS
object. This gives the system a reference to the ECS, which it can use to access the entities and components managed by the ECS.
What is a Component?
public addComponent(entity: Entity, component: Component): void {
this.entities.get(entity)?.add(component);
this.checkEntity(entity);
}
In the provided code snippet, the addComponent
function is used to add a component to an entity. The function takes in an entity
and a component
as arguments and adds the component to the entity using the add
method.
It is important to note that in an ECS architecture, entities do not have any behavior or state on their own. Instead, they are simply containers that hold references to their associated components. By attaching different combinations of components to entities, developers can create complex systems with diverse behavior and functionality.
The Component base class is simple.
export abstract class Component {}
Example of an component (Health)
export class HealthComponent extends Component {
constructor(public health: number) {
super();
}
}
Quick look at the ComponentCollection class
export type ComponentClass<T extends Component> = new (...args: any[]) => T
export class ComponentCollection {
private map = new Map<Function, Component>()
public add(component: Component): void {
this.map.set(component.constructor, component);
}
public get<T extends Component>(componentClass: ComponentClass<T>): T {
return this.map.get(componentClass) as T;
}
public has(componentClass: Function): boolean {
return this.map.has(componentClass);
}
public hasAll(componentClasses: Iterable<Function>): boolean {
for (let cls of componentClasses) {
if (!this.map.has(cls)) {
return false;
}
}
return true;
}
public delete(componentClass: Function): void {
this.map.delete(componentClass);
}
}
How does the system knows when a entity is added?
private checkEntity(entity: Entity): void {
for (let system of this.systems.keys()) {
this.checkEntitySystem(entity, system);
}
}
private checkEntitySystem(entity: Entity, system: System): void {
let componentCollection = this.entities.get(entity);
if (componentCollection === undefined) {
return;
}
if (componentCollection.hasAll(system.componentsRequired)) {
this.systems.get(system)?.add(entity);
return
}
this.systems.get(system)?.delete(entity);
}
The checkEntity
function takes in an entity
as an argument and loops through a list of systems. For each system, it calls the checkEntitySystem
function, passing in the entity
and the system
as arguments.
The checkEntitySystem
function is used to check if the given entity has all of the components that the given system requires. If the entity does have all of the required components, the entity is added to the system using the add
method. If it does not have all of the required components, the entity is removed from the system using the delete
method.
By calling the checkEntity
function, we can ensure that all systems only contain entities that have the appropriate data to run in them. This helps to ensure that the systems are only processing entities that meet the necessary requirements, and that the system's logic is not being applied to entities that do not have the necessary components. #Performance😇
How does the system class look?
The System
class has three members:
componentsRequired
: This is a set of Component classes that are required before the system is run on an entity. This set should be defined at compile time and should not change.update(entities: Set<Entity>)
: This is an abstract method that is called on the System every frame. It takes a set of entities as an argument and must be implemented by any class that extends theSystem
class.ecs
: This is a reference to the ECS (Entity-Component-System) instance that the system belongs to. The ECS is given to all systems, and systems contain most of the game code, so they need to be able to create, mutate, and destroy entities and components.
export abstract class System {
public abstract componentsRequired: Set<Function>
public abstract update(entities: Set<Entity>): void
public ecs: ECS | undefined
}
Here are an example of a HealthBarSystem
export class HealthBarSystem extends System {
componentsRequired = new Set<Function>([HealthComponent]);
update(entities: Set<Entity>): void {
for (let entity of entities) {
const components = this.ecs?.getComponents(entity);
const healthComponent = components?.get(HealthComponent)
if (healthComponent === undefined) {
break;
}
console.log(healthComponent.health)
}
}
}
What can we do with the game engine in the currenct state?
const ecs = new ECS();
ecs.addSystem(new HealthBarSystem());
let health = new HealthComponent(100);
const playerEntity = ecs.addEntity();
ecs.addComponent(playerEntity, health);
ecs.update(); // HealthBarSystem/console: 100
health.health = 90;
ecs.update(); // HealthBarSystem/console: 90
This is the basic about the way the game engine is going to work so we will setup a way for the computer/browser/phone to call the update function for us so we don’t have to do that manually and the data will not change this way that will happen in the systems.
Thank you for reading this article about the game engine. If you enjoyed it, please consider giving it a “clap” to show your support. I hope to see you in my future posts. Thank you again for reading! ❤️