Synchronization with the player loop
The problem
Typically, when managing physics, the core logic for a single object resides in FixedUpdate. Input, on the other hand, is read in Update or any function executed every frame. However, Unity executes as many physics cycles as needed (including the various FixedUpdate calls) before executing any Update function.
This is fine for most cases, but I wanted something extremely reactive, where input could be processed in the same frame as the physics simulation. However, placing my custom physics in Update wouldn’t work either, as I still need the simulation to produce consistent results regardless of the rendering frame rate.
How I decided to approach it
There are multiple ways to resolve this issue. I didn’t want to rely on tools like script execution order or build a fragile solution directly in something like LateUpdate. While that approach isn’t inherently bad, there’s a cleaner alternative: I decided to modify the player loop by adding a function that executes after Update.
However, this alone isn’t sufficient: simply executing a function every frame would make the simulation frame-dependent. Fortunately, this problem is solvable: the system can track elapsed time and run the simulation as many times as needed, similar to how Unity’s built-in physics simulation and FixedUpdate work. The key difference is that this approach eliminates the one-frame delay in the simulation.
There’s another complication: I need the ability to run my custom functions in different orders. This is useful for several reasons — for instance, to move certain objects before others or to implement a two-step simulation. The standard solution with FixedUpdate is to adjust the execution order, and a similar system would meet my needs. However, it would be even better to use an enum to specify simulation priority in a more readable and maintainable way.
The implementation
To implement this, I created a single Simulator — a class capable of running every registered Simulation. The simulations are essentially callback wrappers, while the simulator handles callback execution.
Simulation
The simulation is a straightforward disposable class. When instantiated, it registers itself with the simulator, and upon disposal, it unregisters. An IsValid property indicates whether the simulation remains active — that is, whether it has been disposed.
The implementation is simple enough that a detailed explanation isn’t necessary.
using System;
using JetBrains.Annotations;
[MustDisposeResource]
class Simulation : IDisposable
{
readonly Action<float> update;
public Simulation(SimulationPriority priority, Action<float> update)
{
Simulator.AddSimulation(priority, this);
this.update = update;
}
public bool IsValid { get; private set; } = true;
public void Update(float deltaTime) =>
update(deltaTime);
public void Dispose() {
if (!IsValid)
return;
IsValid = false;
Simulator.RemoveSimulation(this);
}
}
Note that I’m using the MustDisposeResource attribute to ensure proper disposal of the simulation in my IDE. If you use Rider, this is helpful; otherwise, you can remove or ignore the attribute.
I initially considered adding a finalizer as a runtime safety net for missed disposals, but that would have introduced threading complications. The GC finalizer runs on a separate thread concurrently with the main thread, so calling into the Simulator’s internal collections would have required synchronization locks in AddSimulation, RemoveSimulation, and ExecuteMissingSteps. Since the static analysis provided by MustDisposeResource already catches missed disposals at compile time, I decided to avoid the extra complexity—but feel free to implement it in your own code if you find it helpful.
I also have a type called SimulationPriority, which is simply an enum used to specify the simulation’s priority.
Simulator
This is where the real complexity begins. Since this class serves as a persistent service, I implemented it as a singleton using a static approach.
using UnityEngine;
// Other usings
static class Simulator
{
// variables and properties
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void Initialize()
{
// Initialization of the simulator
}
// Other methods
}
To retrieve simulations by their priority and to look up the priority of specific simulations, I added the following member variables.
using System.Collections.Generic;
// In the class
static SortedDictionary<SimulationPriority, HashSet<Simulation>> _simulations;
static Dictionary<Simulation, SimulationPriority> _simulationPriorities;
// In the Initialize method
_simulations = new SortedDictionary<SimulationPriority, HashSet<Simulation>>();
_simulationPriorities = new Dictionary<Simulation, SimulationPriority>();
Note that _simulations is a SortedDictionary, which allows for easy iteration over simulations in priority order. The natural enumeration order is used to iterate through the simulations, which works perfectly for this use case.
The script also disables the default 2D physics simulation. While not strictly necessary, I chose to disable it for performance reasons since it isn’t needed.
using Unity.U2D.Physics;
// In the Initialize method
var defaultWorld = PhysicsWorld.defaultWorld;
defaultWorld.simulationType = PhysicsWorld.SimulationType.Script;
Before examining how the simulations are executed, let’s look at the code for adding and removing simulations.
using UnityEngine.Pool;
// In the class
public static void AddSimulation(SimulationPriority priority, Simulation simulation) {
if (!_simulations.TryGetValue(priority, out HashSet<Simulation> simulations))
_simulations[priority] = simulations = HashSetPool<Simulation>.Get();
simulations.Add(simulation);
_simulationPriorities[simulation] = priority;
}
public static void RemoveSimulation(Simulation simulation) {
if (!_simulationPriorities.Remove(simulation, out SimulationPriority priority))
return;
if (!_simulations.TryGetValue(priority, out HashSet<Simulation> simulations))
return;
simulations.Remove(simulation);
if (simulations.Count != 0)
return;
_simulations.Remove(priority);
HashSetPool<Simulation>.Release(simulations);
}
The only notable aspect of this code is the use of HashSetPool, which allows me to avoid unnecessary allocations.
Now it’s time to handle simulation execution. First, I need to attach a function to the player loop in Initialize.
using UnityEngine.LowLevel;
using UnityEngine.PlayerLoop;
// In the Initialize method
PlayerLoopSystem loop = PlayerLoop.GetCurrentPlayerLoop();
for (int i = 0; i < loop.subSystemList.Length; i++)
if (loop.subSystemList[i].type == typeof(PreLateUpdate))
{
loop.subSystemList[i].updateDelegate -= ExecuteMissingSteps;
loop.subSystemList[i].updateDelegate += ExecuteMissingSteps;
break;
}
PlayerLoop.SetPlayerLoop(loop);
Notice that I chose PreLateUpdate as the anchor point. It executes after Update, making it ideal for my use case, while still leaving LateUpdate available should I need to execute something after the simulations (though in that scenario, I might prefer adding a function directly to the player loop).
I also removed the ExecuteMissingSteps function from the delegate before re-adding it, in case the same player loop is being modified twice. This is a good practice in general, but it’s particularly important here: when the game runs in the editor, the player loop persists between play sessions. Without this safeguard, ExecuteMissingSteps would be called multiple times per frame.
The strategy I use to execute the simulations is to accumulate the time that hasn’t been used for simulations yet. At the start of ExecuteMissingSteps, this accumulated time is always less than the duration of a single step. This information must be stored in a member variable, which is reset in Initialize:
// In the class
static float _unusedSimulationTime;
// In Initialize
_unusedSimulationTime = 0;
In ExecuteMissingSteps, I iterate as long as _unusedSimulationTime exceeds the duration of a single step. StepDuration is a constant representing the duration of a single step in seconds.
Notice that I reset _unusedSimulationTime, like I did with the various dictionaries, _simulations and _simulationPriorities, because static variables are not guaranteed to be reset between play sessions.
// In the class
const float StepDuration = 1 / 60f;
static void ExecuteMissingSteps()
{
float deltaTime = Time.deltaTime;
_unusedSimulationTime += deltaTime;
while (_unusedSimulationTime >= StepDuration)
{
// Execute the simulations
_unusedSimulationTime -= StepDuration;
}
// Other code
}
I cached Time.deltaTime in the deltaTime variable to avoid redundant property accesses, as I need to reuse it later—each access incurs a cost due to crossing the native-to-managed boundary.
I could iterate directly over the simulations to execute them, but the collection may change during execution — for instance, when an object is instantiated or destroyed mid-simulation. Therefore, I need to capture all simulations in order beforehand. However, I want to avoid allocations, especially in ExecuteMissingSteps, which runs continuously. This is achieved through the GetSimulations function, described later.
// Inside the ExecuteMissingSteps while,
using var _ = GetSimulations(out List<Simulation> simulations);
foreach (Simulation simulation in simulations)
if (simulation.IsValid)
simulation.Update(StepDuration);
// Other code
Notice that GetSimulations creates a copy of the simulations, preventing new ones from being added during iteration. However, an invalid simulation could still execute without the IsValid check if it was removed during the execution of a previous simulation.
Tracking elapsed time is generally useful, but there are actually two distinct time values worth tracking. The first, which I call SimulationTime, represents the current time of the ongoing simulation. The second, CurrentFrameTime, represents the time when the current batch of simulations began executing. I opted to use double instead of float for these values, as they can grow considerably larger than the step duration, ensuring good precision is maintained. While using double isn’t strictly necessary, the performance cost is negligible.
// In the class
public static double SimulationTime { get; private set; }
public static double CurrentFrameTime { get; private set; }
// Inside the Step while, after the execution of the simulations
SimulationTime += StepDuration;
// At the end of Step
SimulationTime = CurrentFrameTime += deltaTime;
I don’t clamp the accumulated time here to prevent uncontrolled death spirals—situations where the system must process an increasing number of simulation steps each frame. Unity already caps Time.deltaTime via Time.maximumDeltaTime, so replicating that logic in _unusedSimulationTime would merely duplicate a safeguard I already have. If your project modifies that setting, this approach warrants reconsideration.
Also note that I reset CurrentFrameTime to match the final SimulationTime value, keeping them perfectly synchronized.
Finally, let’s examine how GetSimulations is implemented. The key challenge here is minimizing allocations. I use object pools and pre-set Capacity values to reduce allocations to the bare minimum.
The core structure is straightforward: retrieve the simulation groups, create a list to store all simulations, adjust the list’s capacity, and populate it with all simulations.
// In the class
static PooledObject<List<Simulation>> GetSimulations(out List<Simulation> simulations)
{
using var _ = GetSimulationGroups(out List<HashSet<Simulation>> simulationGroups);
var simulationsDisposable = ListPool<Simulation>.Get(out simulations);
SetCapacity(simulations);
foreach (HashSet<Simulation> simulationGroup in simulationGroups)
simulations.AddRange(simulationGroup);
return simulationsDisposable;
// Inner functions
}
SetCapacity is straightforward: I iterate over all the groups, sum the number of simulations in each group, and set the capacity accordingly. I never shrink the capacity to avoid unnecessary deallocations and reallocations.
// In the GetSimulations function
void SetCapacity(List<Simulation> simulations)
{
int capacity = 0;
foreach (HashSet<Simulation> simulationGroup in simulationGroups)
capacity += simulationGroup.Count;
simulations.Capacity = Mathf.Min(capacity, simulations.Capacity);
}
Similar work is done in GetSimulationGroups.
// In the GetSimulations function
static PooledObject<List<HashSet<Simulation>>> GetSimulationGroups(out List<HashSet<Simulation>> groups)
{
var simulationGroupsDisposable = ListPool<HashSet<Simulation>>.Get(out groups);
groups.Capacity = Mathf.Min(groups.Capacity, _simulations.Count);
foreach ((SimulationPriority _, HashSet<Simulation> value) in _simulations)
groups.Add(value);
return simulationGroupsDisposable;
}
The complete code for the Simulator class is shown below.
using System.Collections.Generic;
using Unity.U2D.Physics;
using UnityEngine;
using UnityEngine.LowLevel;
using UnityEngine.PlayerLoop;
using UnityEngine.Pool;
static class Simulator
{
const float StepDuration = 1 / 60f;
static SortedDictionary<SimulationPriority, HashSet<Simulation>> _simulations;
static Dictionary<Simulation, SimulationPriority> _simulationPriorities;
static float _unusedSimulationTime;
public static double SimulationTime { get; private set; }
public static double CurrentFrameTime { get; private set; }
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void Initialize()
{
_unusedSimulationTime = 0;
_simulations = new SortedDictionary<SimulationPriority, HashSet<Simulation>>();
_simulationPriorities = new Dictionary<Simulation, SimulationPriority>();
var defaultWorld = PhysicsWorld.defaultWorld;
defaultWorld.simulationType = PhysicsWorld.SimulationType.Script;
PlayerLoopSystem loop = PlayerLoop.GetCurrentPlayerLoop();
for (int i = 0; i < loop.subSystemList.Length; i++)
if (loop.subSystemList[i].type == typeof(PreLateUpdate))
{
loop.subSystemList[i].updateDelegate -= ExecuteMissingSteps;
loop.subSystemList[i].updateDelegate += ExecuteMissingSteps;
break;
}
PlayerLoop.SetPlayerLoop(loop);
}
public static void AddSimulation(SimulationPriority priority, Simulation simulation)
{
if (!_simulations.TryGetValue(priority, out HashSet<Simulation> simulations))
_simulations[priority] = simulations = HashSetPool<Simulation>.Get();
simulations.Add(simulation);
_simulationPriorities[simulation] = priority;
}
public static void RemoveSimulation(Simulation simulation)
{
if (!_simulationPriorities.Remove(simulation, out SimulationPriority priority))
return;
if (!_simulations.TryGetValue(priority, out HashSet<Simulation> simulations))
return;
simulations.Remove(simulation);
if (simulations.Count != 0)
return;
_simulations.Remove(priority);
HashSetPool<Simulation>.Release(simulations);
}
static void ExecuteMissingSteps()
{
float deltaTime = Time.deltaTime;
_unusedSimulationTime += deltaTime;
while (_unusedSimulationTime >= StepDuration)
{
using var _ = GetSimulations(out List<Simulation> simulations);
foreach (Simulation simulation in simulations)
if (simulation.IsValid)
simulation.Update(StepDuration);
SimulationTime += StepDuration;
_unusedSimulationTime -= StepDuration;
}
SimulationTime = CurrentFrameTime += deltaTime;
}
static PooledObject<List<Simulation>> GetSimulations(out List<Simulation> simulations)
{
using var _ = GetSimulationGroups(out List<HashSet<Simulation>> simulationGroups);
var simulationsDisposable = ListPool<Simulation>.Get(out simulations);
SetCapacity(simulations);
foreach (HashSet<Simulation> simulationGroup in simulationGroups)
simulations.AddRange(simulationGroup);
return simulationsDisposable;
void SetCapacity(List<Simulation> simulations)
{
int capacity = 0;
foreach (HashSet<Simulation> simulationGroup in simulationGroups)
capacity += simulationGroup.Count;
simulations.Capacity = Mathf.Min(capacity, simulations.Capacity);
}
static PooledObject<List<HashSet<Simulation>>> GetSimulationGroups(out List<HashSet<Simulation>> groups)
{
var simulationGroupsDisposable = ListPool<HashSet<Simulation>>.Get(out groups);
groups.Capacity = Mathf.Min(groups.Capacity, _simulations.Count);
foreach ((SimulationPriority _, HashSet<Simulation> value) in _simulations)
groups.Add(value);
return simulationGroupsDisposable;
}
}
}
Conclusion
With this implementation, it is now possible to execute any simulation using the following code.
// In the component or another class that uses the simulation
Simulation simulation;
// In the class initialization
simulation = new Simulation(SimulationPriority.MyPriority, SimulationUpdate);
// In the class
void SimulationUpdate()
{
// Code to execute during the simulation, similarly to FixedUpdate
}
// During the class destruction
simulation.Dispose();
That’s all that’s required. This approach is faster than FixedUpdate — avoiding the per-object native-to-managed dispatch overhead — while providing more maintainable ordering that is completely decoupled from the rest of the codebase. It supports multiple simulations per component, each with different priorities if needed, and grants the simulation access to the input state of the frame in which it executes.
I chose not to pool Simulation instances since they are typically created alongside components — a relatively rare event that coincides with many other object allocations. In more complex scenarios, or if this becomes a performance bottleneck, pooling can be implemented, though doing so would require modifying the code to allow configuration outside the constructor.