Beyond MonoBehaviour: Running Code in Unity's Game Loop Using Non-MonoBehaviour C# Classes

Posted by : on

Category : Unity

Introduction

You can get the code for this post, from the github repository.

Some time ago, I received a question regarding my post: How to create an early and a super late update in Unity. The question, upon further clarification, addressed two distinct points:

  • How to implement early and late fixed updates, as simply adding systems before and after the FixedUpdate external loop would cause them to run every update tick, not every fixed update tick.
  • How to register for these updates using an event.

The second requirement is independent of the first, as it can apply to any stage of Unity’s game loop. This can be useful for executing code within non-MonoBehaviour classes (POCOs) during any update or fixed update. This approach is demonstrated in my post about early and super late updates.

In this post, I will demonstrate how to add early and late fixed updates that execute on every fixed update tick. I will also explain how to use plain old C# classes (POCOs) for this purpose, and then present two different methods for running code from a POCO class while respecting Unity’s game loop (i.e., running during every update or fixed update).

Changing Unity’s Game Loop

I won’t delve into the specifics of Unity’s game loop and how to hook into it, as this is covered in my post, How to create an early and a super late update in Unity. Instead, I will provide the code that specifically creates the early and late fixed updates, ensuring they run on every FixedUpdate used by Unity’s physics simulation, and not every Update.

To accomplish this, we need to go one level deeper. Each system in Unity’s game loop contains a list of subsystems that adhere to that system’s tick. At the top level, we have:

TimeUpdate
Initialization
EarlyUpdate
FixedUpdate
PreUpdate
Update
PreLateUpdate
PostLateUpdate

Each of those has a list of subsystems. For the FixedUpdate, its subsystems are:

ClearLines
NewInputFixedUpdate
DirectorFixedSampleTime
AudioFixedUpdate
ScriptRunBehaviourFixedUpdate
DirectorFixedUpdate
LegacyFixedAnimationUpdate
XRFixedUpdate
PhysicsFixedUpdate
Physics2DFixedUpdate
PhysicsClothFixedUpdate
DirectorFixedUpdatePostPhysics
ScriptRunDelayedFixedFrameRate

Unity’s game loop systems and subsystems can vary between versions. This is the case for version 6000.0.38f1. Anyone comparing the systems and subsystems in this version with those described in my post from two years ago will notice that new subsystems have been added. The script provided in that post can be used to check the subsystems specific to your Unity version.

Returning to the problem of implementing early and late fixed updates: the subsystem of interest is ScriptRunBehaviourFixedUpdate. This subsystem is responsible for executing any C# code written in the FixedUpdate event method. Therefore, we need to add our own subsystems immediately before and after it.

First, we must iterate through all the systems in the default player loop and check each one to determine if it is the FixedUpdate system. If it is not, we add it to a list that will serve as the updated game loop structure. When we find the FixedUpdate system, we replace it with a modified version that includes our new subsystems.

foreach (var subSystem in defaultSystems.subSystemList)
{
   if (subSystem.type != typeof(FixedUpdate))
   {
      newSubSystemList.Add(subSystem);
   }
   else
   {
      var newSubSystem = CreateNewSubsystem(subSystem);
      newSubSystemList.Add(newSubSystem);
   }
}

Now, we need to implement the CreateNewSubsystem method, which returns a subsystem containing all the existing FixedUpdate subsystems while also inserting our own before and after ScriptRunBehaviourFixedUpdate.

To achieve this, we follow the same approach as before: we iterate through all the subsystems, adding them to a list as they are. When we find ScriptRunBehaviourFixedUpdate, we insert our own subsystems before and after it.

foreach (var newSystemSubsystem in subSystem.subSystemList)
{
   if (newSystemSubsystem.type != typeof(FixedUpdate.ScriptRunBehaviourFixedUpdate))
   {
      newSystemSubSystemList.Add(newSystemSubsystem);
   }
   else
   {
      newSystemSubSystemList.Add(myEarlyFixedUpdate);
      newSystemSubSystemList.Add(newSystemSubsystem);
      newSystemSubSystemList.Add(myLateFixedUpdate);
   }
}

The only aspect not covered here is the PlayerLoopSystem struct, which we use to create our own subsystems. Here’s the complete method:

PlayerLoopSystem CreateNewSubsystem(PlayerLoopSystem subSystem)
{
   PlayerLoopSystem newSubSystem = new()
   {
      loopConditionFunction = subSystem.loopConditionFunction,
      type = subSystem.type,
      updateDelegate = subSystem.updateDelegate,
      updateFunction = subSystem.updateFunction
   };
      
   List<PlayerLoopSystem> newSystemSubSystemList = new();
   foreach (var newSystemSubsystem in subSystem.subSystemList)
   {
      if (newSystemSubsystem.type != typeof(FixedUpdate.ScriptRunBehaviourFixedUpdate))
      {
         newSystemSubSystemList.Add(newSystemSubsystem);
      }
      else
      {
         newSystemSubSystemList.Add(myEarlyFixedUpdate);
         newSystemSubSystemList.Add(newSystemSubsystem);
         newSystemSubSystemList.Add(myLateFixedUpdate);
      }
   }
   newSubSystem.subSystemList = newSystemSubSystemList.ToArray();
   return newSubSystem;
}

I won’t go into detail about the entire script since you can find the implementation details in my other post. I’m including it here for completeness:

public static class FixedUpdateManager
{
   private static readonly HashSet<IEarlyFixedUpdate> _EarlyFixedUpdates = new();
   private static readonly HashSet<ILateFixedUpdate> _LateFixedUpdates = new();

   public static void RegisterEarlyFixedUpdate(IEarlyFixedUpdate earlyUpdate) => _EarlyFixedUpdates.Add(earlyUpdate);

   public static void RegisterLateFixedUpdate(ILateFixedUpdate superLateUpdate) => _LateFixedUpdates.Add(superLateUpdate);

   public static void UnregisterEarlyFixedUpdate(IEarlyFixedUpdate earlyUpdate) => _EarlyFixedUpdates.Remove(earlyUpdate);

   public static void UnregisterLateFixedUpdate(ILateFixedUpdate superLateUpdate) => _LateFixedUpdates.Remove(superLateUpdate);

   [RuntimeInitializeOnLoadMethod]
   private static void Init()
   {
      var defaultSystems = PlayerLoop.GetDefaultPlayerLoop();

      var myEarlyFixedUpdate = new PlayerLoopSystem
      {
         subSystemList = null,
         updateDelegate = OnEarlyFixedUpdate,
         type = typeof(EarlyFixedUpdate)
      };

      var myLateFixedUpdate = new PlayerLoopSystem
      {
         subSystemList = null,
         updateDelegate = OnLateFixedUpdate,
         type = typeof(LateFixedUpdate)
      };

      PlayerLoopSystem newPlayerLoop = new()
      {
         loopConditionFunction = defaultSystems.loopConditionFunction,
         type = defaultSystems.type,
         updateDelegate = defaultSystems.updateDelegate,
         updateFunction = defaultSystems.updateFunction
      };

      List<PlayerLoopSystem> newSubSystemList = new();

      foreach (var subSystem in defaultSystems.subSystemList)
      {
         if (subSystem.type != typeof(FixedUpdate))
         {
            newSubSystemList.Add(subSystem);
         }
         else
         {
            var newSubSystem = CreateNewSubsystem(subSystem);
            newSubSystemList.Add(newSubSystem);
         }
      }

      newPlayerLoop.subSystemList = newSubSystemList.ToArray();
      PlayerLoop.SetPlayerLoop(newPlayerLoop);

      return;

      PlayerLoopSystem CreateNewSubsystem(PlayerLoopSystem subSystem)
      {
         PlayerLoopSystem newSubSystem = new()
         {
            loopConditionFunction = subSystem.loopConditionFunction,
            type = subSystem.type,
            updateDelegate = subSystem.updateDelegate,
            updateFunction = subSystem.updateFunction
         };
            
         List<PlayerLoopSystem> newSystemSubSystemList = new();

         foreach (var newSystemSubsystem in subSystem.subSystemList)
         {
            if (newSystemSubsystem.type != typeof(FixedUpdate.ScriptRunBehaviourFixedUpdate))
            {
               newSystemSubSystemList.Add(newSystemSubsystem);
            }
            else
            {
               newSystemSubSystemList.Add(myEarlyFixedUpdate);
               newSystemSubSystemList.Add(newSystemSubsystem);
               newSystemSubSystemList.Add(myLateFixedUpdate);
            }
         }

         newSubSystem.subSystemList = newSystemSubSystemList.ToArray();
         return newSubSystem;
      }
   }

   private static void OnEarlyFixedUpdate()
   {
      using var e = _EarlyFixedUpdates.GetEnumerator();
      while (e.MoveNext())
      {
         e.Current?.EarlyFixedUpdate();
      }
   }

   private static void OnLateFixedUpdate()
   {
      using var e = _LateFixedUpdates.GetEnumerator();
      while (e.MoveNext())
      {
         e.Current?.LateFixedUpdate();
      }
   }
}

This will add early and late fixed update systems that run at the same frequency as Unity’s FixedUpdate event method. From a POCO class, they can be used as follows:

public class NonMonoBehavior : IEarlyFixedUpdate, ILateFixedUpdate
{
   public NonMonoBehavior()
   {
      FixedUpdateManager.RegisterEarlyFixedUpdate(this);
      FixedUpdateManager.RegisterLateFixedUpdate(this);
   }
   
   void IEarlyFixedUpdate.EarlyFixedUpdate() => Debug.Log("Inside NonMonoBehavior Early Fixed Update");
   void ILateFixedUpdate.LateFixedUpdate() => Debug.Log("Inside NonMonoBehavior Late Fixed Update");

   // Could also implement IDisposable for unregistering from FixedUpdateManager
   public void UnregisterFromFixedUpdateManager()
   {
      FixedUpdateManager.UnregisterEarlyFixedUpdate(this);
      FixedUpdateManager.UnregisterLateFixedUpdate(this);
   }
}

In this implementation, I decided not to use standard C# events, where scripts register with the += syntax, but instead opted for a traditional observer pattern using a HashSet. There are a couple reasons for this:

  • It helps avoid garbage collection (GC) issues caused by C# events. While this is not always a significant problem, in performance-critical code that interacts with low-level Unity APIs, reducing GC overhead can make a difference.
  • Explicitly implementing the IEarlyFixedUpdate and ILateFixedUpdate interfaces not only signals to other developers that the class integrates with Unity’s game loop but also helps prevent potential bugs. If these interfaces are implemented, we must ensure proper registration and unregistration with the FixedUpdateManager, and registering/unregistering cannot be done without the implementation of those interfaces.

Now, let’s explore an alternative way of connecting to Unity’s game loop.

Creating a Singleton

Until recently, the primary way to interact with Unity’s game loop, other than modifying its subsystems, was through MonoBehaviour event methods. We can apply the same approach of registering and unregistering with a HashSet, but instead of modifying the game loop directly, we iterate through the set and execute the relevant methods inside a MonoBehaviour class.

To ensure this class is unique and that no other instance exists, we must implement it as a MonoBehaviour singleton. Within this class, we can execute the appropriate methods to iterate through our HashSets:

private void FixedUpdate()
{
    OnEarlyFixedUpdate();
    OnLateFixedUpdate();
}

where their implementations are:

private void OnEarlyFixedUpdate()
{
    using var e = _earlyFixedUpdates.GetEnumerator();
    while (e.MoveNext())
    {
        e.Current?.EarlyFixedUpdate();
    }
}

private void OnLateFixedUpdate()
{
    using var e = _lateFixedUpdates.GetEnumerator();
    while (e.MoveNext())
    {
        e.Current?.LateFixedUpdate();
    }
}

This approach allows us to execute code from POCOs but comes with a few drawbacks compared to the previous solution:

  • Singleton Manager. This should be sufficient for now. I will discuss singletons in more detail in a future post.
  • Loss of the ability to run code in the standard FixedUpdate method – If we attempt to do so, the execution order becomes undefined; it may run before or after both OnEarlyFixedUpdate and OnLateFixedUpdate. While we could introduce an OnFixedUpdate between them, this wouldn’t change the fact that, from now on, any code that needs to run in FixedUpdate, whether from MonoBehaviours or POCOs, should be registered with our singleton. This approach has potential performance benefits for projects with a large number of MonoBehaviours, as it eliminates the need to transition between managed and native code. However, it is highly prone to mistakes.

Here’s the complete singleton code for reference:

public static class FixedUpdateManager 
{
    private static FixedUpdateGameObject _Instance;
    public static FixedUpdateGameObject Instance
    {
        get
        {
            if (!_Instance && SceneManager.GetActiveScene().isLoaded)
            {
                _Instance = new GameObject().AddComponent<FixedUpdateGameObject>();
                _Instance.name = _Instance.GetType().ToString();
                Object.DontDestroyOnLoad(_Instance.gameObject);
            }
            return _Instance;
        }
    }

    public class FixedUpdateGameObject : MonoBehaviour
    {
        private readonly HashSet<IEarlyFixedUpdate> _earlyFixedUpdates = new();
        private readonly HashSet<ILateFixedUpdate> _lateFixedUpdates = new();

        private void FixedUpdate()
        {
            OnEarlyFixedUpdate();
            OnLateFixedUpdate();
        }
        
        public void RegisterEarlyFixedUpdate(IEarlyFixedUpdate earlyUpdate) => _earlyFixedUpdates.Add(earlyUpdate);

        public void RegisterLateFixedUpdate(ILateFixedUpdate superLateUpdate) => _lateFixedUpdates.Add(superLateUpdate);

        public void UnregisterEarlyFixedUpdate(IEarlyFixedUpdate earlyUpdate) => _earlyFixedUpdates.Remove(earlyUpdate);

        public void UnregisterLateFixedUpdate(ILateFixedUpdate superLateUpdate) => _lateFixedUpdates.Remove(superLateUpdate);
    
        private void OnEarlyFixedUpdate()
        {
            using var e = _earlyFixedUpdates.GetEnumerator();
            while (e.MoveNext())
            {
                e.Current?.EarlyFixedUpdate();
            }
        }

        private void OnLateFixedUpdate()
        {
            using var e = _lateFixedUpdates.GetEnumerator();
            while (e.MoveNext())
            {
                e.Current?.LateFixedUpdate();
            }
        }
    }
}

I know this isn’t the typical way you’re used to seeing singletons in MonoBehaviours. However, there are many different ways to implement singletons in Unity, some good, some bad, and some that aren’t really singletons at all. Since this is a broad topic, I’ll cover it in a future post when I have the time.

Using Awaitable

Finally, let’s discuss the most recent tool Unity has introduced: Awaitable. You can read more about it in my post: Asynchronous Code In Unity Using Awaitable and AwaitableCompletionSource.

The Awaitable plays a significant role in the Unity ecosystem, though its importance isn’t immediately obvious, as it’s often seen merely as a replacement for Task.

For the first time since Unity’s creation, Awaitable provides a way to run code that adheres to Unity’s game loop without requiring it to be inside a Unity event method (which only exist in MonoBehaviours) and without needing an active MonoBehaviour, as was previously necessary for coroutines. An async method that runs a loop and awaits an Awaitable effectively behaves like an Update or FixedUpdate method, at least in terms of execution flow, inside a POCO class.

For example, consider the following class:

public class NonMonoBehavior
{
    public async Awaitable FixedUpdate(Func<bool> predicate)
    {
        while (predicate())
        {
            Debug.Log("Fixed update from NonMonobehavior");
            await Awaitable.FixedUpdateAsync();
        }
    }
}

If called like this:

public class Square : MonoBehaviour
{

    private NonMonoBehavior _example;
    private bool _shouldRun = true;

    private void Start()
    {
        _example = new NonMonoBehavior();
        _example.FixedUpdate(ShouldRun);
    }
    
    private void Update()
    {
        if (Keyboard.current.spaceKey.wasPressedThisFrame)
            _shouldRun = false;
    }

    private void FixedUpdate() => Debug.Log("Square Fixed Update");

    private bool ShouldRun() => _shouldRun;
}

the method will continuously log “Fixed update from NonMonobehavior” to the console every fixed update until the space key is pressed.

Where we place our code, before or after the await expression, makes a slight difference. In both cases, the code will always execute after any code inside the FixedUpdate event methods of MonoBehaviours. However:

  • If the code is placed before the await, it will execute immediately when the method is first called (in this example, from the Start method).
  • If the code is placed after the await, it will only execute after Unity’s standard FixedUpdate runs, each time the loop continues.

The Awaitable class also provides NextFrameAsync and a EndOfFrameAsync static methods, allowing us to connect to other parts of Unity’s game loop as well.

Conclusion

These are three different ways to run code that follows Unity’s game loop from standard C# classes:

  1. Modifying the Player Loop – This method creates distinct early and late fixed update phases, ensuring they run at the same interval as Unity’s built-in FixedUpdate. One runs before and one after Unity’s standard FixedUpdate. How to modify Unity’s game loop is covered in detailed in my blog post: How to create an early and a super late update in Unity.

  2. Using a Singleton Manager – This method involves a singleton that manages its own FixedUpdate loop, calling registered early and late update methods in a defined order. However, MonoBehaviours with their own FixedUpdate methods will execute either before or after these calls, making execution order unpredictable. Like the first method, this approach also works with non-MonoBehaviour classes.

  3. Leveraging Awaitable – This method utilizes Unity’s new Awaitable feature to execute code at fixed update intervals from a regular C# class, without requiring it to be a MonoBehaviour.

Choosing the Right Approach

  • If you need strict early and late fixed updates, the first method is the best choice.
  • If you want a quick and easy way to run code at fixed update intervals from regular C# classes, the third method (Awaitable) is more suitable.
  • I generally advise against the second method (Singleton Manager) due to its unpredictable execution order and reliance on singletons, which I dislike.

I also chose not to use C# events due to potential garbage collection overhead. Instead, I implemented register and unregister methods for classes that follow the appropriate interfaces, adhering to the classic observer pattern. You can easily switch to events if desired, but I don’t see a compelling reason to do so.

You can get the code for this post, from the github repository.

Thank you for reading, and if you have any questions or comments you can use the comments section, or contact me directly via the contact form or by email. Also, if you don’t want to miss any of the new blog posts, you can subscribe to my newsletter or the RSS feed.


Follow me: