Unity setup
This page explains how FlowTask runs in Unity and how to configure it. You will learn how the default World runs on the PlayerLoop, how time advances, what happens when Play Mode ends or the domain reloads, and how unhandled exceptions show up in the Console.
Installing the packages (what to write in Packages/manifest.json, the optional packages) is covered in Installation. This page is about what comes after installation.
Your first flow
Section titled “Your first flow”Once installed, a default FlowWorld is created at startup and runs every frame on the PlayerLoop. Start a flow with FlowTaskUnity.World.Run.
using Katout.FlowTask;using Katout.FlowTask.Unity;using UnityEngine;
public sealed class Boot : MonoBehaviour{ void Start() => FlowTaskUnity.World.Run(Game());
static async FlowTask Game() { await FlowTask.WaitForSeconds(1.0); // DefaultClock advances like Time.deltaTime // ... }}The World is created at RuntimeInitializeLoadType.SubsystemRegistration, so it exists before any Awake. Reading FlowTaskUnity.World when there is no World (in Edit Mode, after Shutdown, after a recompile during play) throws an InvalidOperationException that names the cause. Check whether there is one with FlowTaskUnity.IsInstalled. The other examples on this page assume the same three using directives (Katout.FlowTask, Katout.FlowTask.Unity, UnityEngine), plus using System; for examples that use Math or ArgumentOutOfRangeException.
If you want a flow to stop when its GameObject is deactivated, start it with gameObject.RunWhileActive instead of World.Run (see GameObject lifetime).
Packages and assemblies
Section titled “Packages and assemblies”FlowTask supports Unity 2023.1 and later. It is tested in the Unity 6 (6000.3) Editor (EditMode and PlayMode), in Windows Mono and IL2CPP players, and with an Android IL2CPP build (built, but not run on a device). It has not been tested on 2023.1 or 2023.2 themselves.
The package is split into the following assemblies (asmdefs). If your code lives in an asmdef, add the assemblies you use to its references.
| asmdef | References | Compiled when |
|---|---|---|
FlowTask |
None (noEngineReferences) |
Always |
FlowTask.Unity |
FlowTask | Always. PlayerLoop integration, bridges, GameObject lifetime |
FlowTask.Unity.UI |
FlowTask, FlowTask.Unity, UnityEngine.UI | com.unity.ugui is present (FLOWTASK_UGUI) |
FlowTask.Unity.Editor |
FlowTask, FlowTask.Unity | Editor only. The Scope Tree window |
FlowTask.Testing |
FlowTask | Editor and test players only (UNITY_INCLUDE_TESTS). autoReferenced: false |
FlowTask.Testing.NUnit |
FlowTask, FlowTask.Testing, nunit.framework.dll |
Same as above |
FlowTask.UniTask |
FlowTask, UniTask | com.cysharp.unitask 2.0.0 or later is present (FLOWTASK_UNITASK) |
- If the package they depend on is missing,
FlowTask.Unity.UIandFlowTask.UniTaskare silently left out of compilation (no error). - If you installed UniTask as a
.unitypackageintoAssets/Plugins/UniTask, Unity does not see it as a package, soFLOWTASK_UNITASKis not defined and code that usesFlowUniTaskfails withCS0246. AddFLOWTASK_UNITASKfor each platform under Player Settings > Other Settings > Scripting Define Symbols. - The two testing assemblies are compiled only for packages listed in the manifest’s
testables(see Installation).
C# 10 and csc.rsp
Section titled “C# 10 and csc.rsp”The package source is written in C# 10. Unity defaults to C# 9, so each asmdef has a csc.rsp next to it containing the single line -langversion:10.0, which raises the language version for that assembly only. The language version of your own code stays at the default.
Some IDEs show the C# 10 syntax in the package source as errors. The Rider package and the Visual Studio package 2.0.24 or later carry the csc.rsp language version into the csproj files that Unity generates for the IDE. If your IDE does not and shows errors, they do not affect Unity’s compilation.
.meta files
Section titled “.meta files”Packages installed from git are read-only, so the repository includes a .meta file for every file and folder in the package. If you embed the package in Packages/ and modify it, move the .meta files along with any file you rename or move (the GUID is the asset’s identity).
The package folders also contain csproj files for .NET. Unity imports them as plain assets, and they play no part in compilation.
Tick and Flush
Section titled “Tick and Flush”The default World ticks once per frame, at the start of the Update phase (just before Update.ScriptRunBehaviourUpdate). It also calls FlowWorld.Flush() at the end of the Update phase and at the end of the PreLateUpdate phase. Flush does not advance time; it processes the resumptions scheduled up to that point (Emit, FlowProperty.Set, UnityEvents, completed bridges).
EarlyUpdate inputFixedUpdate physicsUpdate [FlowTask Tick] -> MonoBehaviour.Update, uGUI clicks, coroutines -> [Flush]PreLateUpdate MonoBehaviour.LateUpdate, animation events -> [Flush]PostLateUpdate renderingThis order means:
- Tick comes after input and physics, so flows act on the current frame’s state. State changed by flows is visible to
MonoBehaviour.Updatein the same frame. - An Emit from
MonoBehaviour.Update(including uGUI clicks), or from a coroutine or Awaitable continuation that runs in the Update phase (yield return null,NextFrameAsync, and so on), resumes its receivers at the Flush at the end of Update, within the same frame and before rendering. Emits fromLateUpdateand animation events are handled by the Flush at the end of PreLateUpdate. - Emits in FixedUpdate (including continuations of
WaitForFixedUpdateandFixedUpdateAsync) are handled by that frame’s Tick. Emits after PreLateUpdate (continuations ofWaitForEndOfFrameandEndOfFrameAsync, for example) are handled by the next frame’s Tick. To handle them sooner, add Flush points (see “Settings” below). - An empty Flush only reads a few fields, so adding Flush points costs almost nothing.
When you deactivate a GameObject bound with RunWhileActive, FlowTask also flushes right then, apart from the points in the table (see GameObject lifetime).
Each system shows up in the Profiler under the name of a marker type such as FlowTaskUnity.FlowTaskTick.
The ordering rules inside a Tick, such as Emit not resuming receivers synchronously, are in Execution model.
The default World ticks with Time.unscaledDeltaTime (clamped by Time.maximumDeltaTime), and sets World.DefaultClock.TimeScale every frame so that DefaultClock advances by the same amount as that frame’s Time.deltaTime.
var world = FlowTaskUnity.World;await FlowTask.WaitForSeconds(2.0); // DefaultClock: follows Time.timeScale, stops at 0await FlowTask.WaitForSeconds(2.0, world.UnscaledClock); // keeps running while Time.timeScale is 0DefaultClockadvances by exactlyTime.deltaTimeevery frame, including the first frames after startup. It stops atTime.timeScale = 0.UnscaledClockkeeps running whileTime.timeScale = 0. You cannot change its TimeScale or pause it.- Long frames (scene loads, breakpoints) are clamped by
Time.maximumDeltaTime, as in Unity. Timed waits that pass their deadline within a single Tick complete in the order they started waiting, not in deadline order (in argument order for a Race). WaitForSecondsfinishes on the first Tick where the time, summed from dt as a double, reaches the target. Waiting 0.1 seconds at 60 fps finishes on the 7th Tick (six additions of 1/60 fall just short of 0.1). To wait a number of frames, useFlowTask.DelayFrames.
General use of Clocks, Pause, and TimeScale is covered in Time and Clocks.
Your own slow motion and pause
Section titled “Your own slow motion and pause”The integration overwrites DefaultClock.TimeScale every frame, so do not change it yourself. Do slow motion and pause for your game with Clocks that are children of DefaultClock.
var world = FlowTaskUnity.World;var game = world.CreateClock("Game"); // a child of DefaultClock: follows Time.timeScalevar ui = world.CreateClock("UI", world.UnscaledClock); // keeps running while Game is paused and at Time.timeScale 0game.TimeScale = 0.5; // slow motion on top of Time.timeScale-
Run dialogs and menus on the UI Clock (
Flow.WithClock(ui, Menu())). If a menu that callsPause()on the game Clock runs on that same Clock, it also stops its own resumption (you get aPausedOwnClockwarning). -
For slowing down a single enemy, create a temporary Clock with
Flow.CreateClock. It is removed when the scope that created it ends.async FlowTask SlowedAttack(){var slow = Flow.CreateClock("slow", Flow.CurrentClock); // removed when this method's scope endsslow.TimeScale = 0.5;await Flow.WithClock(slow, Attack());}If you call
Flow.CreateClockdirectly inside an AI loop, the Clocks pile up until the loop’s scope (the enemy’s lifetime) ends, so put the call in its own method as above. For a Clock that lives until the World ends, useFlowWorld.CreateClock. -
If the product of
Time.timeScaleand the scales of the child Clocks would become non-finite, setting the scale onDefaultClockis rejected withArgumentOutOfRangeException. The integration logs the exception and ticks that frame with the previous scale.
Pausing a Clock stops only the flows on it. To stop Animators and physics with it, set Time.timeScale to 0 while the Clock is paused (PauseLink in Samples).
In depth: frames where DefaultClock.TimeScale differs from Time.timeScale
Section titled “In depth: frames where DefaultClock.TimeScale differs from Time.timeScale”DefaultClock.TimeScale is normally the same as Time.timeScale. But on frames where Time.deltaTime is not “unscaled time × timeScale”, it gets the ratio instead, so that DefaultClock advances by exactly Time.deltaTime. These frames are:
- The first frames after startup. Unity reports a provisional
Time.deltaTime. - Frames where
Time.timeScalewas changed before the Tick (in FixedUpdate, EarlyUpdate, Input System callbacks in PreUpdate, or UniTask continuations atPlayerLoopTiming.Updateor earlier). The new value takes effect from the next frame. - While
Time.captureDeltaTimeis set (when recording at a fixed frame rate).DefaultClockadvances in recording time, whileUnscaledClockand its children advance in real time (Time.unscaledDeltaTime). Do waits that should line up with the recording onDefaultClockor one of its children.
Settings
Section titled “Settings”Settings live in FlowTaskSettings, and you can change them from code or from an asset.
FlowTaskUnity.Configure(new FlowTaskSettings{ FlushPoints = FlowFlushPoints.Default | FlowFlushPoints.AfterFixedUpdate, OnUnhandledException = info => MyCrashReporter.Report(info.Exception, info.ScopePath), // replaces the default logging});| Setting | Meaning | Default |
|---|---|---|
AutoTick |
Whether to Tick at the start of Update. If false, you Tick yourself (the Flush points still run) |
true |
FlushPoints |
Where to Flush. FlowFlushPoints flags: AfterEarlyUpdate, AfterFixedUpdate, AfterUpdate, AfterLateUpdate, EndOfFrame, Default, All, None |
Default (AfterUpdate | AfterLateUpdate) |
AutoInstall |
Whether to create the World at startup and install it into the PlayerLoop. Read only from the asset | true |
OnUnhandledException |
Replaces the handling of unhandled exceptions that reach the root. Code only | See “Unhandled exceptions and warnings” below |
- You can call
Configureat any time.OnUnhandledExceptiontakes effect immediately; changes to Tick and the Flush points take effect from the next frame. Reinstalling withFlowTaskUnity.Configurekeeps the systems that other libraries, such as UniTask, have added to the PlayerLoop. - To configure with an asset, create a
FlowTaskSettingsAssetwithAssets > Create > FlowTask > Settings, name itFlowTaskSettings, and put it in aResourcesfolder. It is applied before the first scene loads. - Order at startup: at
SubsystemRegistrationthe World is created with the default settings (unlessFLOWTASK_DISABLE_AUTO_INSTALLis defined), and the asset is applied atBeforeSceneLoad. If your code callsInitialize,ConfigureorShutdownbefore that (from aRuntimeInitializeOnLoadMethodatAfterAssembliesLoadedorBeforeSplashScreen), the asset is not applied: the settings from code win,OnUnhandledExceptionincluded, and a World you installed stays even if the asset setsAutoInstalltofalse. WithFLOWTASK_DISABLE_AUTO_INSTALLdefined, the asset is not read. - You can read the current settings with
FlowTaskUnity.Settings. It returns a copy: changing it has no effect until you pass it toConfigure.
Turning off automatic startup
Section titled “Turning off automatic startup”If you set AutoInstall to false in the asset, or define the scripting symbol FLOWTASK_DISABLE_AUTO_INSTALL, no World is created automatically. Call FlowTaskUnity.Initialize(settings) yourself to start. To make a World you created the default World, pass it with Initialize(settings, myWorld). A World passed this way is not disposed by Shutdown. If it already has an OnUnhandledException handler, that handler is kept (FlowTaskSettings.OnUnhandledException and the default logging do not apply to it); otherwise the integration installs its handler and Shutdown removes it again.
Ticking yourself
Section titled “Ticking yourself”With AutoTick = false, write the same steps as the default yourself.
void Update(){ var world = FlowTaskUnity.World; FlowLifetime.PollWatched(); // completes WaitForDestroy of objects that never became active if (world.DefaultClock.TimeScale != Time.timeScale) world.DefaultClock.TimeScale = Time.timeScale; world.Tick(Math.Min(Time.unscaledDeltaTime, Time.maximumDeltaTime));}If you pass Time.deltaTime, even UnscaledClock will follow Time.timeScale. Pass unscaled time, and supply the scale through DefaultClock.TimeScale.
Shutdown and domain reload
Section titled “Shutdown and domain reload”FlowTaskUnity.Shutdown() is called when Play Mode ends (before the scene’s objects are destroyed) and when the player quits (Application.quitting). It removes the systems from the PlayerLoop and disposes the World, so every flow unwinds. Dispose does not run Ticks, however, so an await inside finally only runs up to its first await (see “Saving on quit” below).
Even with domain reload disabled in Enter Play Mode Options, the startup hook runs every time you enter Play Mode, cleans up the previous World, and then creates a new one.
Create Signals and similar types per World
Section titled “Create Signals and similar types per World”Signal<T>, FlowProperty<T>, and Once<T> are bound to the World that first uses them. Using one from another World after that World has been disposed throws FlowMisuseException.
// Wrong: with domain reload disabled, the second Play Mode session throwsstatic readonly Signal<int> s_damaged = new("Damaged");If you keep one in a static field, it throws on the second play session when domain reload is disabled. Create it after the World exists (in Start, for example) and pass it to flows as an argument. The samples’ GameClocks and BackKeyRouter follow this pattern (see Samples and Signals).
Recompiling while playing
Section titled “Recompiling while playing”If Preferences > General > Script Changes While Playing is set to “Recompile And Continue Playing”, Unity reloads the domain while still playing when you change a script. FlowTask does not recover from this on its own (the reason is under “Lifetime of engine objects” in Design rationale).
- The default World is gone. Flows started with
RunWhileActiveunwind inOnDisable, which Unity calls before the reload, but other flows vanish without unwinding (theirfinallyblocks do not run). - FlowTask stays stopped until you stop playing or call
FlowTaskUnity.Initialize(). After the reload,FlowTaskUnity.WorldandRunWhileActivethrow anInvalidOperationExceptionthat starts withNo FlowTask World: .... The Editor logs a warning just before the reload, and both this exception’s message and the Scope Tree window point out the cause and the name of the setting. - Starting it again with
Initialize()does not bring back the lost flows.AwakeandStartare not called again either, so the flows they start do not start.
Set this preference to “Recompile After Finished Playing” or “Stop Playing and Recompile”.
Saving on quit
Section titled “Saving on quit”During the Shutdown on quit, awaits in finally are cut at their first await. Awaits in Flow.NonCancelable are cut too, and you get a CleanupCutAtDispose warning. To finish saving before the app quits, cancel the quit once with Application.wantsToQuit, then call Application.Quit() again after the save is done.
// on an object that lives for the whole session (DontDestroyOnLoad)public sealed class SaveOnQuit : MonoBehaviour{ public IGameSave Save { get; set; } // your save system: FlowTask Write(), void WriteNow() FlowHandle _saving; bool _quitRequested; bool _saved;
void OnEnable() => Application.wantsToQuit += WantsToQuit; void OnDisable() => Application.wantsToQuit -= WantsToQuit;
bool WantsToQuit() { if (_saved || Application.isEditor) return true; // the Editor ignores Application.Quit() if (!_quitRequested) { _quitRequested = true; // Runs to the end, or gives up after 5 s; on the UnscaledClock, so also while Time.timeScale is 0. var world = FlowTaskUnity.World; _saving = world.Run(FlowTask.Race(Save.Write(), FlowTask.WaitForSeconds(5.0)).WithoutResult(), world.UnscaledClock); } return false; // not yet: the World keeps running while it saves }
void Update() { if (!_quitRequested || _saved || !_saving.IsCompleted) return; _saved = true; Application.Quit(); // outside flow code: quitting disposes the World }
// Android and iOS: the OS can end an app in the background without wantsToQuit. void OnApplicationPause(bool paused) { if (paused) Save.WriteNow(); // the PlayerLoop stops in the background: save synchronously }}How disposing a World relates to cleanup is covered in Failures.
Unhandled exceptions and warnings
Section titled “Unhandled exceptions and warnings”In the default World, an unhandled exception that reaches the root is written to the Console with Debug.LogException(new FlowTaskUnhandledException(info)). The scope path appears after the original exception and its stack trace.
InvalidOperationException: ... (stack trace)Rethrow as FlowTaskUnhandledException: [Unhandled] at 'Game > InGame > Battle': InvalidOperationException: ...-
Only
FlowExceptionKind.Undelivered(an exception with no one left to receive it: from a canceled flow, from a Race that was already decided, the second and later exceptions, and so on) is logged withDebug.LogWarning. It happens in normal play, and logging it as an error would bury real errors. -
Setting
FlowTaskSettings.OnUnhandledExceptionreplaces all of the output above. To handle kinds differently, checkFlowExceptionInfo.Kind.OnUnhandledException = info =>{if (info.Kind == FlowExceptionKind.Undelivered) Debug.LogWarning($"[FlowTask] {info.Kind} at '{info.ScopePath}': {info.Exception}");else MyCrashReporter.Report(info.Exception, info.ScopePath);}, -
World warnings (
FlushLimit,PausedOwnClock,LongCleanup,CleanupCutAtDispose) are logged once per kind per World withDebug.LogWarning("[FlowTask] ..."). There is no setting to turn them off.
The kinds of reports and how to read them are covered in Failures and Debugging and diagnostics.
Your own World (for physics frames and more)
Section titled “Your own World (for physics frames and more)”To wait for a single physics frame, you don’t need another World: write await Awaitable.FixedUpdateAsync().AsFlow();. The code after it runs not inside FixedUpdate, but at the Tick or Flush that follows.
Run flows that should keep advancing on physics frames in a World that you Tick from your own FixedUpdate. Use a separate World for each driver. The PlayerLoop integration does nothing for this World, so you write the exception and warning output, the Tick, and the Dispose yourself.
[DefaultExecutionOrder(-10000)] // before the FixedUpdate of the other scriptspublic sealed class PhysicsFlows : MonoBehaviour{ public FlowWorld World { get; private set; }
void Awake() { World = new FlowWorld("UnityPhysics"); // Without OnUnhandledException, the outermost Tick, Flush, Run or Dispose throws the reports (FlowUnhandledException). World.OnUnhandledException = info => { if (info.Kind == FlowExceptionKind.Undelivered) Debug.LogWarning($"[FlowTask] {info.Kind} at '{info.ScopePath}': {info.Exception}"); else Debug.LogException(new FlowTaskUnhandledException(info)); }; World.OnWarning += w => Debug.LogWarning("[FlowTask] " + w); FlowWorldRegistry.Register(World); // shown in the Scope Tree window }
void FixedUpdate() { try { if (World.DefaultClock.TimeScale != Time.timeScale) World.DefaultClock.TimeScale = Time.timeScale; } catch (ArgumentOutOfRangeException ex) { Debug.LogException(ex); // rejected: keep the previous scale and tick anyway } World.Tick(Time.fixedUnscaledDeltaTime); }
void OnDestroy() { FlowWorldRegistry.Unregister(World); World.Dispose(); // unwinds the physics flows }}- Without
OnUnhandledException, unhandled exceptions and the other reports are thrown from the outermostTick,Flush,RunorDisposeasFlowUnhandledException. - In this World,
NextFrame()andDelayFrames(n)count physics frames. FixedUpdateis not called whileTime.timeScale = 0, so this World’sUnscaledClockstops too. Run waits that must keep going during a pause in the default World.- Scopes have no parent-child relationship across Worlds. To tie a flow started in another World to this lifetime, keep its handle and write
Flow.AddCleanup(handle.Cancel). - When passing
Signal<T>between Worlds, the sending side callsClose()when it ends, and the receiving side waits withNextOrClosed(). A pendingNext()does not finish when the other World is disposed. Use the Signal in the longer-lived World first (see “Create Signals and similar types per World” above). - If you pass a Clock of this World (
World.DefaultClock, for example) as the second argument ofRunWhileActive, its flows can also be tied to a GameObject’s lifetime (see GameObject lifetime).WhileActivein flow code runs in the World of the awaiting flow.
The Tick steps and the threading rules are covered in Flows and the World and Threads.
Analyzers
Section titled “Analyzers”The core package includes analyzers (Analyzers/FlowTask.Analyzers.dll), which run when compiling every assembly that references FlowTask. The rules that are errors by default (FLOW001, FLOW002, FLOW005) also stop Unity’s compilation. The list of rules and how to change their severity in Unity are in Analyzers.
IL2CPP and builds
Section titled “IL2CPP and builds”- The core does not use runtime code generation (
Reflection.Emit), reflection, or generic virtual methods. It works as is under IL2CPP and Managed Stripping (the async method builders are also instantiated ahead of time). - Allocation-free behavior applies to optimized builds (Release players, and the Editor with Code Optimization set to Release). A Development Build compiles scripts without optimization, which turns async state machines into classes, so every call to an
async FlowTaskmethod allocates. To measure allocations in a Development Build, add-optimize+to thecsc.rspnext to your asmdef. GC.GetAllocatedBytesForCurrentThread()does not work in Unity (always 0 on Mono, not implemented on IL2CPP). Check allocations with the Profiler’s GC Alloc column or withProfilerRecorder("GC Allocated In Frame").
Allocation costs are covered in Performance and memory.