Skip to content

Godot Integration

kimja edited this page May 26, 2026 · 1 revision

๐ŸŽฎ Godot Integration

๐Ÿ“œ Rzeka is engine-agnostic, but Godot is its primary target host. This page covers the discipline you need at the rzekaโ†”Godot boundary - schedulers and scene-tree mutation safety from inside rzeka chains.

For the initial hosting setup (autoload + main-thread scheduler), see Getting Started. For wrapping async engine APIs into rzeka observables, see Async Operations.

Schedulers and the main thread

Godot's _Ready, _Process, _PhysicsProcess, and signal callbacks all run on the main thread. Anything you Strand or Pluck from those entry points starts on the main thread, and rzeka itself never hops threads - the river stays single-threaded by design.

What does hop threads is anything async you introduce: Task, await, ResourceLoader.LoadThreadedRequest, Observable.FromAsync, HTTP clients. The moment one of those reports a result you may be on a worker thread, and any subsequent rzeka emission from that callback is on a worker thread too.

Expose a main-thread scheduler once at startup (see Getting Started) and use .ObserveOn(MainThread) to re-enter the river safely. See Async Operations for the full pattern.

Scene-tree mutations: use CallDeferred

๐Ÿ“œ๐Ÿงญ For scene-tree mutations from inside rzeka callbacks, use CallDeferred (or SetDeferred for property changes). This queues the change to Godot's idle window instead of executing it inline, which avoids two classes of bugs:

  • Re-entrant tree modification while Godot is mid-traversal from another signal or callback.
  • Calling tree-modifying methods from a callback that turns out to be off the main thread (scheduler hops inside SelectMany, Observable.FromAsync, await continuations).

Even when your rzeka chain looks like it's safely on the main thread, individual Rx operators can re-enter or hop schedulers in ways that are easy to miss. CallDeferred is the cheap defence:

Q += rzeka.Loom<GameOpened, MainSceneLoaded>(
    this,
    spell => spell.SelectMany(gameStarted => rzeka
        .Ask<LoadSceneRequest, LoadSceneResponse>(
            this,
            new LoadSceneRequest(_mainLevel).WithCircumstances(gameStarted))
        .Take(1)
        .Where(r => r.WasSuccessful)
        .Reacting(r => CallDeferred(Node.MethodName.AddChild, r.PackedScene.Instantiate()))
        .Select(r => new MainSceneLoaded().WithCircumstances(gameStarted, r))));

Prefer the typed MethodName form (Node.MethodName.AddChild) over the string form ("add_child"). It uses Godot 4's StringName cache, has compile-time safety, and renames cleanly. For deferring a lambda rather than a single method call, use Callable.From(() => /* work */).CallDeferred().

Which reactions need deferring?

The rule is specifically about mutating the scene tree or scene-aware properties. Reactions that don't touch the tree run inline:

Reaction Inline OK Defer?
AddChild, RemoveChild, Reparent, SetParent โœ“
QueueFree โœ“ (already deferred internally)
Setting transform / physics / render properties on Nodes โœ“ (use SetDeferred)
Updating your own non-Node state (services, caches, fields) โœ“
rzeka.Pluck(...) more matter โœ“
rzeka.Whisper(...) log messages โœ“
Pure computation, math, string building โœ“

Deferred timing caveat

๐Ÿ“œ๐Ÿงจ With CallDeferred(AddChild, node), the child is instantiated immediately but not in the tree until the next idle frame.

This bites when later matter in the same chain assumes the mutation is complete:

.Reacting(r => CallDeferred(Node.MethodName.AddChild, r.PackedScene.Instantiate()))
.Select(r => new MainSceneLoaded().WithCircumstances(gameStarted, r))

MainSceneLoaded ships on the current frame. Anything downstream subscribing to MainSceneLoaded that immediately reaches into the new subtree (scene.GetNode("Player")) sees an empty tree until next idle frame.

For high-level matter that means "the scene's load request resolved", this is fine. For code that wants to act on a freshly mounted subtree, either:

  • Move the dependent work into the deferred call itself, or
  • Have the spawned node Pluck something like SceneTreeAttached from its own _Ready callback. Then the downstream Loom listens for SceneTreeAttached instead of MainSceneLoaded, and timing is guaranteed.

The second pattern is preferred for rzeka systems - it keeps causality explicit (the matter chain shows when the scene actually became live) and avoids fragile assumptions about frame timing.

Clone this wiki locally