Search Unity

  1. Want to see 2020.1b in action? Sign up for our Beta 2020.1 Overview Webinar on April 20th for a live presentation from our evangelists and a Q&A session with guests from R&D.
    Dismiss Notice
  2. Interested in giving us feedback? Join our online research interviews on a broad range of topics and share your insights with us.
    Dismiss Notice
  3. We're hosting a webinar for the new Input System where you'll be able to get in touch with the devs. Sign up now and share your questions with us in preparation for the session on April 15.
    Dismiss Notice
  4. Dismiss Notice

[Complete Game Project] DOTS of the Dead!

Discussion in 'Data Oriented Technology Stack' started by illogikaStudios, Oct 11, 2019.

  1. illogikaStudios


    Nov 19, 2008

    Hi everyone,

    Today we've decided to share a little project we've been working on for the last few days. DOTS of the Dead is a DOTS sample game project that's meant to be an example of "real gameplay" implemented fully in DOTS. It is a simple local-multiplayer top-down zombie shooter.

    We are using DOTS physics, Hybrid Renderer, URP and the new Input System

    We intended this project to be used as a DOTS learning/reference project for our programmers at iLLOGIKA, but we figured there would be no harm in sharing it with the entire Unity community! The code in the project is relatively simple. We didn't go very far with optimization, for the sake of accessibility and readability, but the performance is still pretty incredible compared to the old MonoBehaviour/GameObject workflow.

    Feedback, questions and suggestions are welcome




    Post Mortem

    About the project

    This project was made over 2 weeks by 3 programmers with varied knowledge of DOTS. We had several goals:
    • Learn more about DOTS, and introduce it to devs who have never worked with it before
    • Start building a DOTS sample project to be used for teaching DOTS to other programmers
    • Gain a better understanding of the usability and current status of DOTS
    • See what working with DOTS in a team looks like
    • Evaluate the performance of DOTS when we program with flexibility & designer-friendliness in mind
    • Try out other packages such as URP, Addressables, new Input System, etc… as well
    • Have a bit of fun
    The code is "game jam quality" and was not formally reviewed, so it should be expected to be incomplete/incoherent/unoptimal at times

    Things that DOTS made easier
    • Performance is awesome even if we programmed everything very naively (no time was spent on optimization). And we don’t even have to worry about GC and pooling anymore. We can just instantiate things as we please
    • DOTS automatically guides us towards an architecture that is extremely robust, scalable and modular. We didn't have to spend much time thinking about how we'd organize things, what kinds of "game managers" and "singletons" we'd have, what kinds of inheritance hierarchies we'd have, how we'd set things up so that we'd be able to optimize them later if we need to, etc... We just followed the basic rules of the ECS and made almost everything be a system that always exists. This gives us an architecture that is pretty much ideal
    • No need to manually “register” things anymore (lists of enemies, lists of Players, etc...). Entity Queries solve that problem
    • We believe teamwork is easier to coordinate with DOTS than with the old workflows, due to the natural modularity of DOTS and the reasons mentioned above

    Things that DOTS made more difficult
    • Within our team, there are two very different opinions on the "boilerplate code" necessary when using DOTS. There was a very heated debate regarding this point so we'll just share both perspectives. Here are the two opinions (note that we weren't using the upcoming autogenerated Authoring components or the Entities.ForEach):
      • Negative: The fact that we need to create/maintain 3 separate files (ComponentData, Authoring, Systems) for each thing we make makes development longer and more tedious than with monobehaviour. Other things that make writing code more tedious are the need to declare & schedule job structs, the need to extract, modify, and write back the structs into ComponentDataFromEntity arrays, and the management of ReadOnly that has to be both as an attribute over the ComponentDataFromEntity in jobs, and in the GetComponentDataFromEntity(isReadOnly)
      • Positive: In game development, the time cost of writing boilerplate code is infinitely small compared to the time cost of figuring out a proper architecture, managing teamwork, and optimizing the game. In the context of DOTS, if the boilerplate code plays an important role in solving those problems (and we believe it does), then the boilerplate is effectively a huge timesaver and we'll gladly live with it. We also don't have a clear solution to suggest in order to minimize the amount of code to write
    • The conversion workflow works well, but it’s still more work to set up things compared to monobehaviours. In an ideal world, we still wish there would be a pure DOTS “multi-entity prefab” editor where you can drag & drop links between entities, setup entity transform hierarchies, see exactly which components are on entities, etc…. The upcoming inspector of generated components will help a lot here
    • Debugging is much more tedious than before, namely due to not being able to click on an object to inspect it, not being able to tweak values on entities at runtime, etc....
    • The fact that "things don't always happen instantly" in DOTS (even when single-threaded) creates some new problems that we mostly didn't have to worry about before. These problems can be solved, but they do require more work and are not always easy to detect. Here's an example:
      • Projectile hit detection: When a projectile kills a zombie, it's supposed to destroy itself along with the zombie collider entity. However, since entity destruction is deferred, when your firing rate & projectile speed makes it so that two projectiles might hit the same zombie within the same simulation frame, you'll end up in a situation where instead of the first projectile killing the zombie and the second one passing through, you'll have both projectiles get destroyed on the same zombie, therefore reducing the efficiency of high firing-rates

    Things that are missing or buggy
    • A huge amount of time was spent on trying to figure out things that are very basic, but that were made difficult due to lack of well-advertised & straight-to-the-point code examples:
      • How transform parenting works (which components do you have to add manually, which ones will be added automatically, etc....)
      • How to make a child object be destroyed along with its root entity
      • How to set the worldspace rotation of a child transform
      • How to do things like "sphere cast" or "overlap box" in parallel foreach jobs, without requiring every iterated entity to have a physics collider on them. Think about how you'd make a job that for every projectile in the game, does a spherecast from its previous position to the current position, with variable radiuses
      • How to make a dynamic rigidbody have certain rotation or position axes constrained
      • How to change material properties on a given renderer (ex: make characters glow red when damaged)
      • How to work with allocating new NativeArrays within jobs? What are the rules & limitations, and what are the caveats?
    • Math lib is missing some utility functions that we need almost all the time (but it's also possible that they do exist and we just didn't find them). We had to make our own utilities for some of these:
      • Vector3.Project
      • Vector3.ProjectOnPlane
      • Vector3.ClampMagnitude
      • Vector3.Slerp
      • Some kind of quaternion.FromToRotation(quaternion from, quaternion to)
    • Most of the time, when we press Play, a bunch of cryptic errors pop up. They seem to come from the UnityPhysics package
    • Inspecting an entity with physics components on it in the EntityDebugger seems to throw tons of errors and break the inspector
    • We're missing a way to define a collision filter in inspector (for physics queries). Some equivalent of LayerMask with a custom property drawer
    • We think that the “MaxHitsCollector” from the physics samples should probably come included by default
    • We're still waiting for the SimulationGroup to run on fixed update and the PresentationGroup to have a solution for interpolation. We know there are ways to do it manually, but for this project, we didn't bother with it. So our character movement is framerate-dependant right now

    Other thoughts
    • We can't really compare to the old workflow when it comes to development velocity, because most devs were new to DOTS, and documentation/samples are still too scarce
    • We believe there is still a LOT of room for optimization in this project
    • Overall, we are very excited about DOTS’s future and this project was a very positive experience.
    • That being said, there are still several things we’d like to wait for before feeling 100% confident about starting a real DOTS project:
      • Animation (a low-level API similar to Playables, a node editor, an Animation window equivalent, examples of using the Animation Rigging package’s IK and constraints with pure DOTS, etc...)
      • Skinned Meshes
      • Particle Systems & VFXGraph for DOTS
      • The proper final setup for simulation & presentation worlds (fixed update, interpolation, etc….)
      • Runtime UIElements (or a UI solution for DOTS)
      • Full integration with SRPs (indirect lighting, dots camera, dots volumes, dots lights, etc….)
      • Better DOTS editor/debugging tooling
    Last edited: Oct 16, 2019
  2. brunocoimbra


    Sep 2, 2015
    That seems pretty great! Looking forward to see it specially because of the new Input System (didn't had much time to learn about it yet) but those links are protected currently (probably you didn't want to protect them).
  3. PhilSA


    Jul 11, 2013
    (Having been one of the programmers to work on this project, I can answer questions in this thread if you have them)
    edoarn and pal_trefall like this.
  4. illogikaStudios


    Nov 19, 2008
    should be fixed now!
    Quantimax likes this.
  5. Onigiri


    Aug 10, 2014
    Thank you for sharing this! DOTS examples are always appreciated.
  6. jGate99


    Oct 22, 2013
    Thanks guys, it'd be really great if you upgrade this project to upcoming conversion system
    as a lot of people like me are waiting till that moment to start learning it
  7. Joachim_Ante


    Unity Technologies

    Mar 16, 2005
    Would it make sense to put this on github so its easier to view & comment on the code?
  8. PhilSA


    Jul 11, 2013
    We'll be able to set it up shortly. It should be done by tomorrow
  9. Lonepig


    May 13, 2017
    Wonderful! Thanks for sharing such a great example for people to learn from.
  10. Micz84


    Jul 21, 2012
    @PhilSA I have questions about PickupSystem. There are to jobs there one for health and one for weapon pickups. Weapon pickup job is scheduled with dependency on health system it sims to be working with independent scheduling.
    The second question is wouldn't it be more performant to create one job with this conditionals statement?
    Code (CSharp):
    2.  if (healthPickupGroup.Exists(entityA) || healthPickupGroup.Exists(entityB))
    3.  {
    4.           //health job code here
    5.  }
    6.  else if (FireRatePickupGroup.Exists(entityA) || FireRatePickupGroup.Exists(entityB))
    7.  {
    8.          //weapon job code here
    9.  }
    I know it is less extensible if it would be required to more types of pickups. I have tried both of these approaches and I think they are more performant than the current version. But I can't tell if one is more performant then the other. I was just watching frame time during playtesting with about 40k of zombies and the weapon set to 5000 firing rate and 20 bullets per shot.

    Maybe I will write a performance test to check which one is better.

    I will have to check how early out for checking if any of the entities belongs to a player affects performance.
    Last edited: Oct 13, 2019
  11. illogikaStudios


    Nov 19, 2008
    Updated original post with github repo
  12. PhilSA


    Jul 11, 2013
    This project was made with the mindset of "let's explore and see what we can come up with in 2 weeks", so you should expect inconsistencies and unoptimal/incomplete things in the code. You could almost think of it as a game jam. No formal code reviews or anything of the sort were done

    It's totally possible that your approach to pickups would be more performant, but we also wanted to program things in a way that is representative of how we'd do things in a "real game". In other words, we want things to be scalable, modular, etc...

    For instance; we could've made zombies be just one single entity and it would've been more performant that way. But we wanted to implement zombies in a way that would allow us to make many different kinds of enemies that all mostly use the same systems. For this reason, zombies have a character entity, a weapon hold point entity, a weapon entity, a generic "attack Inputs" system that ties all of this together, etc.... and they are given a weapon when they are instantiated. In theory, you could give zombies the player's melee weapon prefab and they would all attack with that instead of their default melee attack. (that hasn't been tested yet). This sort of versatility in the game's architecture/design is something we wanted to try out in DOTS

    If you do your performance test, we'd be curious to see the results though
    Last edited: Oct 14, 2019
    Micz84 likes this.
  13. MrGeeDee


    Sep 18, 2019
    This looks so satisfying! Could you build it for Android maybe???
  14. Micz84


    Jul 21, 2012
    I was just asking if there was a reason behind this way of coding. I understand that this code is not perfect. I have tried to write a performance test for this using the test framework. But my experience with testing DOTS project is to low to write it properly. I will have to learn more about this project and performance testing in general. I was struggling with instantiating zombies. Would have to set up Zombie Spawning systems with all the prefabs and so on.

    I have playtested it and performance gain some performance (about 5-10%)
    Here is my combined job with early out:

    Code (CSharp):
    2. struct WeaponAndHealthBonusPickupSystemJob : ITriggerEventsJob
    3.     {
    4.         public EntityCommandBuffer entityCommandBuffer;
    6.         public ComponentDataFromEntity<WeaponBonusPickUp> FireRatePickupGroup;
    7.         public ComponentDataFromEntity<RangeWeapon> RangeWeaponGroup;
    8.         public ComponentDataFromEntity<HealthPickup> healthPickupGroup;
    9.         public ComponentDataFromEntity<Health> healthGroup;
    11.         [ReadOnly] public ComponentDataFromEntity<PlayerCharacter> playerCharacterGroup;
    13.         [ReadOnly] public ComponentDataFromEntity<Character> CharacterGroup;
    15.         public void Execute(TriggerEvent triggerEvent)
    16.         {
    17.             Entity entityA = triggerEvent.Entities.EntityA;
    18.             Entity entityB = triggerEvent.Entities.EntityB;
    20.             var entityAIsPlayerCharacter = playerCharacterGroup.Exists(entityA);
    21.             if (!entityAIsPlayerCharacter && !playerCharacterGroup.Exists(entityB))
    22.                 return;
    23.             var playerEntity = entityAIsPlayerCharacter ? entityA : entityB;
    24.             var pickupEntity = entityAIsPlayerCharacter ? entityB : entityA;
    25.             if (FireRatePickupGroup.Exists(pickupEntity))
    26.             {
    27.                 WeaponBonusPickUp FireRatePickUp = FireRatePickupGroup[pickupEntity];
    28.                 var characterEntity = CharacterGroup[playerEntity].ActiveRangeWeaponEntity;
    29.                 RangeWeapon weapon = RangeWeaponGroup[characterEntity];
    31.                 weapon.FiringRate += FireRatePickUp.additionnalFireRate;
    32.                 weapon.BulletAmountPerShot *= FireRatePickUp.bulletPerShotMultiplier;
    33.                 RangeWeaponGroup[characterEntity] = weapon;
    35.                 entityCommandBuffer.DestroyEntity(pickupEntity);
    36.             }
    37.             else if (healthPickupGroup.Exists(pickupEntity))
    38.             {
    39.                 HealthPickup healthPickUp = healthPickupGroup[pickupEntity];
    40.                 Health health = healthGroup[playerEntity];
    42.                 health.Value += healthPickUp.restoredAmount;
    43.                 healthGroup[entityB] = health;
    44.                 entityCommandBuffer.DestroyEntity(entityA);
    45.             }
    46.         }
    47.     }
    daniilkorotin and PhilSA like this.
  15. PhilSA


    Jul 11, 2013
    Looking at this now, i think I like your single job version better (even if we cleaned up our version to not have duplicated code)

    Our intention was to separate logic more cleanly in case we end up having like 50 pickup variations, but even then, it seems to me that your version might still feel cleaner

    I wonder if having one single trigger events job in the entire project (for all projects in general) would be ideal?
  16. PhilSA


    Jul 11, 2013
    The issue would be implementing character controls for android, so we can't do that test very quickly

    But if you want to try out just the zombie spawning, you can make GameInitializer.cs spawn waves on tap instead of when pressing 1, and in theory you should be ready to build for android

    Depends how android-ready this version of DOTS is
  17. alexandre-fiset


    Mar 19, 2012
    Really nice, thanks for sharing this! un petit salut en provenance de Québec ;)
  18. PhilSA


    Jul 11, 2013
    a post mortem writeup in in the works. Stay tuned
    SisyphusStudio likes this.
  19. Micz84


    Jul 21, 2012
    I was wondering about this too. I tried to think of some abstraction that would make it more extensible but it would probably cause the job to be non burstable.

    I have checked ProjectilesHitDetectionSystem and I have modified it a little bit. Now it can use burst.

    I do know understand why ZombieSpawningSystem is getting very slow (80-90 ms in entity debugger) when a lot of zombies is spawned
    Last edited: Oct 14, 2019
    PhilSA likes this.
  20. PhilSA


    Jul 11, 2013
    yeah, instantiating seems to be SUPER slow in editor compared to in builds. I have a suspicion it's related to instantiating physics entities

    thanks for the modification!
    Last edited: Oct 14, 2019
  21. calabi


    Oct 29, 2009
    This is seriously awesome, thanks for making this available, we need more DOTS code examples.
  22. Micz84


    Jul 21, 2012
    I learn a lot from reading your code so no need to thank me for this :).
    I also that SpawnZombiesSystem is slow when there is a lot of zombies (after instantiation) but I may be due to synchronization with other jobs. I have an idea of how to change a code for updating zombie count I will try to implement it today.
  23. illogikaStudios


    Nov 19, 2008
    Original post was updated with Post Mortem thoughts!

    Last edited: Oct 16, 2019
  24. Ziboo


    Aug 30, 2011
    Post Mortem is perfect.
    I agree 100 % with every points you made.
    GliderGuy likes this.
  25. AlexTE-Unity


    Unity Technologies

    Aug 7, 2017
    Really cool, keep it up guys :)
  26. calabi


    Oct 29, 2009
    When I try this in Unity I get a couple of erros. 'This entity does not exist' and 'Exception encountered in operation UnityEngine.AddressableAssets.Initialization.InitializationOperation, result='', status='Succeeded' - Chain<SceneInstance,IResourceLocator>: ChainOperation of Type: UnityEngine.ResourceManagement.ResourceProviders.SceneInstance failed because dependent operation failed'
  27. Lucas-Meijer


    Unity Technologies

    Nov 26, 2012
    Thanks for the postmortem writeup Phil. We have a lot of work ahead, but are happy to see the things you miss in your postmortem map pretty well to our view of what we're missing, and roadmap of what we're building.
    GliderGuy, MehO, hippocoder and 3 others like this.
  28. e199


    Mar 24, 2015

    The reason why Burst doesn't work with that job is that you have foreach loops
    which are internally `try finally` blocks, and this is not supported by burst yet

    Works great after changing to for loop
    Code (CSharp):
    1. [BurstCompile]
    2.     struct GameplayInputsJob : IJobForEach_BCC<InputDeviceIdBufferElement, PlayerTag, GameplayInputs>
    3.     {
    4.         [ReadOnly]
    5.         [NativeDisableParallelForRestriction]
    6.         public NativeList<DeviceInputEvent<float2>> MoveInputs;
    7.         [ReadOnly]
    8.         [NativeDisableParallelForRestriction]
    9.         public NativeList<DeviceInputEvent<float2>> LookInputs;
    10.         [ReadOnly]
    11.         [NativeDisableParallelForRestriction]
    12.         public NativeList<DeviceInputEvent<float>> ShootInputs;
    13.         [ReadOnly]
    14.         [NativeDisableParallelForRestriction]
    15.         public NativeList<DeviceInputEvent<float>> MeleeInputs;
    16.         [ReadOnly]
    17.         [NativeDisableParallelForRestriction]
    18.         public NativeList<DeviceInputEvent<float>> ReturnInputs;
    19.         [ReadOnly]
    20.         [NativeDisableParallelForRestriction]
    21.         public NativeList<DeviceInputEvent<float>> ActionInputs;
    23.         public void Execute([ReadOnly] DynamicBuffer<InputDeviceIdBufferElement> inputDeviceIdBuffer, [ReadOnly] ref PlayerTag player, ref GameplayInputs gameplayInputs)
    24.         {
    25.             for (var index = 0; index < inputDeviceIdBuffer.Length; index++)
    26.             {
    27.                 InputDeviceIdBufferElement playerDeviceId = inputDeviceIdBuffer[index];
    28.                 for (var i = 0; i < MoveInputs.Length; i++)
    29.                 {
    30.                     DeviceInputEvent<float2> e = MoveInputs[i];
    31.                     if (e.DeviceId == playerDeviceId.DeviceId)
    32.                     {
    33.                         gameplayInputs.Move = e.InputValue;
    34.                     }
    35.                 }
    37.                 for (var i = 0; i < LookInputs.Length; i++)
    38.                 {
    39.                     DeviceInputEvent<float2> e = LookInputs[i];
    40.                     if (e.DeviceId == playerDeviceId.DeviceId)
    41.                     {
    42.                         gameplayInputs.Look = e.InputValue;
    43.                     }
    44.                 }
    46.                 for (var i = 0; i < ShootInputs.Length; i++)
    47.                 {
    48.                     DeviceInputEvent<float> e = ShootInputs[i];
    49.                     if (e.DeviceId == playerDeviceId.DeviceId)
    50.                     {
    51.                         if (gameplayInputs.Shoot == 0f && e.InputValue == 1f)
    52.                         {
    53.                             gameplayInputs.ShootPressed = true;
    54.                         }
    55.                         else
    56.                         {
    57.                             gameplayInputs.ShootPressed = false;
    58.                         }
    60.                         if (gameplayInputs.Shoot == 1f && e.InputValue == 0f)
    61.                         {
    62.                             gameplayInputs.ShootReleased = true;
    63.                         }
    64.                         else
    65.                         {
    66.                             gameplayInputs.ShootReleased = false;
    67.                         }
    69.                         gameplayInputs.Shoot = e.InputValue;
    70.                     }
    71.                 }
    73.                 for (var i = 0; i < MeleeInputs.Length; i++)
    74.                 {
    75.                     DeviceInputEvent<float> e = MeleeInputs[i];
    76.                     if (e.DeviceId == playerDeviceId.DeviceId)
    77.                     {
    78.                         if (gameplayInputs.Melee == 0f && e.InputValue == 1f)
    79.                         {
    80.                             gameplayInputs.MeleePressed = true;
    81.                         }
    82.                         else
    83.                         {
    84.                             gameplayInputs.MeleePressed = false;
    85.                         }
    87.                         if (gameplayInputs.Melee == 1f && e.InputValue == 0f)
    88.                         {
    89.                             gameplayInputs.MeleeReleased = true;
    90.                         }
    91.                         else
    92.                         {
    93.                             gameplayInputs.MeleeReleased = false;
    94.                         }
    96.                         gameplayInputs.Melee = e.InputValue;
    97.                     }
    98.                 }
    100.                 for (var i = 0; i < ReturnInputs.Length; i++)
    101.                 {
    102.                     DeviceInputEvent<float> e = ReturnInputs[i];
    103.                     if (e.DeviceId == playerDeviceId.DeviceId)
    104.                     {
    105.                         if (gameplayInputs.Return == 0f && e.InputValue == 1f)
    106.                         {
    107.                             gameplayInputs.ReturnPressed = true;
    108.                         }
    109.                         else
    110.                         {
    111.                             gameplayInputs.ReturnPressed = false;
    112.                         }
    114.                         if (gameplayInputs.Return == 1f && e.InputValue == 0f)
    115.                         {
    116.                             gameplayInputs.ReturnReleased = true;
    117.                         }
    118.                         else
    119.                         {
    120.                             gameplayInputs.ReturnReleased = false;
    121.                         }
    123.                         gameplayInputs.Return = e.InputValue;
    124.                     }
    125.                 }
    127.                 for (var i = 0; i < ActionInputs.Length; i++)
    128.                 {
    129.                     DeviceInputEvent<float> e = ActionInputs[i];
    130.                     if (e.DeviceId == playerDeviceId.DeviceId)
    131.                     {
    132.                         if (gameplayInputs.Action == 0f && e.InputValue == 1f)
    133.                         {
    134.                             gameplayInputs.ActionPressed = true;
    135.                         }
    136.                         else
    137.                         {
    138.                             gameplayInputs.ActionPressed = false;
    139.                         }
    141.                         if (gameplayInputs.Action == 1f && e.InputValue == 0f)
    142.                         {
    143.                             gameplayInputs.ActionReleased = true;
    144.                         }
    145.                         else
    146.                         {
    147.                             gameplayInputs.ActionReleased = false;
    148.                         }
    150.                         gameplayInputs.Action = e.InputValue;
    151.                     }
    152.                 }
    153.             }
    154.         }
    155.     }