Jump to content

Other Trying to fully understand Entity/Component Architecture


Chief

Recommended Posts

In the past, I have always written games/engines in full-on OOP, but I stumbled upon an architecture that purports to be widely used when it comes to games, which I'm sure you are all aware of, but I was not.

 

Entity/Component architecture is supposed to help with issues of having large hierarchies of inheritance when it comes to entities, like a character.

 

Following this article from Gamedev.net (https://www.gamedev.net/resources/_/technical/game-programming/implementing-component-entity-systems-r3382), I've recreated what I understand to be an Entity/Component architecture in JavaScript.

 

First of all, the article created a core World object that contains a list of all current entities in the game, and then weak maps (I'll explain this momentarily) of the components of each entity. In JavaScript, this looks like the following:

import Position from '../components/position';
import Size from '../components/size';
import Texture from '../components/texture';

const { Map, Set, WeakMap } = global;

export default class World {

    options = new Map();

    state = new Map();

    entities = new Set();

    components = new Map([
        [ Position, new WeakMap() ],
        [ Size, new WeakMap() ],
        [ Texture, new WeakMap() ]
    ]);
}

As you can see, components is a Map of <ComponentClass, WeakMap>. What a WeakMap allows, is that if the key exists nowhere else in memory, the WeakMap will allow it to be garbage collected. The WeakMap is essentially <Entity, Component>, so if the entity is deleted from the world's entities Set, then it will automatically be garbage collected, preventing memory leaks.

 

The second part of this architecture is a Component. Components are strictly made up of data, and combined together as a group, make up an Entity. A simple example of one in JavaScript would be as follows:

export default class Position {

    constructor ({ x, y, z = 0 }) {
        this.x = x;
        this.y = y;
        this.z = z;
    }
}

The third concept, of course, is an Entity. Now, an Entity isn't actually anything more than an identifier that components reference in order for services to work (I'll cover services next). Basically, Entity/Component architecture prefers composition, so I have a create method that takes configuration, and creates components. In JavaScript, this looks like:

import Position from '../components/position';
import Size from '../components/size';
import Texture from '../components/texture';

export default class Character {

    static create (world, { position, size, texture }) {
        const character = new Character();

        world.entities.add(character);

        world.components.get(Position).set(character, new Position(position));
        world.components.get(Size).set(character, new Size(size));
        world.components.get(Texture).set(character, new Texture(texture));

        return character;
    }

    static delete (world, character) {
        world.entities.delete(character);
    }
}

You can see that Character is still a class, and is instantiated inside of the static create method, but this is simply to create a unique entry in memory that the services can correctly find all of the components of an entity.

 

The most complicated (it's not complicated) part of this architecture are the services. Services are just functions that you call that act upon entities. An example of a render services looks as follows:

import Position from '../components/position';
import Size from '../components/size';
import Texture from '../components/texture';

export default function render (world) {
    const { components, entities, state } = world;

    for (const entity of entities) {
        const position = components.get(Position).get(entity);
        const size = components.get(Size).get(entity);
        const texture = components.get(Texture).get(entity);

        if (!position || !size || !texture) {
            continue;
        }

        state.get('drawContext').drawImage(texture.data,
            texture.clip.x, texture.clip.y, texture.clip.width, texture.clip.height,
            position.x, position.y, size.width, size.height
        );
    }
}

As you can see, for each entity, it checks if that entity has the required components, which in this case are Position, Size, and Texture. If all three exist, it uses the data from those components to draw the entity onto our canvas, using the drawContext.

 

To all who are familiar with this architecture and OOP; does this seem right to you? Have you used Entity/Component architecture before? Do you prefer OOP?

Link to comment
Share on other sites

I've made some modifications to my concepts to improve performance.

 

The first is of entities; they are no longer each their own class; now they're just a module with a create method that instantiates an Entity; making all entities an instance of a single class allows the JS engine to optimize storing them in memory, and also simply means less classes.

import Entity from '../core/entity';

import Position from '../components/position';
import Size from '../components/size';
import Texture from '../components/texture';

export default {

    create (world, { position, size, texture }) {
        const character = new Entity();

        world.entities.add(character);

        world.components.get(Position).set(character, new Position(position));
        world.components.get(Size).set(character, new Size(size));
        world.components.get(Texture).set(character, new Texture(texture));

        return character;
    }
}

The next optimization is of services. You now instantiate a Service with a list of required components and a function that will be called if all required components exist for that entity. I also made it so services only take one entity at a time so you need to loop through all entities beforehand, which means looping over them far less frequently, increasing runtime performance.

import System from '../core/system';

import Position from '../components/position';
import Size from '../components/size';
import Texture from '../components/texture';

export default new System([ Position, Size, Texture ], (world, entity) => {
    const position = world.components.get(Position).get(entity);
    const size = world.components.get(Size).get(entity);
    const texture = world.components.get(Texture).get(entity);

    world.state.get('drawContext').drawImage(texture.data,
        texture.clip.x, texture.clip.y, texture.clip.width, texture.clip.height,
        position.x, position.y, size.width, size.height
    );
});

As you can see, the first argument is an array which contains the required components. You run a system by calling its run method, which will check for the existence of components of an entity, like so:

export default class System {

    constructor (mask = [ ], action = (() => null)) {
        this.mask = mask;
        this.action = action;
    }

    run (world, entity) {
        const { components } = world;

        if (this.mask.reduce((prev, next) => prev && components.get(next).has(entity), true)) {
            this.action(world, entity);
        }
    }
}

No modifications have been made to components, as they're still just data.

 

The next concept I want to explore is instead of having one component per character attribute (health, experience, magic, etc), finding some way to have a generic Attribute component, but I'm not sure how that will work quite yet.

Link to comment
Share on other sites

Thinking about it now, I think one component per attribute is the right way to go about it. It makes it super easy to generate a game from data. In other news, I've completely rewritten everything. I've turned it into a library that you can find here: https://github.com/eclipse-games/encosy -- the following are real, working examples, utilizing the library:

 

Component (Position)

import { Component } from '@eclipse-games/encosy'

export default new Component({
    x: Component.types.number,
    y: Component.types.number,
    z: Component.types.number
});

Entity (Character)

import { Entity } from '@eclipse-games/encosy';

import Position from '../components/position';
import Size from '../components/size';
import Texture from '../components/texture';

export default new Entity({
    position: Position,
    size: Size,
    texture: Texture
});

System (Render)

import { System } from '@eclipse-games/encosy';

import state from '../core/state';

import Position from '../components/position';
import Size from '../components/size';
import Texture from '../components/texture';

export default new System([ Position, Size, Texture ], (world, entity) => {
    const position = world.components.get(Position).get(entity);
    const size = world.components.get(Size).get(entity);
    const texture = world.components.get(Texture).get(entity);

    state.drawContext.drawImage(texture.data,
        texture.clip.x, texture.clip.y, texture.clip.width, texture.clip.height,
        position.x, position.y, size.width, size.height
    );
});

I think I got the concept fully down now, and am pretty pleased with the outcome of this weekend curiosity. Definitely let me know your thoughts on ECS vs OOP.

Link to comment
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
×
×
  • Create New...