Unity 6 · game-loop kernel · v1.0

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.

Unity 6000.0+ UniTask DI-agnostic EditMode tests MIT
01Overview

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.

Unity
PlayerLoop
Update, FixedUpdate, LateUpdate.
Bridge
GameLoopDriver
The only MonoBehaviour that receives the phases.
Facade
GameLoop
State machine, pause reasons, scheduler.
Channels
Groups
Pause, time scale and priority per channel.
You
Systems
Plain C# classes or MonoBehaviours.

Features

Lifecycle
One state machine
Off → Play ⇄ Pause → Stop, plus restart. Systems react through small listener interfaces with async callbacks.
Ticking
Phases & priorities
Tick, FixedTick, LateTick, TickAsync. Higher priority runs first inside a phase.
Channels
Pause a part of the game
Freeze Gameplay while UI keeps ticking. Slow down one channel without touching Time.timeScale.
Pause
Pause reasons
Menu over dialog over focus loss: the game resumes only when every reason is gone.
Timers
Scheduler
Every and Once live in game time: they respect channel pause and time scale.
Safety
Queued transitions
A pause requested while a start is still awaiting its listeners runs right after it, instead of throwing.
Tooling
Debugger & Profiler
Live state window, plus Profiler markers per channel and per system type.
Integration
No container required
Works with a plain composition root, VContainer or Zenject — samples for all three.

Samples

SampleWhat it showsCompiles when
Manual BootstrapDriver, registration, start from a MonoBehaviourAlways
VContainerLifetimeScope, systems as entry points, starter after IInitializablejp.hadashikick.vcontainer is installed
ZenjectMonoInstaller, BindExecutionOrder for the startercom.svermeulen.extenject is installed or AZEN_ZENJECT is defined
Demo repositoryFalling balls, round timer, score, every pause mode on keysOpen Assets/Demo/Scenes/DemoScene in the repository
02Setup

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:

Packages/manifest.json
"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:

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.

03Tutorial

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:

RoundTimer.cs
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.

GameBootstrap.cs
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.

SpinningCube.cs
public sealed class SpinningCube : GameLoopElement, IGameTickable
{
    private void Awake() => Configure(GameLoopChannels.Gameplay);

    public void Tick(float deltaTime) => transform.Rotate(0f, 90f * deltaTime, 0f);
}
Spawned during Play? Lifecycle callbacks are not replayed. An object spawned mid-game gets ticks and future transitions, but not OnInit/OnStartGame — initialise it in its factory or Awake.
04Model

Concepts

Game state

Off→ StartAsync → Play⇄ Pause / Resume ⇄ Pause→ StopAsync → Stop
CallFromListeners calledResult
StartAsyncOff, StopOnInit (first start only), OnStartGamePlay
PauseAsync / PauseForPlayOnPauseGame — only for the first reasonPause
ResumeAsync / ResumeForPauseOnResumeGame — only when the last reason is removedPlay
StopAsyncPlay, PauseOnStopGameStop; pause reasons and channel pauses cleared
RestartAsyncanyOnStopGame (if running), OnInit, OnStartGamePlay
DestroyanyOnDestroy in reverse priorityLoop 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

InterfaceMemberCalled
IGameTickableTick(float)Every Update while the channel can tick
IGameFixedTickableFixedTick(float)Every FixedUpdate, with FixedDeltaTime
IGameLateTickableLateTick(float)Every LateUpdate, after all ticks of the frame
IGameAsyncTickableTickAsync(float, CancellationToken)Every Update, but never overlapping itself; the token is cancelled on destroy
IGameTickRateTickIntervalThrottles Tick/FixedTick/LateTick to this interval, in channel time
IGameInitListenerOnInitFirst start and every restart
IGameStartedListenerOnStartGameStart and restart
IGamePausedListenerOnPauseGameGlobal pause, or its channel paused
IGameResumedListenerOnResumeGameGlobal resume, or its channel resumed
IGameStoppedListenerOnStopGameStop and restart
IGameDestroyListenerOnDestroy()Loop destroyed
Naming trap: 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:

ConstantValue
GameLoopPriority.Critical300
GameLoopPriority.High200
GameLoopPriority.Normal100
GameLoopPriority.Low0

A High late tick never jumps ahead of a Normal tick — those are different parts of the frame.

Registration

Direct

C#
var handle = loop.Add(enemyAi, GameLoopChannels.Gameplay, GameLoopPriority.High);
loop.Remove(handle);

Fluent

C#
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.

Do not await a transition from a listener. A synchronous call from inside 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().
05Control

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

C#
await loop.PauseChannelAsync(GameLoopChannels.Gameplay, ct);   // gameplay stops, UI keeps ticking
await loop.ResumeChannelAsync(GameLoopChannels.Gameplay, ct);
  • Pausing an already paused channel does nothing; ChannelStateChanged fires 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, GetChannelState reports 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:

C#
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");
ReasonKindRemoved by ResumeAllAsync
Manual, Menu, DialogManualYes
Focus, CutsceneSystemNo — 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

C#
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

C#
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.

06Time

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.

BallSpawner.cs
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 channelAdvances when
noneThe game is in Play
a channelThat 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.

07Wire it up

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

GameLoopLifetimeScope.cs
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

GameLoopInstaller.cs
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:

FallingBall.cs
public void Construct(IGameLoop loop)
{
    _loop = loop;
    TryRegister();
}

protected override IGameLoop ResolveLoop() => _loop;
08Inspect

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

MarkerCovers
GameLoop.<Channel>.TickAll 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.

09Budget

Performance

  • Ticking iterates plain lists of structs sorted by priority; no allocation per frame.
  • IGameTickRate lowers the frequency of an expensive system — profile first, then throttle.
  • TickBudgetMs stops a channel's Tick pass 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.
10Look up

API reference

IGameManager

GameState StateCurrent state; event StateChanged
StartAsync / StopAsync / RestartAsyncLifecycle transitions; each takes a CancellationToken, extensions without one exist
PauseAsync / ResumeAsyncPause 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), PauseReasonsInspect the reason set

IGameLoop

Add(element, channel, priority)Register; returns a GameLoopElementHandle
Remove(handle | element), ContainsUnregister / query
PauseChannelAsync / ResumeChannelAsyncPause one channel; event ChannelStateChanged
GetChannelState(channel)Pause if the channel is paused while playing, otherwise the global state
Set/GetChannelTimeScaleFinite, ≥ 0
SetChannelTickWhenPausedChannel ticks while the game is not playing
SubState, SetSubState, SubStateChangedFree-form tag for your own phases (menu, round, results)
InitializeAsync, IsInitialized, DestroyManual init and teardown
ThrowOnReentrancy, TickBudgetMsSafety settings
SchedulerEvery, Once, Cancel, CancelAll, Clear

Extensions

loop.For(element)Fluent registration: .On(channel), .Gameplay(), .UI(), .Cutscene(), .Priority(n), .High()…, .Add()
loop.Every / Once / CancelScheduler shortcuts
manager.StartAsync() …Transitions without a token

Unity types

GameLoopDriverBootstrap(timeProvider, logger) creates the loop; forwards Update/FixedUpdate/LateUpdate; destroys the loop on OnDestroy
GameLoopElementSelf-registering MonoBehaviour: Configure(channel, priority), TryRegister, TryUnregister, ResolveLoop, Handle
GameLoopFocusHandlerBasePauses with PauseReason.Focus; override the GameLoop property
GameLoop constructor(ITimeProvider, IGameLoopLogger, throwOnErrors, throwOnReentrancy, tickBudgetMs)
ITimeProvider, IGameLoopLoggerReplace time and logging, e.g. in tests
11Fix

Troubleshooting

SymptomCauseFix
A system never gets OnStartGameIt was registered after StartAsyncRegister in Awake, start in Start; or initialise it yourself
InvalidOperationException: GameLoop reentrancyA listener called a transition synchronouslyCall it from a tick, or after the listener finished
The game hangs on a transitionA listener awaits another transitionDo not await transitions inside listeners
Input stops working on pauseThe input system is in a channel that does not tick when pausedSetChannelTickWhenPaused(UI, true)
Timer never firesThe game or its channel is not playingCheck GetChannelState; pick the right channel
Slow-mo does not affect physicsChannel time scale only scales deltaTime passed to systemsUse Time.timeScale for engine systems
A system ticks twiceIt was added twiceKeep the handle; check Contains before adding
“Cysharp could not be found”UniTask is missingAdd the OpenUPM registry (see Install)