# 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