Godot setup
FlowTask.Godot runs a World from the Godot 4 (.NET) SceneTree. This page covers registering FlowWorldNode as an autoload and starting flows, plus ordering within a frame, pausing, time, threads, unhandled exceptions, and shutdown.
Installation
Section titled “Installation”How to install the package is covered in Installation. Adding FlowTask.Godot to your game’s .csproj also brings in the core FlowTask and the analyzers.
It runs on the .NET build of Godot 4.4.1 or later. The game project targets net8.0, and uses the netstandard2.1 build of the core (the features and public API are the same). Keep the versions of the engine and Godot.NET.Sdk in sync.
Making FlowWorldNode an autoload
Section titled “Making FlowWorldNode an autoload”A Godot autoload needs a script inside the project (res://...). A library class cannot be registered directly, so add a one-line subclass and register that.
// res://FlowAutoload.cs (the class name must match the file name)public partial class FlowAutoload : Katout.FlowTask.Godot.FlowWorldNode { }; project.godot[autoload]FlowAutoload="*res://FlowAutoload.cs"Put it first in the autoload list. That way, FlowWorldNode.Default is also available from the _Ready of the other autoloads.
If you don’t use an autoload (a test scene, a World per scene, and so on), add it from code. The World is created in _EnterTree, so you can use it right after AddChild.
var flow = new FlowWorldNode();AddChild(flow);flow.World.Run(MyFlow());Starting flows
Section titled “Starting flows”// res://Enemy.csusing Katout.FlowTask;using Katout.FlowTask.Godot;using Godot;
public partial class Enemy : Node2D{ public override void _Ready() { // A root flow that runs while this node is in the tree this.RunWhileInTree(EnemyAI()); }
async FlowTask EnemyAI() { while (true) { Position += Vector2.Right; await FlowTask.NextFrame(); } }}RunWhileInTree stops the flow when the node leaves the tree. How to tie the lifetimes of nodes and flows together is covered in Node lifetime.
Start flows that are not tied to a node with FlowWorldNode.Default.Run(...).
FlowWorldNode.Instanceis the firstFlowWorldNodethat entered the tree (normally the autoload).FlowWorldNode.Defaultis its World.- When that node leaves the tree,
Instancegoes back to null. Meanwhile,DefaultthrowsInvalidOperationException. The nextFlowWorldNodeto enter the tree takes overInstance. - You can also get each
FlowWorldNode’s World from that node’sWorldproperty.
Even with both using Godot; and using Katout.FlowTask;, Godot’s Signal (a struct with no type parameter) and FlowTask’s Signal<T> (a class with a type parameter) do not clash. You can write [Signal] and new Signal<int>() as is.
Putting Node subclasses in a library
Section titled “Putting Node subclasses in a library”Godot 4 (.NET) delivers callbacks such as _EnterTree and _Process to C# through code generated by Godot’s source generators. Overrides in a library built without those generators are not called, even when you subclass them in the game assembly.
If you put a Node, such as a subclass of FlowWorldNode, in your own class library, reference Godot.SourceGenerators as FlowTask.Godot does, and disable only the ScriptPathAttribute generator (a library has no res:// paths).
<PropertyGroup> <GodotProjectDir>$(MSBuildProjectDirectory)\</GodotProjectDir> <GodotDisabledSourceGenerators>ScriptPathAttribute</GodotDisabledSourceGenerators></PropertyGroup><ItemGroup> <PackageReference Include="Godot.SourceGenerators" Version="4.4.1" PrivateAssets="all" /></ItemGroup>Subclasses placed inside the game project (such as FlowAutoload above) don’t need this setup.
Ordering within a frame
Section titled “Ordering within a frame”One Godot frame (outline) Input events ... Button.pressed etc. are emitted (resumes are only reserved) Physics steps x 0..n (a physics World of your own is ticked here; see physics.md) Process FlowWorldNode._Process (ProcessPriority = int.MinValue) -> FlowWorld.Tick(dt) advances time and resumes the waits that are due Other nodes' _Process, Timers, ... FlowLateFlushNode._Process (ProcessPriority = int.MaxValue, an internal child node) -> FlowWorld.Flush() runs the reserved resumes; does not advance time End of frame CallDeferred calls, deletion of QueueFree'd nodes (tree_exiting)- Flows run before the
_Processof other nodes. A flow waiting on a Godot signal emitted by input resumes in the same frame’s Tick. - Flows waiting on a Godot signal emitted inside another node’s
_Process(such as a Timer’stimeout), or on aSignal<T>Emitted there, also resume within the same frame, at the Flush at the end of the frame. When no resumes are scheduled, Flush does nothing, so there is no setting to turn it on or off. - A Godot signal or a Task completion never resumes a flow synchronously. Resumption always happens in the World’s Tick or Flush on the main thread.
Tree pause
Section titled “Tree pause”FlowWorldNode’s ProcessMode defaults to Always. Flows keep running while SceneTree.Paused = true. This is so you can write UI such as pause menus as flows.
To pause gameplay, use a Clock’s Pause() rather than pausing the tree (see Time and Clocks). How to make the tree pause follow a Clock’s pause is shown in the pause scenario in Samples.
ProcessMode is a default set in the constructor, so you can change it in the Inspector or in a subclass. With Pausable (or Disabled), the whole World stops while the tree is paused.
- Neither the Tick nor the Flush at the end of the frame runs, so time and frame waits do not advance.
- A flow waiting on a signal emitted during the pause resumes on the first Tick after the pause ends.
- Time from frames that were not processed is not added to the Clocks.
- The exception is a node bound with
RunWhileInTreeorWhileInTreeleaving the tree. To unwind its flows, FlowTask flushes right then. This Flush also runs the other resumes scheduled during the pause (see Node lifetime).
FlowWorldNode calls Tick with Godot’s unscaled time. This is the same process step that advances Timers, SceneTreeTimers, and Tweens set to ignore_time_scale. While Engine.TimeScale is greater than 0, it is computed by dividing the frame’s delta by Engine.TimeScale.
DefaultClock.TimeScaleis set toEngine.TimeScaleevery frame, soDefaultClockadvances by the same amount as Godot’s delta.UnscaledClockdoes not followEngine.TimeScale.- If you change
DefaultClock.TimeScaleyourself, it is overwritten every frame. Do your own slow motion with a child Clock (world.CreateClock("Game"); if you omit the parent, it becomes a child ofDefaultClock). - If an
Engine.TimeScalewould make the product with a child Clock’s scale non-finite, the Clock rejects it withArgumentOutOfRangeException. For that frame, the error goes toGD.PushError, andDefaultClockticks with its previous scale. The World does not stop. - In normal, non-fixed runs, Godot’s step is a smoothed version of real time. Frame by frame, it differs slightly from real time.
How to use Clocks is covered in Time and Clocks.
When the scale is 0
Section titled “When the scale is 0”While Engine.TimeScale = 0, Godot’s delta is 0, so the step cannot be computed. Instead, the World ticks with the actual time elapsed since the previous frame (the difference in Time.GetTicksUsec). UnscaledClock keeps advancing while the scale is 0.
If you change Engine.TimeScale earlier in the frame than the Tick (in _PhysicsProcess, input callbacks, and so on), that frame’s delta was produced with the old scale. So for that frame only, UnscaledClock advances by a factor of “old scale ÷ new scale”. From the next frame on, it lines up again.
After a long frame
Section titled “After a long frame”When a hitch or breakpoint causes a long frame, the time is clamped to Engine.MaxPhysicsStepsPerFrame / Engine.PhysicsTicksPerSecond seconds before the scale is applied. This is the limit Godot puts on the number of physics steps per frame, 8 / 60 = 0.133 seconds by default. It keeps all the waits from finishing at once.
Godot’s ignore_time_scale timers advance with the unclamped time. Only after a long frame do they drift from UnscaledClock.
Note: Timed waits that pass their deadline within a single Tick complete in the order they started waiting, not in order of earliest deadline. If both branches of a
Raceare timed waits and both deadlines pass within one Tick, the earlier branch wins. See Execution model for details.
Fixed frame rate (--fixed-fps, Movie Maker)
Section titled “Fixed frame rate (--fixed-fps, Movie Maker)”When you run Godot with --fixed-fps <fps>, or record with Movie Maker (--write-movie, which forces --fixed-fps), Godot’s delta is 1 / fps × Engine.TimeScale regardless of the time actually taken. FlowWorldNode ticks with this step. Even if a frame takes longer than 1 / fps seconds to process, DefaultClock and UnscaledClock stay in step with Godot’s time (Timers, Tweens, AnimationPlayer, and a physics World).
Scripts cannot detect --fixed-fps, so the same rules apply however the game is run. There are three things to watch for.
- If
1 / fpsexceeds the long-frame limit (below 7.5 fps with the default settings), both Clocks are clamped to the limit and fall behind Godot’s time. To change the limit, overrideGetDeltaTimebelow. - While
Engine.TimeScale = 0,UnscaledClockadvances with the actual elapsed time. It drifts from Godot’signore_time_scaletimers, which keep advancing by the fixed step. To pause the game, use a Clock’sPause()rather than a scale of 0. - Time limits such as
FlowTask.Race(x, FlowTask.WaitForSeconds(n))are also measured in Clock time, that is, Godot’s time. In runs that go faster than real time (such as a light scene in a headless--fixed-fpsrun), the limit is shorter in real time. Put limits that should be in real time, such as on saving or networking, on the side of the Task you bridge withFlowBridge.FromTask(CancellationTokenSource.CancelAfter,HttpClient.Timeout) (see “Timeouts” in Composition).
Changing the time source
Section titled “Changing the time source”The value passed to Tick is decided by protected virtual double GetDeltaTime(double unscaledDelta). This is the only hook for replacing the time source.
A game that wants to advance with the actual elapsed (wall-clock) time overrides it in the autoload subclass like this.
// res://FlowAutoload.cs: ticks with the measured (wall-clock) time instead of Godot's unscaled stepusing System;using Godot;
public partial class FlowAutoload : Katout.FlowTask.Godot.FlowWorldNode{ protected override double GetDeltaTime(double unscaledDelta) => Math.Min(unscaledDelta, (double)Engine.MaxPhysicsStepsPerFrame / Engine.PhysicsTicksPerSecond);}- The
unscaledDeltaargument is the actual time elapsed since the previous frame (the difference inTime.GetTicksUsec). Only on the first frame, and after frames in which the node was not processed (a tree pause), is it Godot’s delta ÷Engine.TimeScale(0 if the scale is 0). Time during which the node was not processed is not included. - The default implementation returns Godot’s delta ÷
Engine.TimeScaleif the scale is greater than 0, orunscaledDeltaif it is 0, clamped to the long-frame limit. - With the override above, the Clocks advance in real time, so at a fixed frame rate they run ahead of Godot’s time.
- To change only the limit, return
Engine.TimeScale > 0 ? GetProcessDeltaTime() / Engine.TimeScale : unscaledDelta, clamped to your own limit. - Other time sources, such as replaying recorded dt values, can be supplied through the same hook. You can also own the World yourself and call
Tick(the same shape as in Running on physics frames).
World settings
Section titled “World settings”Configure the World (OnUnhandledException, OnWarning, Clocks) by overriding CreateWorld(string name) in the autoload subclass.
public partial class FlowAutoload : Katout.FlowTask.Godot.FlowWorldNode{ protected override FlowWorld CreateWorld(string name) { var world = base.CreateWorld(name); // configure the World here (OnUnhandledException, OnWarning, clocks) return world; }}The World is created when the node enters the tree, and is disposed with Dispose when it leaves. If the same node enters the tree again, a new World is created.
Threads
Section titled “Threads”- The World of a
FlowWorldNoderuns on Godot’s main thread. Flows resume only on that thread. - Move heavy computation to another thread with
FlowBridge.FromTask(ct => Task.Run(() => Work(), ct)). Whatever thread a bridged Task finishes on, the flow resumes on the World’s thread. - From other threads, you can hand things to the World with
Signal<T>.EmitFromAnyThread,CloseFromAnyThread,FlowWorld.Post, and a handle’sCancel(). - For Godot signals emitted on another thread, see Godot signals.
Godot’s main thread has a GodotSynchronizationContext. If you await without ConfigureAwait(false) inside a FlowBridge.FromTask factory, the continuation goes back to the main thread.
- This is fine in a normal game.
- But code that blocks the main thread while waiting for a Task to complete (such as a test that spins Tick in a loop) never finishes, because the continuation never runs. In such code, add
ConfigureAwait(false)to the awaits inside the factory. Blocking on theAsTask()Task with.ResultorWait()on the main thread never finishes even withConfigureAwait(false), because Tick stops too (Flows and the World).
The general rules are in Threads.
Unhandled exceptions and warnings
Section titled “Unhandled exceptions and warnings”Unhandled exceptions (exceptions no flow caught that reach the root) are written to Godot’s log.
- They go to
GD.PushErrorin the form[FlowTask] Unhandled at 'Game > InGame > Battle': <exception>, with the scope path (the report’s kind appears whereUnhandledis). FlowExceptionKind.Undelivered(an exception with no one left to receive it) is written as a warning withGD.PushWarning. It happens in normal play, for example when the waiting flow was already canceled or theRacewas already decided, and logging it as an error would bury real errors.- For every kind, the
FlowWorldNode.UnhandledExceptionevent fires after the output.
The kinds of reports and how to read FlowExceptionInfo are covered in Failures.
To handle kinds differently, set OnUnhandledException in the CreateWorld override and branch on FlowExceptionInfo.Kind.
protected override FlowWorld CreateWorld(string name){ var world = base.CreateWorld(name); world.OnUnhandledException = info => { if (info.Kind == FlowExceptionKind.Undelivered) GD.PushWarning($"[FlowTask] {info.Kind} at '{info.ScopePath}': {info.Exception}"); else MyCrashReporter.Report(info.Exception, info.ScopePath); // your crash reporter }; return world;}Setting OnUnhandledException replaces both the default logging and the FlowWorldNode.UnhandledException event (the default handler is what fires the event). If you have code subscribed to that event, do the same thing inside your own handler.
Note: If an
UnhandledExceptionevent handler throws during the World’sDispose,_ExitTreethrowsFlowUnhandledException. The World is still closed, and the nextFlowWorldNodeto enter the tree takes overDefault.
Warnings (FlushLimit, PausedOwnClock, LongCleanup, CleanupCutAtDispose) are written to GD.PushWarning("[FlowTask] ...") once per kind per World. What the warnings mean is covered in Debugging and diagnostics.
To find stuck flows, bind GD.Print(FlowWorldNode.Default.Dump()) to a debug key. How to read the dump is also in Debugging and diagnostics.
Shutdown
Section titled “Shutdown”FlowWorldNode disposes its World with Dispose in _ExitTree, unwinding every flow (finally, using, and AddCleanup run).
When the game quits, the direct children of the root get _ExitTree in reverse order. Nodes of the main scene, which was added after the autoloads, are already out of the tree at this point (though not yet freed).
- If you touch nodes during unwinding, check
IsInstanceValidandIsInsideTree. - Flows tied to nodes of the main scene unwind at that node’s
tree_exiting, while the node is still in the tree, as long as you started them withRunWhileInTreeorWhileInTree(see Node lifetime).
Saving on quit
Section titled “Saving on quit”Dispose does not run Ticks. An await inside finally only runs up to its first await, and awaits in Flow.NonCancelable are cut too, with a CleanupCutAtDispose warning (see Failures).
For work that must run to the end, such as saving, set SceneTree.AutoAcceptQuit to false, receive the close request (NotificationWMCloseRequest), and call GetTree().Quit() after the save finishes.
// res://SaveOnQuit.cs: an autoload placed after FlowAutoloadusing Katout.FlowTask;using Katout.FlowTask.Godot;using Godot;
public interface IGameSave // your save system{ FlowTask Write(); // saves asynchronously void WriteNow(); // saves synchronously}
public partial class SaveOnQuit : Node{ public IGameSave Save { get; set; } // set by the code that starts the session FlowHandle _saving; bool _quitRequested; bool _quitting;
public override void _Ready() { GetTree().AutoAcceptQuit = false; // the close button only sends NotificationWMCloseRequest ProcessMode = ProcessModeEnum.Always; // quits also while the tree is paused }
public override void _Notification(int what) { if (what == NotificationWMCloseRequest && !_quitRequested) { _quitRequested = true; // Runs to the end, or gives up after 5 s; on the UnscaledClock, so also while Engine.TimeScale is 0. var world = FlowWorldNode.Default; _saving = world.Run(FlowTask.Race(Save.Write(), FlowTask.WaitForSeconds(5.0)).WithoutResult(), world.UnscaledClock); } else if (what == NotificationApplicationPaused) { Save.WriteNow(); // Android and iOS: the OS can end an app in the background } }
public override void _Process(double delta) { if (!_quitRequested || _quitting || !_saving.IsCompleted) return; _quitting = true; GetTree().Quit(); // outside flow code: quitting disposes the World in _ExitTree }}- Call
GetTree().Quit()outside flow code (in_Process). - On Android and iOS, save synchronously on
NotificationApplicationPaused, sent when the app moves to the background. As Godot’s documentation says, on iOS you only get about 5 seconds for this. - Quitting with the Android back button (when
SceneTree.QuitOnGoBackis true) does not go through the close request notification. To save on the back button as well, setQuitOnGoBackto false and start the same steps from your back key handling (BackKeyInputin Samples).
The idea is the same if your setup closes the World along with its FlowWorldNode on every scene change: cancel the flows before closing, run Ticks with a limit, then close (see Failures). The same work in Unity is covered in Unity setup.