# 4. Worked Example: Swarm Enemies
Part of [[DOTS]]. Previous: [[3. Entities Authoring Baking and ISystem]]. Next: [[5. Spatial Hashing Separation and Damage]].
**Goal:** 10,000 small enemies pour toward the player, and an aura around the player grinds them down. This is the "horde" fantasy from [[Level Up! (Review)]] (Level 11, enemies that threaten through numbers; the review flags the exact category names for verification).
> [!note] Verification status
> Uses documented Entities 6.5 APIs: `Baker<T>`, `GetEntity(GameObject, TransformUsageFlags)`, `EntityManager.Instantiate(Entity, int, Allocator)`, `SystemAPI.GetSingleton`, `EndSimulationEntityCommandBufferSystem.Singleton`, `[ChunkIndexInQuery]` and `Unity.Mathematics.Random`. **Code not compiled.**
## 4.1 Components
```csharp
// Assets/Scripts/DOTS/Swarm/SwarmComponents.cs
using Unity.Entities;
using Unity.Mathematics;
public struct EnemyTag : IComponentData { }
public struct SwarmAgent : IComponentData
{
public float3 Velocity;
}
public struct Health : IComponentData
{
public float Value;
}
/// Baked once, read by the spawn system.
public struct SwarmSpawner : IComponentData
{
public Entity Prefab;
public int Count;
public float SpawnRadius;
public uint Seed;
}
/// Tunables shared by all agents (a singleton).
public struct SwarmSettings : IComponentData
{
public float MaxSpeed;
public float Responsiveness; // how fast velocity turns toward desired
public float SeekWeight;
public float SeparationWeight; // used in part 5
public float SeparationRadius; // used in part 5
public float CellSize; // used in part 5 (>= SeparationRadius)
}
/// Written every frame from the GameObject player (part 6).
public struct PlayerTarget : IComponentData
{
public float3 Position;
public float AuraRadius;
public float AuraDps;
}
```
## 4.2 Authoring
**Enemy prefab:** make a small capsule or low-poly mesh with a URP Lit material and **no collider**, then add:
```csharp
// Assets/Scripts/DOTS/Swarm/SwarmEnemyAuthoring.cs
using Unity.Entities;
using UnityEngine;
public class SwarmEnemyAuthoring : MonoBehaviour
{
public float health = 3f;
}
public class SwarmEnemyBaker : Baker<SwarmEnemyAuthoring>
{
public override void Bake(SwarmEnemyAuthoring authoring)
{
Entity e = GetEntity(TransformUsageFlags.Dynamic);
AddComponent<EnemyTag>(e);
AddComponent(e, new SwarmAgent());
AddComponent(e, new Health { Value = authoring.health });
}
}
```
**Spawner:** an empty GameObject **inside the subscene** with:
```csharp
// Assets/Scripts/DOTS/Swarm/SwarmSpawnerAuthoring.cs
using Unity.Entities;
using Unity.Mathematics;
using UnityEngine;
public class SwarmSpawnerAuthoring : MonoBehaviour
{
public GameObject enemyPrefab;
public int count = 10000;
public float spawnRadius = 80f;
public uint seed = 1;
[Header("Steering")]
public float maxSpeed = 4f;
public float responsiveness = 4f;
public float seekWeight = 1f;
public float separationWeight = 2.5f;
public float separationRadius = 1.2f;
public float cellSize = 1.5f;
}
public class SwarmSpawnerBaker : Baker<SwarmSpawnerAuthoring>
{
public override void Bake(SwarmSpawnerAuthoring a)
{
Entity e = GetEntity(TransformUsageFlags.None);
AddComponent(e, new SwarmSpawner
{
// Baking a referenced prefab yields a prefab entity (excluded from normal queries).
Prefab = GetEntity(a.enemyPrefab, TransformUsageFlags.Dynamic),
Count = a.count,
SpawnRadius = a.spawnRadius,
Seed = math.max(1u, a.seed), // Random seed must be non-zero
});
AddComponent(e, new SwarmSettings
{
MaxSpeed = a.maxSpeed,
Responsiveness = a.responsiveness,
SeekWeight = a.seekWeight,
SeparationWeight = a.separationWeight,
SeparationRadius = a.separationRadius,
CellSize = math.max(a.cellSize, a.separationRadius),
});
}
}
```
## 4.3 Spawn system: batch instantiate once
```csharp
// Assets/Scripts/DOTS/Swarm/SwarmSpawnSystem.cs
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
[BurstCompile]
public partial struct SwarmSpawnSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<SwarmSpawner>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
SwarmSpawner spawner = SystemAPI.GetSingleton<SwarmSpawner>();
LocalTransform baseTransform = SystemAPI.GetComponent<LocalTransform>(spawner.Prefab);
// One structural change for all 10k: far cheaper than 10k Instantiate calls.
NativeArray<Entity> spawned = state.EntityManager.Instantiate(spawner.Prefab, spawner.Count, Allocator.Temp);
var rng = Random.CreateFromIndex(spawner.Seed);
foreach (Entity e in spawned)
{
// sqrt for uniform distribution over the disc
float2 p = rng.NextFloat2Direction() * spawner.SpawnRadius * math.sqrt(rng.NextFloat());
LocalTransform t = baseTransform;
t.Position = new float3(p.x, 0f, p.y);
SystemAPI.SetComponent(e, t);
}
spawned.Dispose();
state.Enabled = false; // one-shot. Re-enable for waves (exercise).
}
}
```
> [!tip] Object Pool? Not here
> GameObject games pool enemies because `Instantiate`/`Destroy` are expensive ([[Game Programming Patterns (Review)]], Object Pool). Batch-instantiating entities from a prefab entity is cheap and done in one structural change, so pooling is usually unnecessary in ECS.
## 4.4 Seek movement (simple version)
Part 5 replaces this with seek **plus separation**. Start simple and get 10k moving first.
```csharp
// Assets/Scripts/DOTS/Swarm/SwarmSeekSystem.cs
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
[BurstCompile]
public partial struct SwarmSeekSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<SwarmSettings>();
state.RequireForUpdate<PlayerTarget>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
new SeekJob
{
Settings = SystemAPI.GetSingleton<SwarmSettings>(),
PlayerPos = SystemAPI.GetSingleton<PlayerTarget>().Position,
DeltaTime = SystemAPI.Time.DeltaTime,
}.ScheduleParallel();
}
}
[BurstCompile]
[WithAll(typeof(EnemyTag))]
public partial struct SeekJob : IJobEntity
{
public SwarmSettings Settings;
public float3 PlayerPos;
public float DeltaTime;
void Execute(ref LocalTransform transform, ref SwarmAgent agent)
{
float3 to = PlayerPos - transform.Position;
to.y = 0f;
float3 desired = math.normalizesafe(to) * Settings.MaxSpeed;
agent.Velocity = math.lerp(agent.Velocity, desired, math.saturate(Settings.Responsiveness * DeltaTime));
transform.Position += agent.Velocity * DeltaTime;
if (math.lengthsq(agent.Velocity) > 0.01f)
transform.Rotation = quaternion.LookRotationSafe(math.normalize(agent.Velocity), math.up());
}
}
```
## 4.5 Aura damage with a parallel command buffer
```csharp
// Assets/Scripts/DOTS/Swarm/SwarmAuraDamageSystem.cs
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
[BurstCompile]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct SwarmAuraDamageSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<PlayerTarget>();
state.RequireForUpdate<EndSimulationEntityCommandBufferSystem.Singleton>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
PlayerTarget player = SystemAPI.GetSingleton<PlayerTarget>();
var ecb = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>()
.CreateCommandBuffer(state.WorldUnmanaged)
.AsParallelWriter();
new AuraDamageJob
{
PlayerPos = player.Position,
Radius2 = player.AuraRadius * player.AuraRadius,
Damage = player.AuraDps * SystemAPI.Time.DeltaTime,
Ecb = ecb,
}.ScheduleParallel();
}
}
[BurstCompile]
[WithAll(typeof(EnemyTag))]
public partial struct AuraDamageJob : IJobEntity
{
public float3 PlayerPos;
public float Radius2;
public float Damage;
public EntityCommandBuffer.ParallelWriter Ecb;
void Execute([ChunkIndexInQuery] int sortKey, Entity entity, in LocalTransform transform, ref Health health)
{
float3 d = transform.Position - PlayerPos;
d.y = 0f;
if (math.lengthsq(d) > Radius2) return;
health.Value -= Damage;
if (health.Value <= 0f)
Ecb.DestroyEntity(sortKey, entity); // played back at end of simulation
}
}
```
`[ChunkIndexInQuery]` gives a deterministic sort key, so playback order doesn't depend on thread timing.
## 4.6 Scene setup checklist
1. URP asset: **Forward+** rendering path, SRP Batcher on, Linear color space (Entities Graphics requirements).
2. Main scene: a player GameObject with a camera and `PlayerEcsBridge` (part 6). Without a `PlayerTarget`, the seek and damage systems don't run.
3. Subscene: the spawner GameObject, with `enemyPrefab` set to the enemy prefab asset.
4. Press Play and open **Window > Entities > Systems** to see timings, and the **Hierarchy** to browse entities.
## 4.7 Design levers (Level Up!)
| Lever | Effect on the player |
|---|---|
| `count` | Fear through density. Readability drops beyond a point |
| `maxSpeed` (vary it per enemy, see the exercise) | Fast "rushers" mixed with slow "tanks" make the horde readable |
| Aura radius vs separation | A bigger aura with tight separation shows a satisfying "eaten edge" (**feedback**) |
| Spawn ring radius | Gives warning time, a form of **telegraphing** |
## Exercise
Add a `SpeedVariance` to `SwarmSpawner` and write a per-entity `MoveSpeed` component at spawn. Then spawn in **waves** every 20 s by re-enabling the spawn system (`state.Enabled` from another system, or move to a timer component).
## Sources
- Baker overview: https://docs.unity3d.com/Packages/
[email protected]/manual/baking-baker-overview.html
- Instantiate / prefabs: https://docs.unity3d.com/Packages/
[email protected]/manual/baking-prefabs.html
- Entity command buffers: https://docs.unity3d.com/Packages/
[email protected]/manual/systems-entity-command-buffer-automatic-playback.html
- IJobEntity (`ChunkIndexInQuery`): https://docs.unity3d.com/Packages/
[email protected]/manual/iterating-data-ijobentity.html