Survival Shooter using Unity’s Entity Component System (revisited)

gamevanilla
Jan 2 · 5 min read

This post is written by David Pol, founder and code plumber at gamevanilla.

A few months ago, right after Unity’s new Entity Component System (ECS from now on) was announced and its first preview released at GDC, I decided to write a version of the official Survival Shooter tutorial project using this new system for learning purposes.

The ECS has evolved quite a bit since then, so I decided to spend some of my Christmas time to bring the project up to date with the current state of the art of the library.

So, without further ado, what can we do to improve our original project?

Getting rid of injection

In the original codebase, injection was the main mechanism used to iterate over our entities. At the time, it was the most promoted way to do so, but today it is considered a less-than-ideal design (this Unite LA presentation by Aria Bonczek is an excellent source on why that is the case) and will be removed in the future. These are the main issues with injection:

  • It performs a ton of non-obvious work behind the scenes.
  • The way in which it does that work is not very performant (mainly due to the internal use of reflection and GC allocations).

Injection was simple and convenient but, in hindsight, the fact alone that your compiler will warn you about the related structs and variables not being used/initialized (because everything happens implicitly) was actually a pretty big red flag in itself API-wise.

The first alternative to letting injection create an entity query automagically for us is to just do it directly ourselves and iterate over it via the convenient ForEach abstraction. For example, where we used to have this:

We would now have this:

Not particularly bad, is it? We manually create an entity query with the GetEntityQuery method, which needs to be called inside OnCreate, passing the required component types we are interested in using via ComponentType.ReadOnly<> (we could use typeof instead, but ReadOnly is slightly more efficient and symmetrical with the ReadWrite and Exclude variants).

Inside OnUpdate, we iterate over the entities within the group via the ForEach abstraction. Note how the system provides us with many different overloads of this method, so that we can easily retrieve different combinations of the appropriate entities, classic components and ECS components.

Pretty straightforward! It also looks significantly cleaner than injection. Can we still unlock some additional performance, though?

Introducing jobified systems

While we exclusively used systems that run on the main thread via ComponentSystem in the original project (and still need to do so with many systems that interact with classic MonoBehaviour-based components), we can already move some systems that only process ECS entities to jobified systems, which perform their work in a multithreaded way. Specifically, jobified systems using IJobForEach jobs (and its IJobForEachWithEntity companion) are the recommended way to go today for most use cases.

We also have what is known as chunk iteration as the ultimate, most powerful mechanism to iterate over entities, but it is a bit more complex to discuss in the context of an introductory ECS project so we will not use it here (I still recommend you learn about it, as it is the lower-level foundation the rest of the ECS is built upon).

So, how does this type of system look like in practice? Let’s look at a specific example named EnemyHealthSystem, which is responsible for updating the health of enemies that have been hit by the player:

Rather than deriving from ComponentSystem, jobified systems derive from JobComponentSystem instead. Similarly to their main-threaded counterpart, we need to override their OnUpdate method; only this time taking a JobHandle parameter (which you can use to establish dependency chains between different jobs). Inside this method, we schedule a custom job named EnemyHealthJob that derives from IJobForEachWithEntity, which performs the actual work of the system.

This job type takes a number of component types that you want to use as your entity “filters”. In this particular example, the job will iterate over enemy entities that are damaged. The maximum number of component types you can specify for the job is currently limited to 4, but more will be added in future versions of the library.

The job’s Execute method takes the entity, the job index and the specified component types as parameters and performs the following logic:

  • It resolves the actual damage over the enemy entity by updating its HealthData component.
  • It removes the DamagedData component to avoid processing the enemy entity more than once.
  • If the enemy’s health reaches a value of 0 or lower, it adds the DeadData component to it to kill the entity. A different system will take care of processing dead enemies.

Note how all this is done via a concurrent EntityCommandBuffer rather than your usual EntityManager (which does not work with jobs).

An interesting tidbit is that, in order to prevent adding the DeadData component to dead enemy entities more than once, we check if the entity in question has the component already by means of a ComponentDataFromEntity<DeadData> array, which gets passed into the job via the GetComponentDataFromEntity method.

What is even cooler here is that you could add a [BurstCompile] attribute to the EnemyHealthJob job and your code would be automatically compiled by the Burst compiler, which is a custom compiler built by Unity that accepts a high-performance-oriented subset of C# and makes use of auto-vectorization, optimized scheduling and better use of instruction-level parallelism to reduce data dependencies and stalls in the pipeline in the generated machine code. We cannot really use it in this particular job as of yet, because Burst still does not support jobs that use command buffers (unless you exclusively use them to destroy entities), but we will get there at some point in the near future.

I cannot stress enough how game-changing the whole approach to game development with Unity’s ECS, job system and Burst is. Attaining C++-like levels of performance (with the potential to do even better) without going through the pain of actually using the language? Writing highly-performant code while actually enjoying it at the same time? Well, sign me up!

FixedUpdate systems

One important thing to note is that, in the original project from Unity, there was some code running in FixedUpdate (namely, the player movement and the camera follow code). In order to run the equivalent ECS systems in FixedUpdate too, we need to add the DisableAutoCreation attribute to them and invoke their update method manually from FixedUpdate. An easy way to do so is via a helper MonoBehaviour:

It is expected that, as the ECS matures, we will have a more expressive way to define how systems are updated.

You can find the complete, updated Survival Shooter project in this GitHub repository. The project requires Unity 2019.1.0 (currently in beta) and the ECS-specific code lives in the ECS folder.

What next?

Even with all these updates, there is still a ton of code in the project that depends on classic, MonoBehaviour-based components (mainly everything related to rendering, physics and animations). While we could certainly write our own custom systems for these areas, in the context of this demo I would like to keep everything using the built-in ECS functionality.

Moving forward, more and more core engine systems will be available to use from the ECS. 2019 has just started and I cannot wait keep on updating our Survival Shooter project as that progress happens.

gamevanilla

Written by

We are an independent game studio specialized in crafting original games and game development middleware. You can find us at www.gamevanilla.com.