Unity ECS Modding

close upUnity ECS Moddingclose up

Posted: February 26, 2025

Last Updated: February 26, 2025

INTRODUCTION AND SCOPE:

For the past year or so I’ve been in the process of developing a bullet hell game with a fully-fledged level builder. My propensity for making things customizable may have bled too much into this project, but it’s been fun, regardless. One thing I haven’t gotten to until now is the ability to mod my game, though. This one addition would truly add the most flexibility to my level builder. You can’t really beat the creativity of people who want to mod something, so I want to make that process as easy as I can.

This guide is mostly here to provide bare-bones steps for adding ECS mod support. If you want to jump right into an example usage of my modding setup, I’ve set up a git repository with some pretty out-of-the-box projects to test it out. I’ve only been able to test it on Windows machines, so your mileage may vary.

This page is for you if:

  • You use Unity Entities version 1.2.3 (com.unity.entities@1.2.3) or a sufficiently similar version.
  • You want to add ARBITRARY mod support to your game.
    • You want modders to be able to add new components.
    • You want modders to be able to add new systems.
  • You want to add game-specific mod support to your game.
    • You want modders to be able to use a subset of utilities, interfaces, etc. from your project.
  • You want mods to be added as precompiled DLL files.

“Can”s and “Can’t”s:

CAN:

  • Create and use arbitrary, non-ECS related types and methods via reflection.
  • Create and use arbitrary IComponentData.*
  • Create and use arbitrary IBufferElementData.*
  • Create and use arbitrary SystemBase.*
  • Create and use Bursted jobs in your systems.*
  • Gain access to and utilize components and systems from the main game (e.g. BulletComponent and a ton of other stuff in my project or the project you are designing).*†

CAN’T (yet):

  • Create and use arbitrary ISystems. Currently requires special registration that I haven’t figured out yet.*
  • Load ECS mods without restarting; i.e. you must restart the game for ECS mods to take effect.
  • Easily test mods in Unity play mode editor. There is a type cache that does not take external DLLs into account in the editor, so mods are difficult to test without a build.

Details:

* = Requires at least an installation of the Unity Editor with Entities package for the compilation pipeline to generate DLL.

† = Requires at least a special SDK for your project. This includes *.

Note that all of the “can’t”s are just things that I have not figured out how to do yet. That doesn’t necessarily mean they are impossible; you just need someone with more knowledge/time to figure it out. I would also like to support more things, so this may be updated in the future to reflect new “can”s and “can’t”s.

HOW TO ADD MODDING TO YOUR ECS PROJECT:

SIMPLE MOD SETUP:

This setup allows mods to:

  • Use typical C# functionality.
  • Use limited ECS functionality.

You need to determine:

  • Mod Folder: What directory/folder will you use to store mods?

Here are the quick-and-dirty steps to adding mod support in your host project (the one running mods) and the SDK (the project used to make mods).

Preparing your Host Project:

  1. Install the com.unity.entities package (I used version 1.2.3).
  2. Install the com.unity.platforms package.
  3. Create a SimpleModLoader (code below).
  4. Create a build of your project. (I could not get ECS mods to work in the Unity editor due to it caching types).
  5.  
// Use this code for a very simple mod loader.
// It just scans a set folder for .dll files and
// loads their assemblies into the current AppDomain.

using System.IO;
using System.Linq;

namespace DirtyModding {
    public class SimpleModLoader {
        private string modFolderPath;
        public SimpleModLoader() {
            // Change the mod folder path to
            // the desired project/build-specific path.
            modFolderPath = "path/to/mod/folder";
        }
        /// <summary>
        /// Iterate all .dll files in the mod
        /// folder and load their assemblies
        /// into the current AppDomain.
        /// </summary>
        [UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.BeforeSplashScreen)]
        public void LoadModDLLs() {
            string[] dlls = Directory.GetFiles(modFolderPath, "*.dll", SearchOption.AllDirectories);
            foreach (string dll in dlls) {
                string dllPath = Path.Combine(modFolderPath, dll);
                if (File.Exists(dllPath)) {
                    Assembly.LoadFile(dllPath);
                }
            }
        }
    }
}

Preparing your SDK:

  1. Create your SDK project in Unity (I used version 2022.3.39f1).
  2. Install the com.unity.entities package (I used version 1.2.3).
  3. Create a folder in Assets/ called Scripts/ and create a subfolder in Scripts/ called Mods/.
  4. Make a new folder under the Mods/ folder for your new mod.
  5. Add an Assembly Definition (asmdef) in the folder.
  6. Add the following references to the asmdef:
    1. Unity.Burst
    2. Unity.Burst.CodeGen
    3. Unity.Collections
    4. Unity.Entities
    5. Unity.Entities.CodeGen
    6. Unity.Entities.Hybrid
    7. Unity.Jobs.CodeGen
    8. Unity.Mathematics
    9. Unity.Transforms
    10. Any other project/package-specific assemblies that you need for your game.
  7. Create a new mod using IComponentData, IBufferElementData, and SystemBase (example code below).
  8. Create a build of your SDK project.
  9. Locate the folder with the built SDK project and find a DLL file matching the name of your mod’s Assembly Definition name in <BuildFolder>/<BuildProject>/<BuildProject>_Data/Managed/.
  10. Copy this .dll file into your host project’s mod folder.
  11. Run the host project build executable and the mod should load automatically.
// A very simple example mod in MyModSystem.cs.
// Should probably have an associated
// MyGame.Modding.MyMod assembly definition in a parent folder.
// Then Library/ScriptAssemblies/MyGame.Modding.MyMod.dll
// will be the DLL that contains this modded system.

using Unity.Entities;

namespace MyGame.Modding.MyMod {
    public partial class MyModSystem : SystemBase {
        private int numUpdates;
        protected override void OnCreate() {
            numUpdates = 0;
            UnityEngine.Debug.Log($"Created: MyModSystem");
        }
        protected override void OnUpdate() {
            numUpdates += 1;
            UnityEngine.Debug.Log($"Updated: MyModSystem {numUpdates} times");
        }
    }
}

Your SDK project could have the following layout:

MyGameSDKProject
|- Assets/
|- Scripts/
|- Mods/
|- MyMod/
|- MyGame.Modding.MyMod.asmdef
|- MyModSystem.cs
|- BuildFolder/
|- BuiltProject/
|- BuiltProject_Data/
|- Managed/
|- MyGame.Modding.MyMod.dll

ADVANCED MOD SETUP:

For example Host and SDK projects, I recommend this git repository that I made in conjunction with this guide (https://github.com/orcsune/UnityModdingECS).

This setup allows mods to:

  • Use typical C# functionality.
  • Use limited ECS functionality.
  • Use Bursted jobs and other Bursted methods.
  • Use an improved mod loader.
  • Incorporate specific project functionality.

You need to determine:

  • Mod Folder: What directory/folder will you use to store mods?
  • Mod Utilities: How do you want to load your mods? Do you want to have other asset folders available for each mod? Do you want to allow enabling/disabling mods or enforce a mod loading order?
  • Integration: Which parts and how much of your original project’s interfaces/classes/utilities/code should be available for mod use?

Here are the general steps to adding mod support in your host project (the one running mods) and the SDK (the project used to make mods).

Preparing your Host Project:

  1. Install the com.unity.platforms package.
  2. Install NuGet for Unity.
  3. Install NewtonSoft.Json from NuGet.
  4. Create a more advanced mod loader (example code on this git repo & general instructions below).
    1. Should be able to load managed C# DLLs.
    2. Should be able to load special Bursted assemblies (lib_burst_generated.dll) from the build folder.
    3. Should load DLLs on a method decorated with a RuntimeInitializeOnLoadMethod with a RuntimeInitializeLoadType of BeforeSplashScreen. Really, as long as you can get them to load before any call to TypeManager.Initialize(), you should be fine.
    4. Should handle any metadata, loading order, or special/project-specific things you want it to.
  5. Create assembly definition files in various places of your project’s Assets/Scripts/ folder. These assembly definitions should generate DLL files that will be available in your SDK for modders to use. Decompilers can be used to get decompiled source code of types provided in these DLLs, so if these assemblies are of super-secret confidential source code, then either don’t use them or potentially make some wrapper that the DLL can use (example layout for protected/exposed project elements below).
  6. Create a build of your project. (I could not get ECS mods to work in the Unity editor due to it caching types).
  7.  

Your host project could have the following layout:

MyGameProject
|- Assets/
|- Scripts/
|- StuffIWantAvailableInMod1/
|- GameComponent1.cs
|- GameSystem1.cs
|- ... other components/systems/utilities you want to expose
|- ...
|- MyGame.Exposed.Runtime.asmdef
|- ProtectedSource1/
|- HiddenComponent.cs
|- HiddenSystem.cs
|- ...
|- MyGame.Protected.Runtime.asmdef
|- BuildFolder/
|- BuiltProject/
|- BuiltProject_Data/
|- Managed/
|- MyGame.Exposed.Runtime.dll
|- MyGame.Protected.Runtime.dl

Preparing your SDK:

  1. Create your SDK project in Unity (I used version 2022.3.39f1).
  2. Install the com.unity.entities package (I used version 1.2.3).
  3. From the host project, copy the exposed file(s) at path <BuildFolder>/<BuiltProject>/<BuiltProject>_Data/Managed/MyGame.Exposed.Runtime.dll and place it in your Assets/ folder of the SDK. You may get warnings about systems. This is okay, but testing mods in the SDK editor might be difficult. (Copy any custom assemblies you want to have access to in your mod).
  4. Create a folder in Assets/ called Scripts/ and create a subfolder in Scripts/ called Mods/.
  5. Make a new folder under the Mods/ folder for your new mod.
  6. Add an Assembly Definition (asmdef) in the folder.
  7. Add the following references to the asmdef:
    1. Unity.Burst
    2. Unity.Burst.CodeGen
    3. Unity.Collections
    4. Unity.Entities
    5. Unity.Entities.CodeGen
    6. Unity.Entities.Hybrid
    7. Unity.Jobs.CodeGen
    8. Unity.Mathematics
    9. Unity.Transforms
    10. Any other project/package-specific assemblies that you need for your game.
  8. Create a new mod using IComponentData, IBufferElementData, and SystemBase (example code on GitHub). You should also be able to use the components/systems/utilities provided from the host’s MyGame.Exposed.Runtime.dll file.
  9. Create a build of your SDK project.
  10. In your SDK build folder, locate the <BuiltProject>/<BuiltProject>_Data/Managed/ folder and copy the DLLs for each mod into your final mod folder.
  11. Also locate the <BuiltProject>/<BuiltProject>_Data/Plugins/x86_64/ folder and copy the lib_burst_generated.dll file to the final mod folder.
  12. Set up the mod manifest as needed.

Your SDK project could have the following layout:

MyGameSDKProject
|- Assets/
|- Scripts/
|- StuffIWantAvailable1/
|- GameComponent1.cs
|- GameSystem1.cs
|- ... other components/systems/utilities you want to expose
|- ...
|- MyGame.First.Runtime.asmdef
|- ProtectedSource1/
|- HiddenComponent.cs
|- HiddenSystem.cs
|- ...
|- MyGame.Protected.Runtime.asmdef
|- Builds/
|- SDKBuild/
|- SDKBuild_Data/
|- Managed/
|- MyGame.First.Runtime.dll
|- MyGame.Protected.Runtime.dll
|- Plugins/
|- x86_64/
|- lib_burst_generated.dll

Example – Turn Mod:

This is taken from the example repository.

Here we have a host game in which mutliple objects with MoverComponents move to the right. With our “Turn Mod” enabled and set up correctly, those objects are also turned at a constant rate. When profiling the build, we can also see that depending on whether the burst DLL is included also affects whether the TurnJob is bursted.

Turn Mod enabled.

Turn Mod disabled.

Burst enabled. Turn job runs significantly faster.

Burst disabled.

DEBUGGING/THE RANT:

This section is just a vent about the debugging process for adding in mods. Let’s just say it’s here for documenting vague lessons.

The Bootstrapper:

I tried creating my own custom bootstrapper to perform my own World initialization and add the found systems into the game after loading. This, however, did not initially work because of some system registration logic. I attempted to add the collected SystemBases from my mods into the world with DefaultWorldInitialization.AddSystemsToRootLevelSystemGroup. This ended up not working because the systems could not be found in the SystemBaseRegistry. After some investigation into the code, I found that I could essentially perform a roundabout call to TypeManager‘s AddSystemTypeToTablesAfterInit method by invoking TypeManager‘s GetSystemTypeIndex method. GetSystemTypeIndex would call AddSystemTypeToTablesAfterInit if the system type was not already registered. While this worked for registering the system, other problems persisted. Specifically, I then kept getting errors about components, like: 'ArgumentException: Unknown Type:`Modding.GravityComponent` All ComponentType must be known at compile time.'. Everything keeps coming back to the fact that there is a bunch of initialization logic in TypeManager.Initialize() and I simply have to load my DLLs before it is called to make sure everything is registered correctly.

RuntimeInitializeOnLoadMethod:

This brought me to messing around with static classes and static constructors. One of the constant sources of stress was the existing AttachToEntityClonerInjection class. It would consistently run its constructor and call its own Initialize method before any of my stuff would. The main issue with this is that it calls TypeManager.Initialize in its own Initialize method, prompting all the setup logic before I could load my own DLLs. Something I noticed, though, is that this method also had a RuntimeInitializeOnLoadMethod attribute with a type of BeforeSceneLoad. This seemed to be my chance to get into the loop before it could call TypeManager.Initialize. I created my own static bootstrapper with an Initialize method and RuntimeInitializeOnLoadMethod attribute with type AfterAssembliesLoaded, which should run earlier than BeforeSceneLoad.

This technically worked. My initialization method to load mod DLLs did technically start to run before AttachToEntityClonerInjection‘s. Unfortunately and predictably, there was another problem, though. You see, it turns out that doing this seemed to cause my mod loader to load assemblies at the same time that other, already loaded systems were invoking SystemBaseRegistry‘s AddUnmanagedSystemType method. AddUnmanagedSystemType is an invocation appended by one of Unity’s ILPostProcessor that my systems did not have time to invoke. This caused my systems in newly loaded assemblies to not register with SystemBaseRegistry, causing problems. Lots of dumb, garbage words that can be boiled down to:

  1. I can’t load my DLLs before AttachToEntityClonerInjection.Initialize without using RuntimeInitializeOnLoadType AfterAssembliesLoaded.
  2. I can’t load my DLLs using load type AfterAssembliesLoaded because it loads during something else.
  3. It seems like I need to load my DLLs at this exact point.

Pain:

Here’s the backup plan: just modify AttachToEntityClonerInjection‘s Initialize method so it calls my mod loader before TypeManager.Initialize. So, I moved the Entities package (com.unity.entities@1.2.3) from the Library/PackageCache/ folder into the Packages/ folder, opened up the relevant file, and made the necessary change and everything worked… is what I would say if it worked. No, now we can get into the bug-hunting fun time of modifying the Entities package so I can place my simple DLL-loading code in AttachToEntityClonerInjection‘s Initialize method. I was hit with this error being thrown at runtime ONLY IN THE PROJECT BUILD:

ArgumentException: Cannot find TypeIndex for type hash 17085320908745535082. Check in the debug file ExportedTypes.log of your project Logs folder (<projectName/Logs) the corresponding Component type name for the type hash 17085320908745535082. And ensure your runtime depends on all assemblies defining the Component types your data uses.

Why was this showing up? Why was it showing up exactly 4 times in the build? To which component is this type hash referring? WHY IS THIS ONLY HAPPENING AFTER MOVING THE ENTITIES FOLDER? I spent almost 2 days poking around code before I could determine the fix.

Finding the relevant component was simple enough. A glance in the log file and verifying with some forum code pointed me to the Doc.CodeSamples.Tests.Baking.TemporaryBakingType.SomeComputedData component. Huh, that’s strange, what is this and why is this here? Looking that up, I found the type in the DocCodeSamples.Tests/BakingExamples.cs file. None of this stuff seemed to be having an effect when Entities was in the Library/PackageCache/ folder, so what is it doing causing a ruckus now? After further inspection, it seemed to be an example/test for a baking system, but what that system did (maybe) explained why I was getting 4 errors from 4 different Subscenes. This component was associated with a testing Rigidbody baker, so this baker was now running on all my Rigidbody objects and giving them a SomeComputedData component for reasons unbeknownst to me. These 4 Subscenes either directly contained an object with a Rigidbody or referenced an object with a Rigidbody. This was the problem.

Success:

I tried a few things to try and prevent these testing assemblies from being included, but none of my attempts worked. Finally, I settled on just commenting out this entire testing file and hoping that there was no more code that decided to do the same thing. Lo and behold- it worked! That was truly surprising. There’s always more crap to deal with so having it finally just work was pleasant for once. After that, I’m happy to report that my build was able to read enabled mods and run their systems as I had hoped.

Update:

As I wrote this, I created some sample projects to demonstrate this modding process. In doing so, I found that I was sometimes able to not have to modify any Entities code and just apply an earlier RuntimeInitializeLoadType to my mod-loading method. I eventually retried the RuntimeInitializeLoadType strategy and found that SubsystemRegistration began loading things appropriately and didn’t seem to break anything in my bullet hell project. After making an example repository, though, I found that a load type of SubsystemRegistration also can cause problems, and if it does, switching to BeforeSplashScreen fixed them. I guess that changing the Entities code is probably the most surefire way to get the mod loader to run exactly when it needs to, but it’s so disappointing that I have to do that.

UPDATES:

None right now.

Leave a Reply