Skip to content

Lambda Engine Architecture

Mike edited this page Apr 28, 2017 · 4 revisions

A Lamba game is composed of four key elements: entities, components, behaviors, and systems.

  • Entities and Components define what exists within the game world
  • Systems and Behaviors define how those objects behave and interact

Entities

An Entity is composed of many Behaviors. This creates a high level description of what currently exists in the game world.

Example of creating entities:

entities.add(
    new Entity(
        new Renderable(playerGraphic),
        new KeyboardControls(UP_ARROW, LEFT_ARROW, DOWN_ARROW, RIGHT_ARROW),
        new CanPickupItems(),
        new HasScore()
    ),
    new Entity(
        new Renderable(yellowCoinGraphic),
        new PickableItem(PickupEffect.ADD_SCORE),
        new WorthPoints(1)
    ),
    new Entity(
        new Renderable(redCoinGraphic),
        new PickableItem(PickupEffect.ADD_SCORE),
        new WorthPoints(5)
    )
)

After setting up our initial entities, we have an easy to grasp picture of what exists in our game. There's a player that moves with the arrow keys and picks up items, and a yellow coin worth 1 point, and a red coin worth 5.

Because an Entity object can be freely composed out any Behaviors, we can easily create new game mechanics with little additional code. Suppose we wanted a second player, controlled by WASD keys, that has to run away from the first player because they themselves are a pickup worth 25 points.

entities.add(
    new Entity(
        new Renderable(player2Graphic),
        new KeyboardControls(W, A, S, D),
        new PickableItem(PickupEffect.ADD_SCORE),
        new WorthPoints(25)
    )
)

Components

A Component is a collection of data. While an Entity is a high level description of what's in the game, a Component represents the low level, indivisible groups of data.

For example, a Position component would consist of three floats representing an (x,y,z) location, or a Health component might contain an int for current HP and an int for max HP.

The data within a component is defined by a plain object with public static properties. Each property is defined as a DataField<> with a type, name, and default value.

Defining a component's data:

public class Position {
    public static final DataField<Float> X = DataField.withDefaultValue(0f);
    public static final DataField<Float> Y = DataField.withDefaultValue(0f);
    public static final DataField<Float> Z = DataField.withDefaultValue(0f);
}

public class Health {
    public static final DataField<Integer> HP = DataField.withDefaultValue(100f);
    public static final DataField<Integer> MAX_HP = DataField.withDefaultValue(100f);
}

To create a Component object, we reference the data descriptor class.

new Component(
    Position.class
)

We may also define any initial values during creation. This is done by giving pairs of DataField and value.

new Component(
    Position.class,
    Position.X, 50f,
    Position.Z, 100f
)

Because we've reduced our game's data to its smallest, indivisible form, we can easily share and reuse data across game objects. Now that we've defined what it means to "have health", we can add HP to any game object (player, car, tree, ui element) without dealing with complex object inheritance trees.

Behaviors

A Behavior's job is to create and name Components, as well as define relationships between Components in the same Entity.

Given Components represent such small, focused slices of data, any Entity within the game can easily be composed of an unmanageable amount. A hack and slash warrior might have components for position, graphic, control scheme, health, weapon, and armor. Our warrior's damage component might need to reference the base stat component, the weapon component, and any damage boost components. We can even have multiple components of the same type within the same entity. The Behavior class gives us a way to manage this complexity.

To create a Behavior, we extend the class and provide a set of NamedComponents as public static properties. These properties name each Component the behavior will create. Then in the constructor, we call defineComponent() with the name, data descriptor class, and Component object.

public class HasWeapon extends Behavior {
    public static final NamedComponent DURABILITY = new NamedComponent();
    public static final NamedComponent STATS = new NamedComponent();

    public HasWeapon() {
        defineComponent(DURABILITY, Health.class, new Component(Health.class));
        defineComponent(STATS, BattleStats.class, new Component(BattleStats.class));
    } 
}

Our weapon durability uses the same Health component as our warrior does. We can distinguish between two components of the same type within the same entity using the name defined in the behavior.

For example, entity.get(HasWeapon.DURABILITY) will return the Health component associated with the weapon's durability, and not the component associated with the warrior's life.

In some situations, a component needs to depend on another component within the entity. For example, in order to add a behavior that moves our player, we need a reference to the position where the player graphic is being rendered. Instead of giving a Component object directly, the defineComponent() method accepts a function that recieves a reference to the Entity including the Behavior and returns a Component.

Let's look at an example to clarify:

public class Gravity extends Behavior {
    public static final NamedComponent ACCELERATION = new NamedComponent();

    public Gravity() {
        defineComponent(ACCELERATION, Acceleration.class,
            (entity) ->
                new Component(Acceleration.class,
                    Acceleration.TARGET, entity.get(Renderable.POSITION),
                    Acceleration.Y, -9.8f
                )
        );
    } 
}

Here we define a Gravity behavior that creates an Acceleration component. The Acceleration needs to target the Position used to render the player graphic, so we give a function as the third argument to defineComponent. This function gets the parent Entity as an argument, and from there we can get the Position component associated with the Renderable behavior. Now that we have this information, we can properly create the Component object for our Acceleration.

The Entity given to the component definition function has a reference to all components belonging to the entity, regardless of the order the behaviors are included. This let's us freely add behaviors to an entity without worrying about dependencies.

For example, new Entity(new Gravity(), new Renderable()) is a valid entity definition, despite Gravity being added first while depending on Renderable.

Systems

A GameSystem represents a focused piece of game logic. The goal of a GameSystem is to implement an update() method that reads the current state of the game via existing Components and determines how those Components should change.

Let's look at implement a damage system. The first step is creating a new class that extends GameSystem.

public class Damage extends GameSystem {

    @Override
    public void update() {
        // Game logic goes here
    }
}

First we must identify the components we intend to work with:

public class Health {
    public static final DataField<Integer> HP = DataField.withDefaultValue(100f);
    public static final DataField<Integer> MAX_HP = DataField.withDefaultValue(100f);
}

public class DamageEvent {
    public static final DataField<Integer> DAMAGE = DataField.withDefaultValue(0);
    public static final DataField<Component> TARGET = DataField.withDefaultValue(new Component(Health.class));
}

Whenever a game event happens that results in damage, we can create a DamageEvent component that stores the target Health component we intend to reduce, and the damage amount. The game logic in our system is simple - for every DamageEvent component, reduce the target Health component and delete the DamageEvent.

The findAll() method of GameSystem takes a data descriptor class and returns all existing components defined using the given class. The set() and destroy() methods help us change components.

Using these methods, we can complete our system:

public class Damage extends GameSystem {

    @Override
    public void update() {
        findAll(DamageEvent.class).forEach(event -> {
            // The DamageEvent references the Health component it intends to damage
            Component health = event.get(DamageEvent.TARGET);

            // Set the health component's HP property to its new value:
            // the current hp value - the damage stored in the DamageEvent
            set(health, Health.HP, health.get(Health.HP) - event.get(DamageEvent.DAMAGE);

            // Clean up the DamageEvent so we don't repeat the damage
            destroy(event);
        });
    }
}

The Damage system isn't tied to a specific entity type or game context. By packaging the system with the Health and DamageEvent components, we can add health/damage functionality to any game.

Clone this wiki locally