Files
unicorn-utterances/content/blog/bevy-entity-component-system/index.md
2023-09-14 15:49:45 -04:00

14 KiB

title, description, published, authors, tags, attached, license
title description published authors tags attached license
Entity Component System: The Perfect Solution to Reusable Code The ECS pattern is used by many game engines to create stateless, reusable game logic. But how does it work? 2023-09-13
fennifith
rust
computer science
opinion
cc-by-nc-sa-4

An "Entity Component System" is a pattern followed by many game engines to create isolated systems of stateless, reusable game logic. They present a lot of advantages for all kinds of games: enabling out-of-the-box parallelization, universal state management, and unrestricted polymorphism. But how does this work? What makes it so special?

Note: While this post focuses on the conceptual aspects of ECS, it will reference a Rust game engine called Bevy in many of its examples.

I recommend checking out the Unofficial Bevy Cheat Book for a more in-depth introduction to the framework!

What is an Entity Component System?

  • Entity: An instance of an object with components in the game world.
  • Component: A piece of state or data that can be attached to an entity.
  • System: A function that operates on a set of entities and their components to perform some game logic.

ECS is characterized by using entities to represent game objects. Each entity has a unique ID, which ties it to a set of components that store its data.

There is no top-level class or trait that defines an entity! It's just an ID! Everything about it is defined by what components it has, and how systems interact with it.

Systems are the Game Loop!

In most games, you'll find a logical "game loop" or "tick loop" that handles logical events or user input and processes its effects in the world. These might involve things like physics calculations, collision detection, score tracking, win conditions - anything related to the game's state or data.

These are often (but not always!) kept separate from the visual aspects of the game, such as a render loop, which would process graphics updates.

Most games can run at multiple frame rates, which sometimes can even vary while the game is running. This needs to be separate from the game's logic to ensure that it always has reliable behavior, regardless of what frame rate it's running at.

Changing from 60 to 120 fps shouldn't also double your character's movement speed in the game!

A system is effectively a function that gets continuously invoked during the game. It would typically define a query for the components it uses, and perform some kind of operation as a result.

The ECS framework then manages the surrounding loop itself, and controls how and when each system is invoked.

So, you want to build a game?

I think it's best to show the benefits of the ECS approach in a practical example: Snake!

Snake is a well-known retro game in which the player moves a snake on a grid of square tiles, with the goal of eating apples in order to grow. The game ends if the snake runs into itself, or if all apples are eaten.

In an object-oriented approach, I might implement individual structs for each object in the game, as follows:

struct Snake {
	// keep an array of each segment in the snake
	segments: Vec<SnakeSegment>;
}

struct SnakeSegment {
	// each segment has an integer x/y position
	position: (i32, i32);
}

impl Snake {
	fn move(&mut self, direction: KeyCode) -> bool {
		// - move the tail of the snake to "head.position + direction"
		// - if the new position collides with the snake, return true
		//     (the game should end)
		// - else, return false
	}

	fn grow(&mut self) {
		// - add a new segment to the tail of the snake
	}
}

Now, this code might seem pretty straightforward. All of the snake's "state" lives within Snake and SnakeSegment. We'd likely implement a game loop to interact with it and call these methods where appropriate, e.g.

let mut player_snake = Snake {};
let mut apples: Vec<Apple> = Vec::new();

loop {
	let key_code = input.poll();

	// the snake should move once on each tick
	let is_collision = player_snake.move(key_code);

	if (is_collision) {
		// moving has caused the snake to run into itself,
		// so the player loses the game
		break;
	}

	// TODO: detect if the snake eats an apple
	if (is_apple_eaten) {
		// if the snake eats an apple, it should grow
		player_snake.grow();
	}

	if (apples.len() == 0) {
		// there are no apples left, so the player has won the game
		break;
	}
}

Why is this a bad example?

You might be noticing that this code is mixing together a lot of different functionality. snake.move() is directly tied to both keyboard input and the game's end condition.

This might be fine for a game where we know the full extent of its functionality ahead of time. However, in a lot of game development scenarios, this isn't the case. New features and mechanics frequently need to be added, changed, and iterated upon in ways that are rarely considered in the initial version.

For example, take this list of potential additions:

  • The snake should continue moving in the same direction after a key is released, until a new action is entered.
  • Lava pits! The game should end if the snake runs into a lava pit.
  • The snake can also eat speed powerups, which make it move faster.
  • The player should move the snake diagonally when pressing two directions at once.
  • After eating an apple, the snake gains a few seconds of invulnerability in which the game does not end for any reason.
  • The game has multiple snakes, which the player can control with different keybinds for multiplayer!

All of these ideas are possible, but they would become progressively more difficult to implement as the game grows in complexity. Suddenly your move() function is handling 20 different edge cases, your game loop is 2k lines long, and your codebase is an confusing web of logic that is inherently connected to everything else.

If you've ever felt backed into a wall by the way you've structured a project, you know that the solution can be time-consuming. Refactoring functions to use a different data structure, abstracting some functionality behind an extra interface to make way for new changes... What if there was a way to structure your code that could make it universally reusable from the start?

ECS to the Rescue!

Designing this behavior with components allows us to isolate these mechanics into individual pieces of state:

  • SnakeSegments { segments: Vec<SnakeSegment>; }
  • SnakeMovement { direction: (i32, i32); }
  • Invulnerability { ticks_left: u32; }
  • Speed { ticks_left: u32; }
  • Player { key_binds: Map<KeyCode, (i32, i32)>; }

Unlike OOP, these components are not grouped by what they are or what they implement. They are solely buckets of data that can be used by systems to achieve their functionality.

We can then write some systems to process parts of the game logic using these components.

/// Update the snake movement according to Player.key_binds,
/// whenever a key is pressed
fn system_player_input(
	input: Res<Input>,
	mut player_query: Query<(&Player, &mut SnakeMovement)>,
) {
	for (player, mut movement) in player_query.iter_mut() {
		for (key, value) in player.key_binds.iter() {
			if input.pressed(key) {
				movement.direction = value;
			}
		}
	}
}


/// Move any SnakeSegments on each tick, according to its
/// SnakeMovement value
fn system_move_snake(
	mut movement_query: Query<(&mut SnakeSegments, &SnakeMovement, Has<Speed>)>,
) {
	for (mut segments, movement, has_speed) in movement_query.iter_mut() {
		// - move the tail of the snake to "head.position + movement.direction"
		// - if has_speed is true, move another segment (to move by two segments at once)
	}
}

/// If the snake runs into itself, and does not have
/// invulnerability, end the game!
fn system_detect_collision(
	mut segments_query: Query<&SnakeSegments, Without<Invulnerability>>,
) {
	for segments in segments_query.iter() {
		// - find if any two segments have the same position in the snake
		// - if true, end the game!
	}
}

Finally, we can assemble our "player snake" by attaching any combination of the above components, depending on what functionality we want:

commands.spawn((
	SnakeSegments { ... },
	SnakeMovement { direction: (0, 0) },
	Player { key_binds: map![
		KeyCode::ArrowUp -> (0, 1),
		KeyCode::ArrowDown -> (0, -1),
		KeyCode::ArrowLeft -> (-1, 0),
		KeyCode::ArrowRight -> (1, 0),
	] },
));

This is far from a full implementation, but I hope this illustrates the advantages of ECS from an architectural standpoint. Each of these systems accesses only the components they need in order to run. Other components and systems can be added, modified, and replaced without affecting any other part of the game!

This might be a bit more verbose, but it provides a massive improvement for reusability in return.

Aren't these queries bad?

So far, every system we've written has defined a Query<Q, F> type for it to iterate over.

This seems like it would involve an iteration over every entity in the game - which could be absolutely massive! In games that can have many thousands of entities at once, how is this not a major performance bottleneck?

Well, most ECS frameworks are able to implement some neat tricks by knowing these queries ahead of time. In particular, Bevy already knows every query it needs, and is able to cache the results and update some parts of them when an entity is modified, rather than recomputing them every time they're used.

From the Bevy docs on Query Performance:

The following table compares the computational complexity of the various methods and operations, where:

  • n is the number of entities that match the query,
  • r is the number of elements in a combination,
  • k is the number of involved entities in the operation,
  • a is the number of archetypes in the world,
  • C is the binomial coefficient, used to count combinations. nCr is read as “n choose r” and is equivalent to the number of distinct unordered subsets of r elements that can be taken from a set of n elements.
Query operation Computational complexity
iter(_mut) O(n)
for_each(_mut), par_iter(_mut) O(n)
iter_many(_mut) O(k)
iter_combinations(_mut) O(nCr)
get(_mut) O(1)
(get_)many O(k)
(get_)many_mut O(k^2)
single(_mut), get_single(_mut) O(a)
Archetype based filtering (With, Without, Or) O(a)
Change detection filtering (Added, Changed) O(a + n)

At this point, most of the runtime is down to the iteration over the query itself, aside from a few cases where extra filters are involved.

This makes the existence of a query almost negligible, at the cost of some memory space and a few extra steps after a modification is made. Great!

Note: If you're familiar with relational databases, this is somewhat similar to the idea of materialized views, which store the results of frequent queries to avoid computing them every time they're needed.

However, there are still some cases where these queries don't work! In particular, considering relations between two entities can be a point of concern.

Entity Relations

Consider:

  • An orange tree has many oranges. An orange can be picked individually, or the tree can be cut down. If the tree is removed, the oranges should go with it!

  • Two boats can be tied together with a rope. Each end of the rope can only be tied to a single boat at a time!

This would be hard to implement with queries! Queries are great for filtering sets or types of components, but not so good for finding entities based on their relations to each other.

Many ECS frameworks implement some kind of Entity-Entity relation mechanism to satisfy these cases. These can be used to enforce One-to-Many (or One-to-One) constraints, and can be much better optimized using a graph structure than what would be possible with queries.

Defining Relations in Bevy

While Bevy has ongoing discussion about entity relations, there isn't a clear-cut way to implement them in the current release (at the time of writing).

Note: Bevy does support Parent-Child relations, which may satisfy some use cases.

In the meantime, the alternative practice seems to be storing any related entity IDs in a component attached to each entity...

#[derive(Component)]
struct Snake {
	// keep track of all segment entities belonging to the snake
	segments: Vec<Entity>;
}

// Each SnakeSegment is a separate entity that includes this component
#[derive(Component)]
struct SnakeSegment {
	position: (i32, i32);
}


fn system_move_snake(
	mut snake_query: Query<&mut Snake>,
	mut segment_query: Query<&mut SnakeSegment>,
) {
	for mut snake in snake_query.iter_mut() {
		for segment_id in snake.segments.iter() {
			let segment = segment_query.get(segment_id);
			// ...
		}
	}
}

This is a little suboptimal, as it leaves your code open to race conditions and unexpected behavior depending on how it's implemented. Remember that Bevy runs systems in parallel unless configured to do otherwise!

None of this provides any assurance that:

  • Each entity in Snake.segments has a SnakeSegment component
  • Any entity with SnakeSegment is referenced by one and only one Snake

However, a single query.get(id) is still a big runtime improvement compared to looping through a broad query to find the one entity you need.

Conclusion