Skip to content

One vs Many

In this series, I take games and rebuild them using Unity’s Entity Component System (ECS)

Play it in WebGL here.  For full source code of this project, checkout my github

Table of Contents

Overview

“One vs Many” is a type of game in which the player (the “one”)  is pitted against a horde of enemies (the “many”).  Usually the enemies are moving toward the player in order to damage him and the player prevents them from doing so by shooting at the enemies.  Since there are a large number of entities performing similar actions, this game is a perfect use case for Unity’s Entity Component System (ECS), allowing us to spawn thousands for enemies while maintaining high CPU performance.    

Entity Data

In this version of the game, we’ll have 3 different types of entities: the player, bullets, and enemies.

Players have a score, enemies are worth points, and bullets last only a certain amount of time before being deactivated  

public struct Player : IComponentData 
{
    public int score;
}

public struct Enemy : IComponentData
{
    public int points;
}

public struct Bullet : IComponentData
{
    public bool isActive;
    public float age;
} 

The bullets and enemies move in a direction at a certain speed, and the player and enemies both have health.  When a bullet hits an enemy or an enemy hits the player, damage is inflicted.

public struct Movement : IComponentData
{
    public float3 direction;
    public float speed;
}

public struct HealthInt : IComponentData
{
    public int curr;
    public int max;
}

public struct Damage : IComponentData
{
    public float value;
} 

Player Input System

The player can move around using a game controller or keyboard. We’ll write a system that samples player input on the main thread.

public class PlayerUpdateSystem : JobComponentSystem
    {        

        protected override JobHandle OnUpdate(JobHandle inputDeps)
        {
            // take input and move the player
            float h = Input.GetAxis("Horizontal");
            float v = Input.GetAxis("Vertical");
            
            float dt = World.Time.DeltaTime;
            float degenRate = healthDegenRate;

            JobHandle jobHandle = Entities
                .WithAll<Player>()
                .ForEach((Entity entity, int entityInQueryIndex, ref Movement movement, 
                ref Translation position, ref BoundingVolume vol, ref HealthFloat health) =>
            {
                movement.direction.x = h;
                movement.direction.y = v;
                movement.direction = math.normalizesafe(new float3(h, v, 0));
                movement.direction.z = 0;

                position.Value += movement.direction * movement.speed * dt;
                vol.volume.center = position.Value;

                // decrease health
                health.curr -= dt * degenRate;
            }).Schedule(inputDeps);

            return jobHandle;
        }
    } 

The player can also fire bullets by clicking the left mouse button. We’ll handle this in the same system

public class PlayerUpdateSystem : JobComponentSystem
{
    if (Input.GetMouseButtonDown(0)) // left click
    {
        // bullet was fired, finish the player job first
        jobHandle.Complete();
        float3 clickPos = Camera.main.ScreenToWorldPoint(Input.mousePosition);

        Translation playerPosition = GetComponentDataFromEntity<Translation>(true)[GameHandler.playerEntity];
        
        bool foundBullet = false;

       Entities.ForEach((Entity entity, int entityInQueryIndex, ref Bullet bullet,
           ref Movement movement, ref Translation position, ref BoundingVolume vol) =>
        {                    
            if (!foundBullet && !bullet.isActive)
            {
                bullet.isActive = true;
                vol.volume.center = position.Value = playerPosition.Value;
                movement.speed = 7;
                
                // calculate direction to where the player clicked
                movement.direction = math.normalizesafe(clickPos - playerPosition.Value);
                movement.direction.z = 0;

                foundBullet = true;
            }
        }).Run();
    }
} 

Bullet Movement System

After the player creates a bullet, we need to move it along the direction we gave it.  

[UpdateAfter(typeof(FlockingSystem))]
[UpdateAfter(typeof(PlayerUpdateSystem))]
public class MovementSystem : JobComponentSystem
{        
    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {            
        float dt = World.Time.DeltaTime;

        JobHandle jobHandle = Entities.ForEach((ref Bullet bullet, ref Movement movement, ref Translation position, ref BoundingVolume vol) =>
        {
            if (bullet.isActive)
            {
                // move the bullet along the direction that it was shot
                position.Value += movement.direction * movement.speed * dt;
                vol.volume.center = position.Value;
                bullet.age += dt;

                if (bullet.age >= 3)
                {
                    bullet.isActive = false;
                    bullet.age = 0;
                }
            }
            else
            {
                position.Value.x = 1000;
                movement.direction = float3.zero;
            }
        }).Schedule(inputDeps);
        jobHandle.Complete();
        return jobHandle;
    }
} 

Spawning Enemies

In a pure code approach (no prefabs) we can use this method to spawn our enemies

void CreateEnemies(int numEnemies)
{
    for (int i = 0; i < numEnemies; i++)
    {
        Entity e = entityManager.CreateEntity(
            typeof(Movement),
            typeof(Translation),
            typeof(LocalToWorld),
            typeof(RenderMesh),
            typeof(NonUniformScale),
            typeof(BoundingVolume),
            typeof(HealthFloat),
            typeof(HealthModifier),
            typeof(Enemy),
            typeof(WorldRenderBounds),
            typeof(RenderBounds),
            typeof(ChunkWorldRenderBounds)
        );

        entityManager.SetComponentData<Movement>(e, new Movement { speed = enemySpeed });
        entityManager.SetComponentData<Enemy>(e, new Enemy { points = (int)enemyHealth });
        InitHealth(e, enemyHealth, enemyHealth);
        InitHealthModifier(e, -10/*MaxHealth*/);
        InitRenderData(e, CreateRandomSpawnPosition(Vector2.zero, 15, 20), 0.5f, mesh, enemyMat);
    }
    
    void InitHealth(Entity e, float curr, float max)
    {
        entityManager.SetComponentData<HealthFloat>(e, new HealthFloat { curr = curr, max = max });
    }

    void InitHealthModifier(Entity e, float amount)
    {
        entityManager.SetComponentData<HealthModifier>(e, new HealthModifier { value = amount });
    }

    void InitRenderData(Entity e, float3 pos, float scale, Mesh mesh, Material mat)
    {
        if (entityManager.HasComponent<Scale>(e))
        {
            entityManager.SetComponentData<Scale>(e, new Scale { Value = scale });
        }
        else if (entityManager.HasComponent<NonUniformScale>(e))
        {
            entityManager.SetComponentData<NonUniformScale>(e, new NonUniformScale { Value = new float3(scale, scale, scale) });
        }
        entityManager.SetComponentData<Translation>(e, new Translation { Value = pos });
        entityManager.SetSharedComponentData<RenderMesh>(e, new RenderMesh { mesh = mesh, material = mat });

        Bounds b = new Bounds();
        b.center = pos;
        float halfScale = scale * 0.5f;
        b.extents = new float3(halfScale, halfScale, halfScale);
        entityManager.SetComponentData<BoundingVolume>(e, new BoundingVolume { volume = b });
    }
} 

We can use a coroutine in a MonoBehaviour to do the spawning

IEnumerator SpawnEnemies(float interval, int numEnemies)
{
    while (true)
    {
        CreateEnemies(numEnemies);
        yield return new WaitForSeconds(interval);
    }
} 

Conclusion and Things to Try

This covers many of the basics for a One vs Many game, but there’s a lot more that we can do with it. Here’s a few things you can try for yourself. As always, you can use the source code on my github for guidance.  

  1. Make enemies move toward the player linearly
  2. Prevent enemies from bunching by using a flocking algorithm (covered in a future article)
  3. Spawn health kits which give health to the player upon colliding with them.