Polymorphism is one of those OOP foundation that’s hard to shake off. It’s easy to do and it’s very intuitive. We use it to refactor code to make it more organized. We use it to manage different behaviors while maintaining only a single interface. We also use it to make full blown authoring editors that affects runtime behavior by complementing it with reflection.

However, it can’t be done in an environment where reference types are not allowed (Unity’s HPC#). It can be replicated in another way in ECS and that’s what is this post about.

OOP Version

Let’s say we have a framework of projectiles. Let’s also say that our game’s world is steam punk with magic. We want to be able to support both bullets and magic projectiles like fireball. Our OOP code might look like this:

public abstract class Projectile { // Common projectile properties protected Vector2 position; private int damage; // Each projectile type may implement its own movement public abstract void Move(); // Each projectile might have different effects on impact public abstract void OnImpact(); public int Damage { get { return this.damage; } } } public class Bullet : Projectile { private readonly Vector2 direction; private readonly float speed; public Bullet(Vector2 direction, float speed) { this.direction = direction.normalized; this.speed = speed; } public override void Move() { // Move by speed in straight line this.position += this.speed * Time.deltaTime * this.direction; } public override void OnImpact() { // Maybe just destroy the bullet here } } public class Fireball : Projectile { private readonly float initialVelocity; private readonly float angle; private readonly float gravity; private readonly float vX; private readonly float vYPart; private float polledTime; public Fireball(float initialVelocity, float angle, float gravity) { this.initialVelocity = initialVelocity; this.angle = angle; this.gravity = gravity; // Cache this.vX = this.initialVelocity * Mathf.Cos(this.angle); this.vYPart = this.initialVelocity * Mathf.Sin(this.angle); } public override void Move() { // Move by projectile motion // There are better ways to do this but just bare with me this.polledTime += Time.deltaTime; // Update X this.position.x += this.vX * Time.deltaTime; // Update Y float vY = this.vYPart - this.gravity * this.polledTime; this.position.y += vY * Time.deltaTime; } public override void OnImpact() { // Destroy the projectile then send a request to show a fireball impact particle effect // at the current position } }

Then we may implement the class that handles projectiles like this:

public class ProjectileManager { private readonly List<Projectile> projectiles = new List<Projectile>(); public void Add(Projectile projectile) { this.projectiles.Add(projectile); } public void Update() { for (int i = 0; i < this.projectiles.Count; ++i) { this.projectiles[i].Move(); } CheckForCollisions(); } private void CheckForCollisions() { // Let's just say a list of collisions exists foreach(Collision c in this.collisions) { // Apply damage if health component exists if(c.Health != null) { c.Health.Value -= c.Projectile.Damage; } // Execute custom on impact routines c.Projectile.OnImpact(); } } }

This contrived projectile system should be easy to follow. There are bullets that move in straight line at a certain direction and fireballs that move in projectile motion. Both of them can be handled by ProjectileManager as they inherit the Projectile base class.

ECS Version

In Unity’s pure ECS, classes can’t be used and inheritance is certainly not available either. But I consider that a good thing as a different mindset is needed in ECS. We must forget OOP when modelling game elements using ECS.

Let’s start with our projectile component:

public struct Projectile : IComponentData { public float2 position; public readonly int damage; public Projectile(float2 position, int damage) { this.position = position; this.damage = damage; } }

Our intent with this component is that any entity that has this is considered as a projectile. This component can be used by systems to filter only entities with such component and then execute general or common logic that can be applied to all projectiles. Think of it as code found in the base class.

Next are the components that represents the subclasses:

public struct Bullet : IComponentData { public readonly float2 direction; public readonly float speed; public Bullet(float2 direction, float speed) { this.direction = math.normalize(direction); this.speed = speed; } } public struct Fireball : IComponentData { public readonly float initialVelocity; public readonly float angle; public readonly float gravity; public readonly float vX; public readonly float vYPart; public float polledTime; public Fireball(float initialVelocity, float angle, float gravity) { this.initialVelocity = initialVelocity; this.angle = angle; this.gravity = gravity; // Cache this.vX = this.initialVelocity * math.cos(this.angle); this.vYPart = this.initialVelocity * math.sin(this.angle); this.polledTime = 0; } }

We just moved their data to their own components. To model a bullet projectile, we create an entity with Projectile and Bullet components. The same is true for a fireball projectile.

// Create a bullet Entity bullet = entityManager.CreateEntity(typeof(Projectile), typeof(Bullet)); // Create a fireball Entity fireball = entityManager.CreateEntity(typeof(Projectile), typeof(Fireball));

From here, we can then define the different movement systems:

// Using ComponentSystem here instead of JobComponentSystem so that it's // easier to understand public class BulletMoveSystem : ComponentSystem { private EntityQuery query; protected override void OnCreate() { this.query = GetEntityQuery(typeof(Projectile), typeof(Bullet)); } protected override void OnUpdate() { this.Entities.With(this.query).ForEach(delegate(ref Projectile projectile, ref Bullet bullet) { projectile.position = projectile.position + (bullet.speed * Time.deltaTime * bullet.direction); }); } } public class FireballMoveSystem : ComponentSystem { private EntityQuery query; protected override void OnCreate() { this.query = GetEntityQuery(typeof(Projectile), typeof(Fireball)); } protected override void OnUpdate() { this.Entities.With(this.query).ForEach(delegate(ref Projectile projectile, ref Fireball fireball) { // Move by projectile motion fireball.polledTime += Time.deltaTime; float2 newPosition = projectile.position; newPosition.x += fireball.vX * Time.deltaTime; float vY = fireball.vYPart - fireball.gravity * fireball.polledTime; newPosition.y += vY * Time.deltaTime; projectile.position = newPosition; }); } }

To handle the different “on impact” logic, a separate system could handle checking the collision detection then add a Collided tag component to entities that have collided. Separate systems would then handle damage dealing and “on impact” routines. Here’s the system that handles collision detection:

// Component that is added to entities that have collided public struct Collided : IComponentData { public readonly Entity other; // The other entity that we collided with } public class ProjectileCollisionDetectionSystem : ComponentSystem { private EntityQuery query; protected override void OnCreate() { this.query = GetEntityQuery(typeof(Projectile), ComponentType.Exclude<Collided>()); } protected override void OnUpdate() { // Adds Collided component to entities that have collided } }

The system that applies damage could look like this:

// Component representing health public struct Health : IComponentData { public int amount; } public class ProjectileDamageSystem : ComponentSystem { private EntityQuery query; protected override void OnCreate() { this.query = GetEntityQuery(typeof(Projectile), typeof(Collided)); } protected override void OnUpdate() { ComponentDataFromEntity<Health> allHealth = GetComponentDataFromEntity<Health>(); this.Entities.With(this.query).ForEach(delegate(ref Projectile projectile, ref Collided collided) { // Apply damage Health health = allHealth[collided.other]; health.amount -= projectile.damage; allHealth[collided.other] = health; // Modify }); } }

The “on impact” routines can also be implemented in their own systems:

public class BulletOnImpactSystem : ComponentSystem { private EntityQuery query; protected override void OnCreate() { this.query = GetEntityQuery(typeof(Projectile), typeof(Collided), typeof(Bullet)); } protected override void OnUpdate() { // Just destroy them this.EntityManager.DestroyEntity(this.query); } } public class FireballOnImpactSystem : ComponentSystem { private EntityQuery query; protected override void OnCreate() { this.query = GetEntityQuery(typeof(Projectile), typeof(Collided), typeof(Fireball)); } protected override void OnUpdate() { this.Entities.With(this.query).ForEach(delegate(ref Projectile projectile) { // Request fireball particle effect at projectile.position }); // Then destroy them this.EntityManager.DestroyEntity(this.query); } }

At this point, we have replicated the logic from OOP to its ECS version.

The more ideal ECS solution

From our initial refactoring, we can make changes to some components to make them more reusable.

One area that we can improve is movement. Instead of using Bullet component for straight direction movement, why not define it to its own component like StraightDirectionMovement. This way, we can reuse it to other elements in the game that requires this kind of movement. Before we can do this, we also need to remove the position property from Projectile and use a separate component representing it. This is what the new movement system will look like:

// Holds the projectile's position public struct Position : IComponentData { public float2 value; } public struct StraightDirectionMovement : IComponentData { public readonly float2 direction; public readonly float speed; public StraightDirectionMovement(float2 direction, float speed) { this.direction = math.normalize(direction); this.speed = speed; } } public class StraightDirectionMovementSystem : ComponentSystem { private EntityQuery query; protected override void OnCreate() { this.query = GetEntityQuery(typeof(Position), typeof(StraightDirectionMovement)); } protected override void OnUpdate() { this.Entities.With(this.query).ForEach(delegate(ref Position position, ref StraightDirectionMovement movement) { position.value = position.value + (movement.speed * Time.deltaTime * movement.direction); }); } }

In the same way, Fireball’s projectile motion movement could also turned into its own component. Say we call it ProjectileMotionMovement:

public struct ProjectileMotionMovement : IComponentData { public readonly float initialVelocity; public readonly float angle; public readonly float gravity; public readonly float vX; public readonly float vYPart; public float polledTime; public ProjectileMotionMovement(float initialVelocity, float angle, float gravity) { this.initialVelocity = initialVelocity; this.angle = angle; this.gravity = gravity; // Cache this.vX = this.initialVelocity * math.cos(this.angle); this.vYPart = this.initialVelocity * math.sin(this.angle); this.polledTime = 0; } } public class ProjectileMotionMovementSystem : ComponentSystem { private EntityQuery query; protected override void OnCreate() { this.query = GetEntityQuery(typeof(Position), typeof(ProjectileMotionMovement)); } protected override void OnUpdate() { this.Entities.With(this.query).ForEach(delegate(ref Position position, ref ProjectileMotionMovement movement) { // Move by projectile motion movement.polledTime += Time.deltaTime; float2 newPosition = position.value; newPosition.x += movement.vX * Time.deltaTime; float vY = movement.vYPart - movement.gravity * movement.polledTime; newPosition.y += vY * Time.deltaTime; position.value = newPosition; }); } }

Another dimension that we can improve is the routines on impact. Instead of having BulletOnImpactSystem which only works on entities with Bullet component, why not make it more reusable. Let’s use a component named DestroyOnCollision instead:

// A tag component that identifies an entity to be removed on collision public struct DestroyOnCollision : IComponentData { } public class DestroyOnCollisionSystem : ComponentSystem { private EntityQuery query; protected override void OnCreate() { this.query = GetEntityQuery(typeof(Collided), typeof(DestroyOnCollision)); } protected override void OnUpdate() { // Just destroy them this.EntityManager.DestroyEntity(this.query); } }

Requesting a particle effect like what happens for fireball’s impact could also be its own component and system. Let’s say we have a component named RequestParticleEffectOnCollision:

public struct RequestParticleEffectOnCollision : IComponentData { // Used to identify what particle effect to deploy public readonly int effectId; public RequestParticleEffectOnCollision(int effectId) { this.effectId = effectId; } } [UpdateBefore(typeof(DestroyOnCollisionSystem))] public class RequestParticleEffectOnCollisionSystem : ComponentSystem { private EntityQuery query; protected override void OnCreate() { this.query = GetEntityQuery(typeof(Position), typeof(Collided), typeof(RequestParticleEffectOnCollision)); } protected override void OnUpdate() { this.Entities.With(this.query).ForEach(delegate(ref Position position, ref RequestParticleEffectOnCollision effectRequest) { // Request the particle effect at position.value }); // Destruction of the entity will now be handled by DestroyOnCollisionSystem } }

With the systems above in place, modelling a bullet object in the game will now look like this:

Entity bullet = entityManager.CreateEntity(typeof(Position), typeof(Projectile), typeof(StraightDirectionMovement), typeof(DestroyOnCollision));

Notice how we have completely removed the concept of “Bullet”. A bullet is now composed of components that make up its behavior. It’s also the same for Fireball:

Entity fireball = entityManager.CreateEntity(typeof(Position), typeof(Projectile), typeof(ProjectileMotionMovement), typeof(RequestParticleEffectOnCollision), typeof(DestroyOnCollision));

Last thoughts

It’s obvious to see that turning OOP to ECS requires more code. All I can say to that is… it is what it is. Unfortunately, we’re just using C# constructs to kind of model ECS. There’s no such thing as an ECS aware programming language (yet) that will dramatically reduce all this code. I consider it a trade off. I get the benefit of highly modular and efficient code but at the expense of verbosity.

Honestly, verbosity is not a steep price to pay. I get to have code that could be as fast as it can be without switching to another more complex code like C++, which is verbose by itself.