-
Notifications
You must be signed in to change notification settings - Fork 1
Lambda Engine Architecture
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
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)
)
)
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.
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.
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.