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.
- Make enemies move toward the player linearly
- Prevent enemies from bunching by using a flocking algorithm (covered in a future article)
- Spawn health kits which give health to the player upon colliding with them.