Say hello to 2B2D


2B2D is my attempt to answer the question, "what does it take to make a game engine?" which itself was born of the question, "how do GPUs work?" It's an academic exercise in which I try to understand game engine fundamentals by implementing some game engine fundamentals... poorly.

So the engine itself isn't great. It's passable for basic demos and short games where a few bugs are acceptable, but probably not much use beyond that. So rather than leave it at that (a game engine that is almost, but not quite, usable), I thought it might be worthwhile to attempt to share what I've learned and built in the process.

So to that end I wanted to start with an explanation of the fundamental concepts I used in 2B2D, almost all of which are brazenly copied from Bevy, an actually good engine that you probably should use instead.

Components

Let's start with the simplest concept, a Component. A component is just a piece of data about an object in the game. Let's take a humble bullet, fired maybe from an enemy or the player, for example. A bullet has may bits of data associated with it:

  • Position: Where is it in space?
  • Velocity: How fast is it moving? And in what direction?
  • Sprite: What does the bullet look like? Any animation?
  • Lifespan: If the bullet never hits anything, when should it despawn?
  • Target: Should it hurt the player, or enemies?
  • Hits: How many entities can this bullet pierce before it despawns?
  • Bullet: This component doesn't actually contain any data, but is actually just a tag that can be used to identify entities that are specifically a bullet.

Each of these bits of data is a separate component. Some bullets may have all of these bits of data, but is conceivable some may only have a subset. A bullet with no Lifespan component might live forever (assuming it never hits anything).

Importantly, components don't do anything. They are not logic and don't contain logic. They are just data. Think of them like sticky notes we track values on. We attach those notes to game objects, which are called:

Entities

An entity is actually just a collection of components, nothing more. Like components, entities do not have any logic themselves, they are just objects in the game world that contain those sticky notes we call components.

In 2B2D, when you spawn an entity, you spawn it with a set of components. But as the game runs, you can change the values within the components, remove components, or add new components.

But all of those operations require logic. So where's the logic? All logic is contained in:

Systems

All logic happens in systems. Systems in actuality are just functions that typically (but not always) run every frame (more on that in a bit). A typical system works like this:

  1. Query the game world for a list of entities to update. In 2B2D, a system queries for entities that contain specific components.
  2. For each entity returned, perform logic on their components as necessary, including updating existing values, adding / removing components.
  3. Spawn or Despawn entities as necessary via commands (more on commands later).

Using our real-world analogy of sticky notes, updating bullets might look like this:

  1. "Hey game world, give me a list of all entities that have a Position, Velocity, and Bullet sticky note." You're handed several stacks of sticky notes, each stack contains one Position, Velocity, and Bullet note.
  2. You methodically go through each stack, looking at the position and velocity notes, doing some math to figure out the new positions of those bullets now that time has passed. Maybe you also apply some drag to their velocities. You update each note with the newly calculated positions and velocities.
  3. As you calculate the new velocities, you make note of any velocities that fall below a certain speed. You've decided that any bullets going too slow should just be removed. When done, the game world collects the updated notes, and also takes note of the bullets you requested to be removed.

Any given entity may have several systems that act on that entity (or more specifically, act on that entity's components). For example, you may repeat these steps, but looking for any bullets with a lifespan note, calculating the new remaining lifespan, and despawning any that are too old. You may have a system that checks to see if the bullet hit anything, updates its Hits note, and removes the bullet if there are no hits remaining.

The core idea of separating your game objects into components, entities, and systems, is that it gives you incredible flexibility to separate these tiny fragments of logic into distinct units, each running independently of the others. It's a strategy to control complexity, and (in better written game engines) is extremely performant.

It also gives you the ability rapidly apply certain traits and logic to entities without the messiness of inheritance. Want that rock to fall to the ground? Add the Gravity, Position, Velocity, and Collider components. Boom! Assuming you have systems handle that, it falls to the ground.

In 2B2D, all systems are executed one-at-a-time, one after another. In better engines, systems that can execute in parallel generally do.

But what about those notes to remove the bullets? What's that about? Those are:

Commands

Commands are how you spawn new things or remove existing things. During your system's execution, you can issue commands to spawn or despawn. These commands are pushed into a queue and executed after all the other systems have run, at the end of the frame.

But why the deferral? Mostly it prevents weird things from happening when entities are created or removed half-way through running all the systems. When all the systems start running, the entities that exist at the start of the run are guaranteed to be the same entities at the end of the run. The values of their components may change, but the actual entities will still exist (or not exist).

In better game engines, this also allows greater parallelism, but as mentioned before, 2B2D is single-threaded.

So systems update entities, and commands add/remove entities. These things happen every frame, right? Well, that'd be pretty unusable if every system ran, every frame, every time. How would menus work? How would you pause the game? For this, we introduce:

States

At it's heart, a state is mostly just a list of things that should be running. 2B2D allows you to have multiple states active at any given time. But states are not just a set of systems. States also have little sub-states. Each state goes through three phases:

  • Enter: Executes exactly one frame when the state first becomes active.
  • Update: Executes every frame for as long as the state is active.
  • Exit: Executes exactly one frame when the state stops being active.

So why these sub-states? It's mostly useful for spawning and despawning entities associated with a particular state.

For example, you may have a "Main Menu" state, which has various systems scheduled to do the following:

  • Enter: Spawn the main menu graphics and start playing the main menu background music
  • Update: Respond to input events, update UI graphics as necessary. Exit the menu state and enter the next state when necessary.
  • Exit: Despawn all entities that were used for the menu.

Of course in the real world, things are rarely so simple. I often use transition states to animate between different states. You can also stack states. For example, you may have a "Game" state that means the game is in a level, but also a "GameActive" state that must be present for physics, controls, enemies, etc, to be active. Basically behaving as a way to pause the game without despawning and respawning everything.

So what about things that should live outside of state? Truly global things? For that, we use:

Resources

A Resource is global object, for which there is only ever one instance. For example, the Asset Server (which is a glorified hashmap of names to textures) is a resource. Your systems request whatever resources they need, and then can interact with those resources. Really, it's basically just a (likely unnecessary in the case of 2B2D) abstraction of a singleton.

You can have custom resources, too. For a single player game, it's not a bad idea to have some of the player's state (such as health) in a resource. In 2B2D, the audio player is a resource, as is player input.

On the topic of player health, if our fabled bullet manages to hit the player, how do we inform the player entity that it's been hit? Might I suggest:

Events

When it comes to inter-entity communication, it's a good idea to maintain some separation. Each enemy system probably shouldn't have code that modifies the player health. It's better that each of those systems emit an event, and a centralized system respond to those events to update the player's health.

In 2B2D, events are sent in one frame, and read in the next frame. As a quirk of this design, it's a good idea to have systems that read the events come before systems that send the events.

So with all these systems, states, etc, it ends up being a lot of configuration. Putting all this configuration in one file would make for a very long file, so instead 2B2D can be loosely organized into:

Plugins

Unlike in Bevy, plugins in 2B2D are not exactly first class citizens. Rather than a codified class or interface, plugins in 2B2D are more of a convention. Each "plugin" is really just a function that accepts the engine builder (an object to which you add your states and systems), and adds its own configuration as necessary. For example:

export default function addHud(builder:GameEngineBuilder) {
    builder.systems.enter(States.SPAWN_CAM, spawnCamera);
    builder.systems.enter(States.GAME, spawnHud);
    builder.systems.update(States.GAME, updateHealthItems);
}

This is a convenient way to bundle and organize systems and states into separate files, keeping things cleaner and easier to reason about.

In Conclusion

So that's 2B2D at a conceptual level in a nutshell. Again, I must acknowledge that almost every idea here is blatantly stolen from Bevy. Not only that, but these concepts are more robust, polished, and better implemented in Bevy. It's fair to say that 2B2D is a poor imitation. But imitation is how we learn.

2B2D was, for me, an education experience. My primary goal was to learn, not make the next AAA game engine. I answered the question, "how does one make a game engine." When we learn to speak, we don't start by composing poetry; we mimic the sounds we hear. When we learn an instrument, we don't start by writing entirely new songs; we start by playing other people's songs. Yet, when we learn a technology, for some reason we often think we need to dive right in the deep end, no floaties, having never swam, and expect to come up with an all new stroke. That's silly. I say sometimes it's OK to learn by re-implementing. You'll never understand a wheel better than if you reinvent it.

And 2B2D is one very reinvented wheel.

Leave a comment

Log in with itch.io to leave a comment.