Skip to content

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.

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.

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());
// res://Enemy.cs
using 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.Instance is the first FlowWorldNode that entered the tree (normally the autoload). FlowWorldNode.Default is its World.
  • When that node leaves the tree, Instance goes back to null. Meanwhile, Default throws InvalidOperationException. The next FlowWorldNode to enter the tree takes over Instance.
  • You can also get each FlowWorldNode’s World from that node’s World property.

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.

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.

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 _Process of 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’s timeout), or on a Signal<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.

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 RunWhileInTree or WhileInTree leaving 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.TimeScale is set to Engine.TimeScale every frame, so DefaultClock advances by the same amount as Godot’s delta. UnscaledClock does not follow Engine.TimeScale.
  • If you change DefaultClock.TimeScale yourself, 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 of DefaultClock).
  • If an Engine.TimeScale would make the product with a child Clock’s scale non-finite, the Clock rejects it with ArgumentOutOfRangeException. For that frame, the error goes to GD.PushError, and DefaultClock ticks 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.

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.

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 Race are 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 / fps exceeds 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, override GetDeltaTime below.
  • While Engine.TimeScale = 0, UnscaledClock advances with the actual elapsed time. It drifts from Godot’s ignore_time_scale timers, which keep advancing by the fixed step. To pause the game, use a Clock’s Pause() 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-fps run), 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 with FlowBridge.FromTask (CancellationTokenSource.CancelAfter, HttpClient.Timeout) (see “Timeouts” in Composition).

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 step
using 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 unscaledDelta argument is the actual time elapsed since the previous frame (the difference in Time.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.TimeScale if the scale is greater than 0, or unscaledDelta if 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).

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.

  • The World of a FlowWorldNode runs 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’s Cancel().
  • 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 the AsTask() Task with .Result or Wait() on the main thread never finishes even with ConfigureAwait(false), because Tick stops too (Flows and the World).

The general rules are in Threads.

Unhandled exceptions (exceptions no flow caught that reach the root) are written to Godot’s log.

  • They go to GD.PushError in the form [FlowTask] Unhandled at 'Game > InGame > Battle': <exception>, with the scope path (the report’s kind appears where Unhandled is).
  • FlowExceptionKind.Undelivered (an exception with no one left to receive it) is written as a warning with GD.PushWarning. It happens in normal play, for example when the waiting flow was already canceled or the Race was already decided, and logging it as an error would bury real errors.
  • For every kind, the FlowWorldNode.UnhandledException event 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 UnhandledException event handler throws during the World’s Dispose, _ExitTree throws FlowUnhandledException. The World is still closed, and the next FlowWorldNode to enter the tree takes over Default.

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.

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 IsInstanceValid and IsInsideTree.
  • 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 with RunWhileInTree or WhileInTree (see Node lifetime).

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 FlowAutoload
using 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.QuitOnGoBack is true) does not go through the close request notification. To save on the back button as well, set QuitOnGoBack to false and start the same steps from your back key handling (BackKeyInput in 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.