Game Engine Design Study – Phage2D

Game engine architecture is a difficult design problem that presents a number of challenges. Over the years, I’ve studied other people’s engines and developed a few of my own, the first of which I want to share today.

This article will only examine the pros and cons of the overall architecture of Phage2D. It will not present specific implementations of rendering, physics, etc., but will rather discuss the integration of these low-level subsystems into the engine as a whole.

At its core, Phage2D uses the popular entity-component-system, with a few interesting modifications. Since the basic pattern has already been written about widely, I won’t cover it here. If you haven’t heard of ECS’s before, I suggest reading this excellent article first.

Entities

Entities, as in the conventional entity-component-system pattern, are essentially “bags” of Components. Each Entity also contains an Aspect, which is a list of all the types of Components contained by the Entity. Multiple Components which extend the same base Component will still be of the same ComponentType. Aspects are very useful since they allow us to express Component dependencies and filter for certain types of Entities. We’ll get more into this later.

Components

A Phage2D Component belongs to one of two basic types: DataComponent or LogicComponent. The third–WrapperComponent–is, in hindsight, a design mistake. I’ll omit it for now since it makes things unnecessarily complicated and doesn’t contribute much to the basic principles of the system.

DataComponents are strictly meant to hold data, either as public member variables or using getters and setters. DataComponents should not have methods which modify their contents, but can return mutated data (provided that the member variables stay untouched).

LogicComponents provide Entity-specific behavior. It is important to differentiate this from system behavior. As the name suggests, Entity-specific behaviors give an Entity its unique logical characteristics (e.g., when up arrow is pressed, Jack should jump). System behavior is common to all applicable Entities in the world, also called an EntitySystem (e.g., all Entities with the DataComponent for physics should fall, collide, and exhibit other physical phenomena). In this case, Jack is exhibiting both Entity-specific and system behavior.

LogicComponents realize their behavior mainly through DataComponents belonging to the same Entity. In order for a LogicComponent to be added to an Entity, the Entity must first already contain the DataComponents necessary for the LogicComponent to do its work. In other words, the Entity’s Aspect must encapsulate the dependency Aspect of the LogicComponent. Let’s have a look at an example:

/**
 * Stores the Texture of this Entity.
 */
public class TextureData extends DataComponent {
  public Texture texture;
  public TextureData(Texture texture) {
    this.texture = texture;
  }
}
/**
 * Stores the Position of this Entity.
 */
public class PositionData extends DataComponent {
  public float x;
  public float y;
  public PositionData(float x, float y) {
    this.x = x;
    this.y = y;
  }
}

Pretty basic stuff, just two basic DataComponents, one containing the position of the Entity, and the other containing a Texture to render. Now how would the rendering LogicComponent look?

/**
 * Renders a Texture at a certain position.
 */
public class RenderingLogic extends LogicComponent {
  private TextureData m_texture;
  private PositionData m_position;

  public RenderingLogic() {
    super(new Aspect(TypeManager.typeOf(TextureData.class), TypeManager.typeOf(PositionData.class)));
  }

  public void render(Renderer renderer) {
    // do rendering stuff
  }

  @Override
  public void loadDependencies() {
    m_texture = (TextureData) loadDependency(TypeManager.typeOf(TextureData.class));
    m_position = (PositionData) loadDependency(TypeManager.typeOf(PositionData.class));
  }
}

So what does this do? Well, the RenderingLogic Component needs a Texture to render and a position to render at. This requirement is expressed through the argument to the LogicComponent constructor; it takes an Aspect that specifies what DataComponents the Entity must already contain in order to add this LogicComponent. If we try to add a RenderingLogic Component to an Entity lacking a PositionData or TextureData Component, an error will be thrown. Let’s see it all put together:

Entity entity = new Entity();
entity.addComponent(new TextureData(new Texture(/*....*/)));
entity.addComponent(new PositionData(1, 2));
entity.addComponent(new RenderingLogic());

This should look pretty familiar to most conventional entity-component-systems. The main conceptual caveat is that the RenderingLogic Component must be added after the TextureData and PositionData Components, as specified by the dependencies.

AspectActivities

As an attentive reader, you may have noticed that we never specified what is calling the render function in RenderingLogic. Repeated actions executed over groups of similarly composed Entities (system behavior) are done by AspectActivities, which are commonly referred to as systems in conventional entity-component-systems. In Phage2D, EntitySystems actually hold all the Entities in a given world, and allow AspectActivities to add SystemListeners and respond to the adding and removing of Entities.

Let’s have a look at an AspectActivity that will render all Entities containing the RenderingLogic Component:

/**
 * Calls RenderingLogic in all Entities with a RenderingLogic Component.
 */
public class RenderingActivity extends AspectActivity {
  private List<Entity> m_entities = new ArrayList<Entity>();

  public RenderingActivity(EntitySystem system) {
    super(system, new Aspect(TypeManager.typeOf(RenderingLogic.class)));
  }

  @Override
  public void entityAdded(Entity entity) {
    m_entities.add(entity);
  }

  @Override
  public void entityRemoved(Entity entity) {
    m_entities.remove(entity);
  }

  public void render(Renderer renderer) {
    for (Entity entity : m_entities) {
      RenderingLogic rendering = (RenderingLogic) entity.getComponent(TypeManager.typeOf(RenderingLogic.class));
      rendering.render(renderer);
    }
  }
}

Similarly to the dependencies of LogicComponents, an AspectActivity operates on all Entities that include the Components specified in the Aspect passed to AspectActivity’s constructor. In this basic AspectActivity, we filter for Entities with a RenderingLogic Component, therefore subscribing to entityAdded and entityRemoved method calls on these Entities.

You may be wondering why the RenderingActivity doesn’t just render the Entities directly using the TextureData and PositionData, bypassing the RenderingLogic. This is illustrative of one of the main dilemmas of this design: determining where Entity-specific behavior ends and system behavior begins. In this case, we may want to create different kinds of rendering LogicComponents later that would all extend a common base class (e.g., one rendering text, one videos, etc.). Then, it would be clear that the general system behavior is that all renderable Entities should have the render method called on them, and the Entity-specific part begins when deciding how each unique Entity should render itself. The alternative would be to create a huge RenderingActivity class that switches behaviors based on the Entity it’s rendering (yuk!), or multiple smaller AspectActivities for each type of renderable Entity (e.g., TextRenderingActivity would render all Entities with a TextData Component).

One advantage of the LogicComponent approach would be that it is easier to opt out of rendering even when all the required DataComponents are there. If you had an Entity with a TextData Component, but didn’t want it to render, you could simply not add the TextRenderingLogic Component; in the other case, you would need some kind of a flag in the TextData Component telling the AspectActivity not to render it.

Putting It All Together

// holds all the Entities in our world
EntitySystem system = new EntitySystem();
// makes the RenderingActivity manage rendering in the EntitySystem we created
RenderingActivity rendering = new RenderingActivity(system);

// create the Entity we want to render
Entity entity = new Entity();
entity.addComponent(new TextureData(new Texture(/*....*/)));
entity.addComponent(new PositionData(1, 2));
entity.addComponent(new RenderingLogic());

// following two lines will also call entityAdded on the RenderingActivity, since the Entity matches the filter
system.addEntity(entity);
system.update(); // has separate method to process all addEntity and removeEntity calls, used to avoid concurrency issues

while (!/*close condition*/) {
  rendering.render(/*Renderer argument*/);
  // sleep until next frame
}

Although these examples have of course been very crude, and probably syntactically incorrect at times (since I haven’t actually tested this code…), I hope you understand the general concepts behind Phage2D and will use your discretion in determining what elements to include in you own game engines.

The Good, the Bad, and the Ugly

All implemented designs have some lessons that we can take away from it. Below, I attempt to objectively detail some of the benefits and flaws of Phage2D’s architecture. Please note that this list is almost certainly not exhaustive.

Good:

  • Clean separation of DataComponents and LogicComponents
  • Shallow dependency layers
  • New Components do not introduce new dependencies between old ones
  • No need for complex message-passing systems to communicate between Components

Bad:

  • Complex Entities made out of many small Components begin to become obscure in purpose
  • Sometimes ambiguous distinction between Entity-specific and system behavior

Ugly:

  • Lots of beginner design mistakes in the rest of Phage2D. Although it was my first big game engine, I still look back and cringe at some of my past choices (why did I invent WrapperComponents instead of leveraging polymorphism with DataComponents?!?).

Phage2D had its good moments, and even helped me win the “best game” award with FlipFlop at CodeDay NY 2014 (credits to Nick Hartunian for level design). Between my brother and I, the project totaled at over 40,000 lines (fleshed out with physics, multiplayer, and even behavior trees). In the end, though, I decided to restart with another game engine, Big Phage.


Posted

in

by

Tags:

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *