Back to Blog
DEVLOG
Building our first game with Unity DOTS
Writing about the challenges of building a game using Unity DOTS and sharing some real code from our game

Building our first game with ECS
This is Abyssal Crow, yes that’s my code name, I know it’s silly but I find the esoteric nuance fun
Abyssal Titan is a two-person indie game studio. For the past year we have been working on Idle Broods. A cozy Ant colony simulator. But actually our journey began before that. Back in 2024, I(Abyssal Crow) pulled together a group of friends to work on a game, we had no funding so we all worked for free with the hope to get a return on our time investment after releasing a game on steam, at this time we had absolutely no idea of how to market a game but our team did have a couple of artists and programmers so I figured we had the skillset to ship a game on Steam and make money. WRONG!!
I can probably write a blog entry about the challenges of managing a group of friends later and how that went, but I want to keep this entry focused on our journey with Unity DOTS. After several iterations we landed on post-apocalyptic survivor, Vampire Survivors was very hot at the time so we figured that was a good idea. I did some research and discovered Vampire Survivors was technically quite complex and their solution was incredibly elegant, so like any lazy engineer I looked for a shortcut. So after a few Google searches on: “How to build a game like Vampire Survivors on Unity” I saw this brand new thing called Unity DOTS (Data-Oriented Technology Stack). For those that aren’t familiar with DOTS; It replaces the traditional GameObject oriented framework with ECS(Entity, Component, System), leverages the C# Job for multithreading and has first-class support for the Burst Compiler. This sounded like just the right tool for the game we wanted to build, we could spawn thousands upon thousands of enemies and projectiles and the game would run lightning fast. Thanks to online tutorials on Youtube like Turbo Makes Games and Code Monkey and a lot of trial and error DOTS clicked for me after like a year. Now, I don’t consider myself a DOTS expert by any stretch of the imagination but I’d say I’m proficient enough to think of features in ECS terms now, it is a very different paradigm compared to the GameObject industry standard.
By this time the group had fallen apart, we all had day-jobs and there wasn’t an end in sight for the project. It was chill for the most part, I understood people had lives and other things they wanted to do. But also some friendships had eroded because of the strain of working on a project together, I learned some hard lessons along the way. We are actually very proud of the game we built during this time and expect to release sometime in the future but at that time we had been working on the same game for over a year and the scope kept creeping in, so we decided to put that game on hold and build something we could ship fast with two people to really learn the ropes of selling indie games on Steam. Now I understand the dangers of scope creep and that market research can radically alter the success of a video game project from the beginning, it took us several years but through research and online courses we understood just how hard it is to make money off an indie game on Steam. The genre matters, the game has to be polished, the art needs to be attractive, and that’s not a rule but you’ll probably want to give your game a good chance to succeed right?
That’s when we came up with the idea for Idle Broods, a game that would allow you to have your ant colony living in your desktop number hundreds of thousands of ants. We thought it would be simpler, faster to ship, but we still had a long way to go. Fast forward another year of working on a second game and we see an end in sight. This time we are actually releasing a game on Steam! As I said, I’m not a DOTS expert. I’ve had to rebuild a lot of systems along the way. Partly because the game’s design and core loop changed a couple of times over the last year. That was another hard lesson: “Consolidate your design and game loop before you start to code”. But we also had to refactor because some things just weren’t optimal. Building a game with ECS can be daunting but it is satisfying when you have a bunch of entities running around on your screen. You made it through the rant…

Ant digging state machine - ants can be assigned to dig, when they find a diggable tile they dig at it and collect dirt, they drop the dirt at the stockpile and go back to digging. This is somewhat simplified but carries the general idea
So what do the systems that move the ants through this state machine look like:
// imports... namespace ECS.Systems { // Simple struct to hold the direction and coordinates of a diggable neighbor public struct DiggableNeighbor { public int2 Direction; public int2 Target; } [UpdateInGroup(typeof(SimulationSystemGroup))] [UpdateBefore(typeof(AntMoveSystem))] [BurstCompile] public partial struct DiggerAntSystem : ISystem { /// <summary> /// Find Digger ants that don't have a target and aren't digging. /// Calculate a new target and enable /// </summary> private EntityQuery _diggerAntQuery; /// This isn't ideal, Systems should be stateless private NativeArray<Random> _threadRandoms; public void OnCreate(ref SystemState state) { state.RequireForUpdate<EndSimulationEntityCommandBufferSystem.Singleton>(); state.RequireForUpdate<FlowFields>(); state.RequireForUpdate<Tilemap>(); _threadRandoms = new NativeArray<Random>( JobsUtility.MaxJobThreadCount, Allocator.Persistent); RandomUtils.InitializeThreadRandoms(_threadRandoms, 652763, 17); // Ants assigned to digging that aren't digging, hauling, moving or resting _diggerAntQuery = new EntityQueryBuilder(Allocator.Temp) .WithAll<AnimationIndexOverride, AntTag, Assignment, LocalTransform>() .WithDisabled<Digging, Hauling, Heading, Resting>() .Build(state.EntityManager); _diggerAntQuery.SetSharedComponentFilter(new Assignment { Value = AssignmentType.Dig }); } public void OnUpdate(ref SystemState state) { // The tilemap is a grid but we store it as flattened array var tilemap = SystemAPI.GetSingleton<Tilemap>(); var flowFields = SystemAPI.GetSingleton<FlowFields>(); var neighbors = new NativeArray<int2>(8, Allocator.TempJob); TilemapUtils.PopulateNeighbors(neighbors); // Get the built-in system that handles batching at the end of the simulation var ecbSingleton = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>(); // Create the thread-safe writer var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged).AsParallelWriter(); var handle = new DiggerAntJob { DiggerEntityMap = tilemap.DiggerEntityMap.AsParallelWriter(), DiggingFlowField = flowFields.DiggingVectorField, DiggingIntegrationField = flowFields.DiggingIntegrationField, ECB = ecb, MapHeight = TilemapConstants.Height, MapWidth = TilemapConstants.Width, Neighbors = neighbors, TileStates = tilemap.TileStates, ThreadRandoms = _threadRandoms, }.ScheduleParallel(_diggerAntQuery, state.Dependency); // In the absence of a Complete() handle ECS will resolve the dependencies automatically and run in parallel. // Dependent systems wait until completion to run state.Dependency = handle; } [BurstCompile] public partial struct DiggerAntJob : IJobEntity { public NativeParallelMultiHashMap<int, Entity>.ParallelWriter DiggerEntityMap; [ReadOnly] public NativeArray<int2> DiggingFlowField; [ReadOnly] public NativeArray<ushort> DiggingIntegrationField; public EntityCommandBuffer.ParallelWriter ECB; public int MapHeight; public int MapWidth; [DeallocateOnJobCompletion] [ReadOnly] public NativeArray<int2> Neighbors; [ReadOnly] public NativeArray<byte> TileStates; [NativeSetThreadIndex] private int _threadIndex; [NativeDisableContainerSafetyRestriction] public NativeArray<Random> ThreadRandoms; public void Execute( ref AnimationIndexOverride animationIndexOverride, [ChunkIndexInQuery] int chunkIndex, ref Digging digging, ref Heading heading, EnabledRefRW<Digging> isDiggingEnabled, EnabledRefRW<Heading> isHeadingEnabled, EnabledRefRW<Resting> isRestingEnabled, in LocalTransform localTransform, ref Resting resting, Entity entity) { var random = ThreadRandoms[_threadIndex]; // 20% chance to just rest regardless of where the ant is standing if (random.NextInt(0, 10) > 1) { // Where is the ant currently standing in grid coordinates? var currentX = (int)math.round(localTransform.Position.x); var currentY = (int)math.round(-localTransform.Position.y); var currentIndex = TilemapUtils.GetFlattenedIndex(currentX, currentY); var currentTileState = TileStates[currentIndex]; // If standing on a diggable tile, dig for a random duration // and finish the iteration(return) if (currentTileState == (byte)TileState.Filled) { animationIndexOverride.Value = (int)AntAnimationIndex.Dig; var duration = random.NextFloat(1.5f, 3.5f); digging.Duration = duration; isDiggingEnabled.ValueRW = true; DiggerEntityMap.Add(currentIndex, entity); return; } // If not standing on a diggable tile, then check the flow field // Read the Flow Field arrow for this tile var flowDirection = DiggingFlowField[currentIndex]; var targetX = currentX + flowDirection.x; var targetY = currentY + flowDirection.y; // Debug.Log($"Flow direction: {flowDirection}"); // If the flow field is not 0,0, then there is still a path towards a diggable tile // set the heading with the flow field direction if (!flowDirection.Equals(int2.zero)) { animationIndexOverride.Value = (int)AntAnimationIndex.Walk; // Convert grid direction to a world direction (invert Y) heading.Direction = new int2(flowDirection.x, -flowDirection.y); heading.Target = new int2(targetX, -targetY); isHeadingEnabled.ValueRW = true; } else { // If the flow field is 0,0, we are standing next to a diggable tile or there is no path // to a diggable tile. // Debug.Log("Flow field is 0"); // WE REACHED THE END OF THE FLOW FIELD. // Check the 8 adjacent neighbors for a dirt tile. We wouldn't need to check // for the tiles that we just walked through, but it's easier to check all neighbors // than to hold the backtracking state var diggableNeighbors = new NativeList<DiggableNeighbor>(7, Allocator.TempJob); for (var i = 0; i < Neighbors.Length; i++) { var nx = currentX + Neighbors[i].x; var ny = currentY + Neighbors[i].y; // Prevent out-of-bounds if (nx < 0 || nx >= MapWidth || ny < 0 || ny >= MapHeight) continue; var nIndex = TilemapUtils.GetFlattenedIndex(nx, ny); if (TileStates[nIndex] != (byte)TileState.Filled) continue; diggableNeighbors.Add(new DiggableNeighbor { Direction = new int2(Neighbors[i].x, Neighbors[i].y), Target = new int2(nx, -ny) }); } // If there is a diggable neighbor, move to the dirt and start digging. if (!diggableNeighbors.IsEmpty) { // Set animation index to walking animationIndexOverride.Value = (int)AntAnimationIndex.Walk; // Select a diggable neighbor at random var diggableNeighbor = diggableNeighbors[random.NextInt(0, diggableNeighbors.Length)]; // Convert grid direction to a world direction (invert Y) heading.Direction = diggableNeighbor.Direction; heading.Target = diggableNeighbor.Target; isHeadingEnabled.ValueRW = true; } else { // There is no path to a diggable tile. The flow field is 0, and there is no dirt in any adjacent tiles. // Use ECB to revert the ant to Idle assignment. // We should avoid ECB's when possible, in this case it's preferable // since we can run this in parallel and also change the shared Assignment component // value. The alternative is to convert the assignment to multiple enableable components if (DiggingIntegrationField[currentIndex] == ushort.MaxValue) { // DEAD END / NO DIRT LEFT // The BFS algorithm confirmed there is absolutely no path. ECB.SetSharedComponent(chunkIndex, entity, new Assignment { Value = AssignmentType.Idle }); animationIndexOverride.Value = (int)AntAnimationIndex.Idle; resting.Duration = random.NextFloat(1.5f, 3.5f); isRestingEnabled.ValueRW = true; } else { // STALE MAP // Rest for a brief moment and wait for the Flow Field to update! animationIndexOverride.Value = (int)AntAnimationIndex.Idle; resting.Duration = random.NextFloat(0.5f, 1.5f); // Short rest isRestingEnabled.ValueRW = true; } } diggableNeighbors.Dispose(); } } else { // Randomly resting animationIndexOverride.Value = (int)AntAnimationIndex.Idle; resting.Duration = random.NextFloat(1.5f, 3.5f); isRestingEnabled.ValueRW = true; } ThreadRandoms[_threadIndex] = random; } } public void OnDestroy(ref SystemState state) { _diggerAntQuery.Dispose(); _threadRandoms.Dispose(); } } }
// imports... namespace ECS.Systems { // Simple struct to hold the direction and coordinates of a diggable neighbor public struct DiggableNeighbor { public int2 Direction; public int2 Target; } [UpdateInGroup(typeof(SimulationSystemGroup))] [UpdateBefore(typeof(AntMoveSystem))] [BurstCompile] public partial struct DiggerAntSystem : ISystem { /// <summary> /// Find Digger ants that don't have a target and aren't digging. /// Calculate a new target and enable /// </summary> private EntityQuery _diggerAntQuery; /// This isn't ideal, Systems should be stateless private NativeArray<Random> _threadRandoms; public void OnCreate(ref SystemState state) { state.RequireForUpdate<EndSimulationEntityCommandBufferSystem.Singleton>(); state.RequireForUpdate<FlowFields>(); state.RequireForUpdate<Tilemap>(); _threadRandoms = new NativeArray<Random>( JobsUtility.MaxJobThreadCount, Allocator.Persistent); RandomUtils.InitializeThreadRandoms(_threadRandoms, 652763, 17); // Ants assigned to digging that aren't digging, hauling, moving or resting _diggerAntQuery = new EntityQueryBuilder(Allocator.Temp) .WithAll<AnimationIndexOverride, AntTag, Assignment, LocalTransform>() .WithDisabled<Digging, Hauling, Heading, Resting>() .Build(state.EntityManager); _diggerAntQuery.SetSharedComponentFilter(new Assignment { Value = AssignmentType.Dig }); } public void OnUpdate(ref SystemState state) { // The tilemap is a grid but we store it as flattened array var tilemap = SystemAPI.GetSingleton<Tilemap>(); var flowFields = SystemAPI.GetSingleton<FlowFields>(); var neighbors = new NativeArray<int2>(8, Allocator.TempJob); TilemapUtils.PopulateNeighbors(neighbors); // Get the built-in system that handles batching at the end of the simulation var ecbSingleton = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>(); // Create the thread-safe writer var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged).AsParallelWriter(); var handle = new DiggerAntJob { DiggerEntityMap = tilemap.DiggerEntityMap.AsParallelWriter(), DiggingFlowField = flowFields.DiggingVectorField, DiggingIntegrationField = flowFields.DiggingIntegrationField, ECB = ecb, MapHeight = TilemapConstants.Height, MapWidth = TilemapConstants.Width, Neighbors = neighbors, TileStates = tilemap.TileStates, ThreadRandoms = _threadRandoms, }.ScheduleParallel(_diggerAntQuery, state.Dependency); // In the absence of a Complete() handle ECS will resolve the dependencies automatically and run in parallel. // Dependent systems wait until completion to run state.Dependency = handle; } [BurstCompile] public partial struct DiggerAntJob : IJobEntity { public NativeParallelMultiHashMap<int, Entity>.ParallelWriter DiggerEntityMap; [ReadOnly] public NativeArray<int2> DiggingFlowField; [ReadOnly] public NativeArray<ushort> DiggingIntegrationField; public EntityCommandBuffer.ParallelWriter ECB; public int MapHeight; public int MapWidth; [DeallocateOnJobCompletion] [ReadOnly] public NativeArray<int2> Neighbors; [ReadOnly] public NativeArray<byte> TileStates; [NativeSetThreadIndex] private int _threadIndex; [NativeDisableContainerSafetyRestriction] public NativeArray<Random> ThreadRandoms; public void Execute( ref AnimationIndexOverride animationIndexOverride, [ChunkIndexInQuery] int chunkIndex, ref Digging digging, ref Heading heading, EnabledRefRW<Digging> isDiggingEnabled, EnabledRefRW<Heading> isHeadingEnabled, EnabledRefRW<Resting> isRestingEnabled, in LocalTransform localTransform, ref Resting resting, Entity entity) { var random = ThreadRandoms[_threadIndex]; // 20% chance to just rest regardless of where the ant is standing if (random.NextInt(0, 10) > 1) { // Where is the ant currently standing in grid coordinates? var currentX = (int)math.round(localTransform.Position.x); var currentY = (int)math.round(-localTransform.Position.y); var currentIndex = TilemapUtils.GetFlattenedIndex(currentX, currentY); var currentTileState = TileStates[currentIndex]; // If standing on a diggable tile, dig for a random duration // and finish the iteration(return) if (currentTileState == (byte)TileState.Filled) { animationIndexOverride.Value = (int)AntAnimationIndex.Dig; var duration = random.NextFloat(1.5f, 3.5f); digging.Duration = duration; isDiggingEnabled.ValueRW = true; DiggerEntityMap.Add(currentIndex, entity); return; } // If not standing on a diggable tile, then check the flow field // Read the Flow Field arrow for this tile var flowDirection = DiggingFlowField[currentIndex]; var targetX = currentX + flowDirection.x; var targetY = currentY + flowDirection.y; // Debug.Log($"Flow direction: {flowDirection}"); // If the flow field is not 0,0, then there is still a path towards a diggable tile // set the heading with the flow field direction if (!flowDirection.Equals(int2.zero)) { animationIndexOverride.Value = (int)AntAnimationIndex.Walk; // Convert grid direction to a world direction (invert Y) heading.Direction = new int2(flowDirection.x, -flowDirection.y); heading.Target = new int2(targetX, -targetY); isHeadingEnabled.ValueRW = true; } else { // If the flow field is 0,0, we are standing next to a diggable tile or there is no path // to a diggable tile. // Debug.Log("Flow field is 0"); // WE REACHED THE END OF THE FLOW FIELD. // Check the 8 adjacent neighbors for a dirt tile. We wouldn't need to check // for the tiles that we just walked through, but it's easier to check all neighbors // than to hold the backtracking state var diggableNeighbors = new NativeList<DiggableNeighbor>(7, Allocator.TempJob); for (var i = 0; i < Neighbors.Length; i++) { var nx = currentX + Neighbors[i].x; var ny = currentY + Neighbors[i].y; // Prevent out-of-bounds if (nx < 0 || nx >= MapWidth || ny < 0 || ny >= MapHeight) continue; var nIndex = TilemapUtils.GetFlattenedIndex(nx, ny); if (TileStates[nIndex] != (byte)TileState.Filled) continue; diggableNeighbors.Add(new DiggableNeighbor { Direction = new int2(Neighbors[i].x, Neighbors[i].y), Target = new int2(nx, -ny) }); } // If there is a diggable neighbor, move to the dirt and start digging. if (!diggableNeighbors.IsEmpty) { // Set animation index to walking animationIndexOverride.Value = (int)AntAnimationIndex.Walk; // Select a diggable neighbor at random var diggableNeighbor = diggableNeighbors[random.NextInt(0, diggableNeighbors.Length)]; // Convert grid direction to a world direction (invert Y) heading.Direction = diggableNeighbor.Direction; heading.Target = diggableNeighbor.Target; isHeadingEnabled.ValueRW = true; } else { // There is no path to a diggable tile. The flow field is 0, and there is no dirt in any adjacent tiles. // Use ECB to revert the ant to Idle assignment. // We should avoid ECB's when possible, in this case it's preferable // since we can run this in parallel and also change the shared Assignment component // value. The alternative is to convert the assignment to multiple enableable components if (DiggingIntegrationField[currentIndex] == ushort.MaxValue) { // DEAD END / NO DIRT LEFT // The BFS algorithm confirmed there is absolutely no path. ECB.SetSharedComponent(chunkIndex, entity, new Assignment { Value = AssignmentType.Idle }); animationIndexOverride.Value = (int)AntAnimationIndex.Idle; resting.Duration = random.NextFloat(1.5f, 3.5f); isRestingEnabled.ValueRW = true; } else { // STALE MAP // Rest for a brief moment and wait for the Flow Field to update! animationIndexOverride.Value = (int)AntAnimationIndex.Idle; resting.Duration = random.NextFloat(0.5f, 1.5f); // Short rest isRestingEnabled.ValueRW = true; } } diggableNeighbors.Dispose(); } } else { // Randomly resting animationIndexOverride.Value = (int)AntAnimationIndex.Idle; resting.Duration = random.NextFloat(1.5f, 3.5f); isRestingEnabled.ValueRW = true; } ThreadRandoms[_threadIndex] = random; } } public void OnDestroy(ref SystemState state) { _diggerAntQuery.Dispose(); _threadRandoms.Dispose(); } } }
// imports... namespace ECS.Systems { // Simple struct to hold the direction and coordinates of a diggable neighbor public struct DiggableNeighbor { public int2 Direction; public int2 Target; } [UpdateInGroup(typeof(SimulationSystemGroup))] [UpdateBefore(typeof(AntMoveSystem))] [BurstCompile] public partial struct DiggerAntSystem : ISystem { /// <summary> /// Find Digger ants that don't have a target and aren't digging. /// Calculate a new target and enable /// </summary> private EntityQuery _diggerAntQuery; /// This isn't ideal, Systems should be stateless private NativeArray<Random> _threadRandoms; public void OnCreate(ref SystemState state) { state.RequireForUpdate<EndSimulationEntityCommandBufferSystem.Singleton>(); state.RequireForUpdate<FlowFields>(); state.RequireForUpdate<Tilemap>(); _threadRandoms = new NativeArray<Random>( JobsUtility.MaxJobThreadCount, Allocator.Persistent); RandomUtils.InitializeThreadRandoms(_threadRandoms, 652763, 17); // Ants assigned to digging that aren't digging, hauling, moving or resting _diggerAntQuery = new EntityQueryBuilder(Allocator.Temp) .WithAll<AnimationIndexOverride, AntTag, Assignment, LocalTransform>() .WithDisabled<Digging, Hauling, Heading, Resting>() .Build(state.EntityManager); _diggerAntQuery.SetSharedComponentFilter(new Assignment { Value = AssignmentType.Dig }); } public void OnUpdate(ref SystemState state) { // The tilemap is a grid but we store it as flattened array var tilemap = SystemAPI.GetSingleton<Tilemap>(); var flowFields = SystemAPI.GetSingleton<FlowFields>(); var neighbors = new NativeArray<int2>(8, Allocator.TempJob); TilemapUtils.PopulateNeighbors(neighbors); // Get the built-in system that handles batching at the end of the simulation var ecbSingleton = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>(); // Create the thread-safe writer var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged).AsParallelWriter(); var handle = new DiggerAntJob { DiggerEntityMap = tilemap.DiggerEntityMap.AsParallelWriter(), DiggingFlowField = flowFields.DiggingVectorField, DiggingIntegrationField = flowFields.DiggingIntegrationField, ECB = ecb, MapHeight = TilemapConstants.Height, MapWidth = TilemapConstants.Width, Neighbors = neighbors, TileStates = tilemap.TileStates, ThreadRandoms = _threadRandoms, }.ScheduleParallel(_diggerAntQuery, state.Dependency); // In the absence of a Complete() handle ECS will resolve the dependencies automatically and run in parallel. // Dependent systems wait until completion to run state.Dependency = handle; } [BurstCompile] public partial struct DiggerAntJob : IJobEntity { public NativeParallelMultiHashMap<int, Entity>.ParallelWriter DiggerEntityMap; [ReadOnly] public NativeArray<int2> DiggingFlowField; [ReadOnly] public NativeArray<ushort> DiggingIntegrationField; public EntityCommandBuffer.ParallelWriter ECB; public int MapHeight; public int MapWidth; [DeallocateOnJobCompletion] [ReadOnly] public NativeArray<int2> Neighbors; [ReadOnly] public NativeArray<byte> TileStates; [NativeSetThreadIndex] private int _threadIndex; [NativeDisableContainerSafetyRestriction] public NativeArray<Random> ThreadRandoms; public void Execute( ref AnimationIndexOverride animationIndexOverride, [ChunkIndexInQuery] int chunkIndex, ref Digging digging, ref Heading heading, EnabledRefRW<Digging> isDiggingEnabled, EnabledRefRW<Heading> isHeadingEnabled, EnabledRefRW<Resting> isRestingEnabled, in LocalTransform localTransform, ref Resting resting, Entity entity) { var random = ThreadRandoms[_threadIndex]; // 20% chance to just rest regardless of where the ant is standing if (random.NextInt(0, 10) > 1) { // Where is the ant currently standing in grid coordinates? var currentX = (int)math.round(localTransform.Position.x); var currentY = (int)math.round(-localTransform.Position.y); var currentIndex = TilemapUtils.GetFlattenedIndex(currentX, currentY); var currentTileState = TileStates[currentIndex]; // If standing on a diggable tile, dig for a random duration // and finish the iteration(return) if (currentTileState == (byte)TileState.Filled) { animationIndexOverride.Value = (int)AntAnimationIndex.Dig; var duration = random.NextFloat(1.5f, 3.5f); digging.Duration = duration; isDiggingEnabled.ValueRW = true; DiggerEntityMap.Add(currentIndex, entity); return; } // If not standing on a diggable tile, then check the flow field // Read the Flow Field arrow for this tile var flowDirection = DiggingFlowField[currentIndex]; var targetX = currentX + flowDirection.x; var targetY = currentY + flowDirection.y; // Debug.Log($"Flow direction: {flowDirection}"); // If the flow field is not 0,0, then there is still a path towards a diggable tile // set the heading with the flow field direction if (!flowDirection.Equals(int2.zero)) { animationIndexOverride.Value = (int)AntAnimationIndex.Walk; // Convert grid direction to a world direction (invert Y) heading.Direction = new int2(flowDirection.x, -flowDirection.y); heading.Target = new int2(targetX, -targetY); isHeadingEnabled.ValueRW = true; } else { // If the flow field is 0,0, we are standing next to a diggable tile or there is no path // to a diggable tile. // Debug.Log("Flow field is 0"); // WE REACHED THE END OF THE FLOW FIELD. // Check the 8 adjacent neighbors for a dirt tile. We wouldn't need to check // for the tiles that we just walked through, but it's easier to check all neighbors // than to hold the backtracking state var diggableNeighbors = new NativeList<DiggableNeighbor>(7, Allocator.TempJob); for (var i = 0; i < Neighbors.Length; i++) { var nx = currentX + Neighbors[i].x; var ny = currentY + Neighbors[i].y; // Prevent out-of-bounds if (nx < 0 || nx >= MapWidth || ny < 0 || ny >= MapHeight) continue; var nIndex = TilemapUtils.GetFlattenedIndex(nx, ny); if (TileStates[nIndex] != (byte)TileState.Filled) continue; diggableNeighbors.Add(new DiggableNeighbor { Direction = new int2(Neighbors[i].x, Neighbors[i].y), Target = new int2(nx, -ny) }); } // If there is a diggable neighbor, move to the dirt and start digging. if (!diggableNeighbors.IsEmpty) { // Set animation index to walking animationIndexOverride.Value = (int)AntAnimationIndex.Walk; // Select a diggable neighbor at random var diggableNeighbor = diggableNeighbors[random.NextInt(0, diggableNeighbors.Length)]; // Convert grid direction to a world direction (invert Y) heading.Direction = diggableNeighbor.Direction; heading.Target = diggableNeighbor.Target; isHeadingEnabled.ValueRW = true; } else { // There is no path to a diggable tile. The flow field is 0, and there is no dirt in any adjacent tiles. // Use ECB to revert the ant to Idle assignment. // We should avoid ECB's when possible, in this case it's preferable // since we can run this in parallel and also change the shared Assignment component // value. The alternative is to convert the assignment to multiple enableable components if (DiggingIntegrationField[currentIndex] == ushort.MaxValue) { // DEAD END / NO DIRT LEFT // The BFS algorithm confirmed there is absolutely no path. ECB.SetSharedComponent(chunkIndex, entity, new Assignment { Value = AssignmentType.Idle }); animationIndexOverride.Value = (int)AntAnimationIndex.Idle; resting.Duration = random.NextFloat(1.5f, 3.5f); isRestingEnabled.ValueRW = true; } else { // STALE MAP // Rest for a brief moment and wait for the Flow Field to update! animationIndexOverride.Value = (int)AntAnimationIndex.Idle; resting.Duration = random.NextFloat(0.5f, 1.5f); // Short rest isRestingEnabled.ValueRW = true; } } diggableNeighbors.Dispose(); } } else { // Randomly resting animationIndexOverride.Value = (int)AntAnimationIndex.Idle; resting.Duration = random.NextFloat(1.5f, 3.5f); isRestingEnabled.ValueRW = true; } ThreadRandoms[_threadIndex] = random; } } public void OnDestroy(ref SystemState state) { _diggerAntQuery.Dispose(); _threadRandoms.Dispose(); } } }
So this system manages the state machine for the ant. It is complex, yes and we can simplify the logic probably. But the point is that the complexity is contained within a single system. I’m not using any colliders or physics, now Unity Physics package(which is part of DOTS) is awesome, I just went with a simpler Tilemap-based approach stored in flattened arrays since I don’t really care if the ants overlap with each other. My bet is that it can scale better but we’ll see when we finish the game.
Next is the system that moves the ants:
// imports... namespace ECS.Systems { /// <summary> /// Apply linear speed to ants so they will move in their <see cref="Heading"/> direction /// </summary> /// <remarks> /// /// </remarks> [UpdateInGroup(typeof(SimulationSystemGroup))] [BurstCompile] public partial struct AntMoveSystem : ISystem { /// <summary> /// Query focusing on ants that need to move to another tile /// </summary> private EntityQuery _antQuery; [BurstCompile] public void OnCreate(ref SystemState state) { _antQuery = new EntityQueryBuilder(Allocator.Temp) .WithAll< AntTag, FlipXOverride, Heading, LocalTransform, MovementSpeed>() .Build(state.EntityManager); } [BurstCompile] public void OnUpdate(ref SystemState state) { var deltaTime = SystemAPI.Time.DeltaTime; var handle = new AntMoveJob { DeltaTime = deltaTime }.ScheduleParallel(_antQuery, state.Dependency); state.Dependency = handle; } [BurstCompile] public partial struct AntMoveJob : IJobEntity { public float DeltaTime; public void Execute( ref FlipXOverride flipXOverride, ref Heading heading, EnabledRefRW<Heading> isHeadingEnabled, in MovementSpeed movementSpeed, ref LocalTransform localTransform) { // Get the offset vector and the exact distance to the target var offset = heading.Target - localTransform.Position.xy; var distanceToTarget = math.length(offset); // Calculate wow far will the ant walk this frame var distanceToTravelThisFrame = movementSpeed.Value * DeltaTime; if (distanceToTarget <= distanceToTravelThisFrame) { // Snap to the target since the ant will arrive or overshoot localTransform.Position.xy = heading.Target; isHeadingEnabled.ValueRW = false; } else { // Normalize for constant speed var normalizedDirection = offset / distanceToTarget; // Face the correct direction flipXOverride.Value = normalizedDirection.x > 0 ? 1 : -1; // Walk forward localTransform.Position.xy += normalizedDirection * distanceToTravelThisFrame; } } } } }
// imports... namespace ECS.Systems { /// <summary> /// Apply linear speed to ants so they will move in their <see cref="Heading"/> direction /// </summary> /// <remarks> /// /// </remarks> [UpdateInGroup(typeof(SimulationSystemGroup))] [BurstCompile] public partial struct AntMoveSystem : ISystem { /// <summary> /// Query focusing on ants that need to move to another tile /// </summary> private EntityQuery _antQuery; [BurstCompile] public void OnCreate(ref SystemState state) { _antQuery = new EntityQueryBuilder(Allocator.Temp) .WithAll< AntTag, FlipXOverride, Heading, LocalTransform, MovementSpeed>() .Build(state.EntityManager); } [BurstCompile] public void OnUpdate(ref SystemState state) { var deltaTime = SystemAPI.Time.DeltaTime; var handle = new AntMoveJob { DeltaTime = deltaTime }.ScheduleParallel(_antQuery, state.Dependency); state.Dependency = handle; } [BurstCompile] public partial struct AntMoveJob : IJobEntity { public float DeltaTime; public void Execute( ref FlipXOverride flipXOverride, ref Heading heading, EnabledRefRW<Heading> isHeadingEnabled, in MovementSpeed movementSpeed, ref LocalTransform localTransform) { // Get the offset vector and the exact distance to the target var offset = heading.Target - localTransform.Position.xy; var distanceToTarget = math.length(offset); // Calculate wow far will the ant walk this frame var distanceToTravelThisFrame = movementSpeed.Value * DeltaTime; if (distanceToTarget <= distanceToTravelThisFrame) { // Snap to the target since the ant will arrive or overshoot localTransform.Position.xy = heading.Target; isHeadingEnabled.ValueRW = false; } else { // Normalize for constant speed var normalizedDirection = offset / distanceToTarget; // Face the correct direction flipXOverride.Value = normalizedDirection.x > 0 ? 1 : -1; // Walk forward localTransform.Position.xy += normalizedDirection * distanceToTravelThisFrame; } } } } }
// imports... namespace ECS.Systems { /// <summary> /// Apply linear speed to ants so they will move in their <see cref="Heading"/> direction /// </summary> /// <remarks> /// /// </remarks> [UpdateInGroup(typeof(SimulationSystemGroup))] [BurstCompile] public partial struct AntMoveSystem : ISystem { /// <summary> /// Query focusing on ants that need to move to another tile /// </summary> private EntityQuery _antQuery; [BurstCompile] public void OnCreate(ref SystemState state) { _antQuery = new EntityQueryBuilder(Allocator.Temp) .WithAll< AntTag, FlipXOverride, Heading, LocalTransform, MovementSpeed>() .Build(state.EntityManager); } [BurstCompile] public void OnUpdate(ref SystemState state) { var deltaTime = SystemAPI.Time.DeltaTime; var handle = new AntMoveJob { DeltaTime = deltaTime }.ScheduleParallel(_antQuery, state.Dependency); state.Dependency = handle; } [BurstCompile] public partial struct AntMoveJob : IJobEntity { public float DeltaTime; public void Execute( ref FlipXOverride flipXOverride, ref Heading heading, EnabledRefRW<Heading> isHeadingEnabled, in MovementSpeed movementSpeed, ref LocalTransform localTransform) { // Get the offset vector and the exact distance to the target var offset = heading.Target - localTransform.Position.xy; var distanceToTarget = math.length(offset); // Calculate wow far will the ant walk this frame var distanceToTravelThisFrame = movementSpeed.Value * DeltaTime; if (distanceToTarget <= distanceToTravelThisFrame) { // Snap to the target since the ant will arrive or overshoot localTransform.Position.xy = heading.Target; isHeadingEnabled.ValueRW = false; } else { // Normalize for constant speed var normalizedDirection = offset / distanceToTarget; // Face the correct direction flipXOverride.Value = normalizedDirection.x > 0 ? 1 : -1; // Walk forward localTransform.Position.xy += normalizedDirection * distanceToTravelThisFrame; } } } } }
Now this is way simpler. The ant simulation works though systems that determine the ant’s state and systems that mutate the ant’s properties like position and whether they are digging or hauling resources back to the drop-off location. This separation allows for complex behaviors running at lightning speed.
