Azen GameLoop
One place decides which systems run, when, and in what order. Lifecycle, channels, pause reasons and timers — without an Update() in every MonoBehaviour.
What you get
GameLoop does not replace Unity's PlayerLoop. Unity still calls Update, FixedUpdate and LateUpdate — but one driver receives them and routes each phase to your systems by clear rules: game state, channel, priority.
Features
Off → Play ⇄ Pause → Stop, plus restart. Systems react through small listener interfaces with async callbacks.Tick, FixedTick, LateTick, TickAsync. Higher priority runs first inside a phase.Gameplay while UI keeps ticking. Slow down one channel without touching Time.timeScale.Every and Once live in game time: they respect channel pause and time scale.Samples
| Sample | What it shows | Compiles when |
|---|---|---|
| Manual Bootstrap | Driver, registration, start from a MonoBehaviour | Always |
| VContainer | LifetimeScope, systems as entry points, starter after IInitializable | jp.hadashikick.vcontainer is installed |
| Zenject | MonoInstaller, BindExecutionOrder for the starter | com.svermeulen.extenject is installed or AZEN_ZENJECT is defined |
| Demo repository | Falling balls, round timer, score, every pause mode on keys | Open Assets/Demo/Scenes/DemoScene in the repository |
Install
Two options: Package Manager from git, or a .unitypackage from the release page. Both need UniTask.
Add the OpenUPM registry for UniTask
The package declares com.cysharp.unitask as a dependency, so Unity must know where to find it:
"scopedRegistries": [
{
"name": "package.openupm.com",
"url": "https://package.openupm.com",
"scopes": ["com.cysharp"]
}
]Add the package from git
Package Manager → + → Add package from git URL:
https://github.com/AndriiSviatenko/GameLoop.git?path=Packages/com.azen.gameloop#v1.0.0
Or import the Unity package
Download Azen.GameLoop-1.0.0.unitypackage from the latest release and import it via Assets → Import Package → Custom Package. It lands in Assets/Azen.GameLoop, samples included as regular folders.
Reference the assembly
Azen.GameLoop is auto-referenced, so code without an asmdef sees it. If your code has an asmdef, add Azen.GameLoop and UniTask to its references.
Quick start
A round timer that counts down while the game plays and stops the round when time is up — in three files.
Write a system
Implement only the interfaces you need. This one wants a callback on init and a call every frame:
public sealed class RoundTimer : IGameElement, IGameInitListener, IGameTickable
{
private readonly IGameManager _game;
public RoundTimer(IGameManager game) => _game = game;
public float RemainingSeconds { get; private set; }
public UniTask OnInit(CancellationToken cancellationToken)
{
RemainingSeconds = 30f;
return UniTask.CompletedTask;
}
public void Tick(float deltaTime)
{
if (RemainingSeconds <= 0f) return;
RemainingSeconds -= deltaTime;
if (RemainingSeconds <= 0f)
_game.StopAsync().Forget();
}
}Create the loop, register, start
Create the loop in Awake, register systems, start in Start. Everything registered before StartAsync receives OnInit and OnStartGame.
public sealed class GameBootstrap : MonoBehaviour
{
private IGameLoop _loop;
private void Awake()
{
_loop = gameObject.AddComponent<GameLoopDriver>()
.Bootstrap(logger: DebugLogGameLoopLogger.Instance);
_loop.Add(new RoundTimer(_loop), GameLoopChannels.Gameplay, GameLoopPriority.Normal);
}
private void Start() => _loop.StartAsync(destroyCancellationToken).Forget();
}Scene objects: inherit GameLoopElement
A MonoBehaviour that needs a Transform registers itself in OnEnable and unregisters in OnDisable. It never gets Unity's Update.
public sealed class SpinningCube : GameLoopElement, IGameTickable
{
private void Awake() => Configure(GameLoopChannels.Gameplay);
public void Tick(float deltaTime) => transform.Rotate(0f, 90f * deltaTime, 0f);
}OnInit/OnStartGame — initialise it in its factory or Awake.Concepts
Game state
| Call | From | Listeners called | Result |
|---|---|---|---|
| StartAsync | Off, Stop | OnInit (first start only), OnStartGame | Play |
| PauseAsync / PauseFor | Play | OnPauseGame — only for the first reason | Pause |
| ResumeAsync / ResumeFor | Pause | OnResumeGame — only when the last reason is removed | Play |
| StopAsync | Play, Pause | OnStopGame | Stop; pause reasons and channel pauses cleared |
| RestartAsync | any | OnStopGame (if running), OnInit, OnStartGame | Play |
| Destroy | any | OnDestroy in reverse priority | Loop is dead; further transitions are ignored |
StateChanged fires on every transition. Restart goes straight from the current state to Play; it does not raise Stop.
Element interfaces
| Interface | Member | Called |
|---|---|---|
| IGameTickable | Tick(float) | Every Update while the channel can tick |
| IGameFixedTickable | FixedTick(float) | Every FixedUpdate, with FixedDeltaTime |
| IGameLateTickable | LateTick(float) | Every LateUpdate, after all ticks of the frame |
| IGameAsyncTickable | TickAsync(float, CancellationToken) | Every Update, but never overlapping itself; the token is cancelled on destroy |
| IGameTickRate | TickInterval | Throttles Tick/FixedTick/LateTick to this interval, in channel time |
| IGameInitListener | OnInit | First start and every restart |
| IGameStartedListener | OnStartGame | Start and restart |
| IGamePausedListener | OnPauseGame | Global pause, or its channel paused |
| IGameResumedListener | OnResumeGame | Global resume, or its channel resumed |
| IGameStoppedListener | OnStopGame | Stop and restart |
| IGameDestroyListener | OnDestroy() | Loop destroyed |
IGameDestroyListener.OnDestroy() has the same name as Unity's message. Do not implement it on a GameLoopElement — override OnDestroy there instead.Phase and priority
The interface picks when in the frame a system runs. Priority picks who goes first among systems of the same channel and phase. Higher numbers run first:
| Constant | Value |
|---|---|
| GameLoopPriority.Critical | 300 |
| GameLoopPriority.High | 200 |
| GameLoopPriority.Normal | 100 |
| GameLoopPriority.Low | 0 |
A High late tick never jumps ahead of a Normal tick — those are different parts of the frame.
Registration
Direct
var handle = loop.Add(enemyAi, GameLoopChannels.Gameplay, GameLoopPriority.High); loop.Remove(handle);
Fluent
var handle = loop.For(enemyAi).Gameplay().High().Add(); loop.Remove(handle);
Adding and removing tickables takes effect at the start of the next pass of that list, so a system can remove itself inside its own Tick.
Transitions and reentrancy
Transitions run one at a time. A call made while another transition is still awaiting its listeners is queued and runs after it; a cancelled queued call leaves the queue without blocking it.
OnStartGame, OnPauseGame and friends throws InvalidOperationException (or logs a warning when ThrowOnReentrancy = false). Awaiting a transition after an await inside a listener deadlocks: both wait for each other. Fire it from a tick or with .Forget().Channels & pause
A channel is a group of systems you want to control together. Built-ins are Default, Gameplay, UI and Cutscene; any string works as a custom channel.
Pause a channel
await loop.PauseChannelAsync(GameLoopChannels.Gameplay, ct); // gameplay stops, UI keeps ticking await loop.ResumeChannelAsync(GameLoopChannels.Gameplay, ct);
- Pausing an already paused channel does nothing;
ChannelStateChangedfires only on a real change. - A paused channel stays paused through a global resume, and is not notified twice by a global pause.
- While the game is not playing,
GetChannelStatereports the global state. - Stop and restart clear every channel pause.
Pause reasons
Global pause is a set of reasons. The game returns to Play only when the set is empty:
await loop.PauseFor(PauseReason.Menu, ct);
await loop.PauseFor(PauseReason.Dialog, ct);
await loop.ResumeFor(PauseReason.Dialog, ct); // still paused: the menu is open
await loop.ResumeFor(PauseReason.Menu, ct); // Play
var tutorial = PauseReason.Custom("Tutorial");| Reason | Kind | Removed by ResumeAllAsync |
|---|---|---|
| Manual, Menu, Dialog | Manual | Yes |
| Focus, Cutscene | System | No — only its own ResumeFor |
PauseAsync/ResumeAsync are shortcuts for the Manual reason. Inherit GameLoopFocusHandlerBase to pause with Focus when the window loses focus.
Time scale
loop.SetChannelTimeScale(GameLoopChannels.Gameplay, 0.25f);
Systems of that channel are still called every frame, with a four times smaller deltaTime. Scheduler timers of the channel slow down too. Time.timeScale, Animator and physics are not touched.
Keep UI alive while paused
loop.SetChannelTickWhenPaused(GameLoopChannels.UI, true);
That channel ticks (and runs its timers) while the game is paused, stopped or not started — so input in it can resume or restart the game.
Scheduler
Delayed and repeating calls in game time. Not a thread and not a coroutine: the scheduler is updated by the loop on the main thread, right after the frame's ticks.
public UniTask OnStartGame(CancellationToken cancellationToken)
{
_spawn = _loop.Every(1.2f, SpawnBall, GameLoopChannels.Gameplay);
return UniTask.CompletedTask;
}
public UniTask OnStopGame(CancellationToken cancellationToken)
{
_loop.Cancel(_spawn);
return UniTask.CompletedTask;
}| Timer channel | Advances when |
|---|---|
| none | The game is in Play |
| a channel | That channel can tick: not channel-paused, and the game plays or the channel ticks when paused. Uses the channel time scale. |
At time scale 0.25 a 1.2 s interval takes 4.8 real seconds — spawn rhythm slows together with movement.
Integration
One composition root, one GameLoopDriver, one loop. A container changes only how the reference is delivered.
Two contracts, one object
IGameManager
State and commands: start, pause, resume, stop, restart, pause reasons. Give it to a pause menu or a round timer.
IGameLoop : IGameManager
Adds registration, channels, time scale and the scheduler. Give it to bootstrap code and system coordinators.
With DI, bind the same instance to both.
VContainer
protected override void Configure(IContainerBuilder builder)
{
var loop = _driver.Bootstrap(logger: DebugLogGameLoopLogger.Instance);
builder.RegisterInstance(loop).As<IGameLoop, IGameManager>();
builder.RegisterEntryPoint<EnemyAi>(); // registers itself in IInitializable
builder.RegisterEntryPoint<GameLoopStarter>(); // StartAsync in IStartable
}Zenject
public override void InstallBindings()
{
var loop = _driver.Bootstrap(logger: DebugLogGameLoopLogger.Instance);
Container.Bind(typeof(IGameLoop), typeof(IGameManager)).FromInstance(loop);
Container.BindInterfacesTo<EnemyAi>().AsSingle();
Container.BindInterfacesTo<GameLoopStarter>().AsSingle();
Container.BindExecutionOrder<GameLoopStarter>(100);
}Systems register themselves in Initialize and remove themselves in Dispose; the starter runs after them, so every system sees OnInit and OnStartGame.
GameLoopElement and a container
GameLoopElement resolves GameLoop.Active by default. To use an injected loop, override ResolveLoop and call TryRegister() once the dependency arrives:
public void Construct(IGameLoop loop)
{
_loop = loop;
TryRegister();
}
protected override IGameLoop ResolveLoop() => _loop;Debugging
GameLoop Debugger
Window → Azen → GameLoop Debugger shows state, sub-state, pause reasons, every channel with its state and time scale, and buttons for each transition in Play Mode.
Profiler markers
| Marker | Covers |
|---|---|
| GameLoop.<Channel>.Tick | All ticks of one channel; also .FixedTick and .LateTick |
| GameLoop.<SystemType> | One system's call |
Errors
An exception in one system is logged and the rest of the frame continues. DebugLogGameLoopLogger prints the message and the exception with its stack trace. Pass throwOnErrors: true to the GameLoop constructor to rethrow instead — the original stack trace is kept.
Performance
- Ticking iterates plain lists of structs sorted by priority; no allocation per frame.
IGameTickRatelowers the frequency of an expensive system — profile first, then throttle.TickBudgetMsstops a channel'sTickpass once the budget is spent. Low-priority systems can then starve every frame; treat it as a safety net, not a scheduler.- Transitions allocate a small snapshot of listeners; they are rare, not per frame.
API reference
IGameManager
| GameState State | Current state; event StateChanged |
| StartAsync / StopAsync / RestartAsync | Lifecycle transitions; each takes a CancellationToken, extensions without one exist |
| PauseAsync / ResumeAsync | Pause and resume with the Manual reason |
| PauseFor / ResumeFor(reason, ct) | Add or remove one pause reason |
| ResumeAllAsync(ct) | Remove every Manual-kind reason |
| IsPausedFor(reason), PauseReasons | Inspect the reason set |
IGameLoop
| Add(element, channel, priority) | Register; returns a GameLoopElementHandle |
| Remove(handle | element), Contains | Unregister / query |
| PauseChannelAsync / ResumeChannelAsync | Pause one channel; event ChannelStateChanged |
| GetChannelState(channel) | Pause if the channel is paused while playing, otherwise the global state |
| Set/GetChannelTimeScale | Finite, ≥ 0 |
| SetChannelTickWhenPaused | Channel ticks while the game is not playing |
| SubState, SetSubState, SubStateChanged | Free-form tag for your own phases (menu, round, results) |
| InitializeAsync, IsInitialized, Destroy | Manual init and teardown |
| ThrowOnReentrancy, TickBudgetMs | Safety settings |
| Scheduler | Every, Once, Cancel, CancelAll, Clear |
Extensions
| loop.For(element) | Fluent registration: .On(channel), .Gameplay(), .UI(), .Cutscene(), .Priority(n), .High()…, .Add() |
| loop.Every / Once / Cancel | Scheduler shortcuts |
| manager.StartAsync() … | Transitions without a token |
Unity types
| GameLoopDriver | Bootstrap(timeProvider, logger) creates the loop; forwards Update/FixedUpdate/LateUpdate; destroys the loop on OnDestroy |
| GameLoopElement | Self-registering MonoBehaviour: Configure(channel, priority), TryRegister, TryUnregister, ResolveLoop, Handle |
| GameLoopFocusHandlerBase | Pauses with PauseReason.Focus; override the GameLoop property |
| GameLoop constructor | (ITimeProvider, IGameLoopLogger, throwOnErrors, throwOnReentrancy, tickBudgetMs) |
| ITimeProvider, IGameLoopLogger | Replace time and logging, e.g. in tests |
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
A system never gets OnStartGame | It was registered after StartAsync | Register in Awake, start in Start; or initialise it yourself |
InvalidOperationException: GameLoop reentrancy | A listener called a transition synchronously | Call it from a tick, or after the listener finished |
| The game hangs on a transition | A listener awaits another transition | Do not await transitions inside listeners |
| Input stops working on pause | The input system is in a channel that does not tick when paused | SetChannelTickWhenPaused(UI, true) |
| Timer never fires | The game or its channel is not playing | Check GetChannelState; pick the right channel |
| Slow-mo does not affect physics | Channel time scale only scales deltaTime passed to systems | Use Time.timeScale for engine systems |
| A system ticks twice | It was added twice | Keep the handle; check Contains before adding |
| “Cysharp could not be found” | UniTask is missing | Add the OpenUPM registry (see Install) |