The scene structure of Unity and Godot have discrepancies and needed some more thinking about how we want to access objects in the scene and the components of those scene objects.
TinkerFlow’s ComponentExtensions can solve these discrepancies between our system and VR Builder.
The Godot implementation makes sense on how we are thinking and handling objects in the scene.
The Component System#
ProcessEngine (VR Builder Core) started on Unity with a typical Unity scene and GameObject structure.
Its main types ISceneObject, ISceneObjectProperty, behaviors, conditions all assume a component object where multiple behaviors attach to a single GameObject.
A MoveProperty sits alongside a Rigidbody and a MeshRenderer.
The engine queries them with GetComponent<T>().
Godot works differently.
A Node holds one script.
If there are multiple behaviors on one logical object with several components required, child nodes need to be added.
TinkerFlow maps this by making ProcessSceneObject the parent node and treating each property as a child node.
The “component” is a sibling under the same parent.
@startuml
tinkerflow-theme
package "Runtime Composition" {
component "Door" as FRAME <<Node3D>>
() "ProcessSceneObject" as PSO <<ProcessSceneObject>>
FRAME -down-> PSO
FRAME -down-> "MoveProperty"
FRAME -down-> "ModifyParentProperty"
FRAME -down-> "SnapableProperty"
FRAME -down-> "CollisionShape3D"
}
@endumlProcessSceneObject.Properties returns GetComponents<ISceneObjectProperty>().
That call walks the parent’s children, filters by type, and returns the matches.
Display servers over Nodes?#
We also thought about using the Godot display server to handle the rendering and the components. We would have more control on a way lower level of the engine, but the abstraction layer is wrong here and also the compatibility with Unity for an example. If ECS’s (Entity Component System, like Fennecs) is something that would fit to TinkerFlow then we also would consider it. An ECS is very different from a node system, but please take a look at Fennecs or a Unity post about it because we are not experts!
However, Godot servers (DisplayServer, RenderingServer, PhysicsServer3D, InputServer, AudioServer, TextServer) are low-level engine APIs for hardware/media interaction, so not something suitable for us. TinkerFlow operates at the application layer with process editing, step execution, object locking, property inspection, so where users interact with the scene and or Godot. We do want to touch rendering or audio pipelines directly (yet). This might be something for later and or potential integrations.
ComponentExtensions API#
ComponentExtensions provides four static methods that mirror Unity’s Component API but operate on the sibling model.
GetComponent<T>() returns the first sibling of type T under the same parent.
GetComponents<T>() returns all of them.
GetComponentInChildren<T>() searches recursively down the parent’s subtree.
AddComponent<T>() creates a new sibling node, parents it, and sets its Owner so the editor tracks it correctly.
The private core is FindComponents<T> implemented like the follwing:
private static IEnumerable<T> FindComponents<T>(this Node self, bool recursive = true)
=> self.GetParent()?
.FindChildren("*", recursive: recursive, owned: false)
.Where(node => node != self)
.OfType<T>()
?? [];FindChildren("*", recursive, owned: false) scans the parent’s subtree.
The wildcard matches any name.
recursive: true means it walks the entire tree, not just direct children.
owned: false includes instanced scenes nodes that belong to a different scene file but are instantiated here.
The method excludes self because a node is not its own component. If there is no parent, it returns an empty sequence.
Contrast this with NodeExtensions.GetNode<T>().
That one searches direct children only, by path or type.
Two extension classes with two mental models here
NodeExtensions= “my children” Godot-nativeComponentExtensions= “my parent’s other children” (more Unity-compatible)
Editor Time Validation#
Unity’s [RequireComponent] enforces same-GameObject coexistence at edit time.
TinkerFlow ports this to the sibling model.
The attribute (/TinkerFlow/Core/Runtime/Godot/Attributes/RequireComponentAttribute):
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class RequireComponentAttribute : Attribute
{
public Type type0 { get; }
public RequireComponentAttribute(Type requiredType) => type0 = requiredType;
}The validator (RequireComponentValidator) is a [Tool] autoload that runs only in the editor:
public override void _Ready()
{
if (!Engine.IsEditorHint()) { QueueFree(); return; }
CallDeferred(nameof(ValidateScene));
}
private void ValidateScene()
{
var sceneRoot = EditorInterface.Singleton.GetEditedSceneRoot();
if (sceneRoot == null) return;
foreach (var child in sceneRoot.FindChildren("*"))
TryCreateRequiredSiblings(child);
}
private void TryCreateRequiredSiblings(Node node)
{
var attrs = node.GetType().GetCustomAttributes(typeof(RequireComponentAttribute), true);
foreach (RequireComponentAttribute attr in attrs)
{
var parent = node.GetParent();
if (parent != null) AddMissingSibling(parent, node, attr.type0);
}
}AddMissingSibling checks whether a sibling of the required type already exists.
If not, it instantiates the type, names it after the type, parents it under the same parent, and sets Owner = parent.Owner ?? parent.
This is the same logic AddComponent uses.
Missing dependencies appear before you hit Play, with a log entry:
[RequireComponentValidator] Created missing 'MoveProperty' sibling on 'Door'The validator removes itself in exported builds because of the Engine.IsEditorHint() check.
This can be used inside the Editor to just have one button to fix all missing dependencies (components).
Example Components#
Examples of how the new component system is used for behaviors and properties.
MoveProperty Needs a Path3D Sibling#
BezierSplinePathProperty needs a Path3D sibling for spline data to know where and how to move this object:
// BezierSplinePathProperty
Path ??= GetParent().GetComponent<Path3D>();GetParent() reaches the ProcessSceneObject.
GetComponent<Path3D>() finds the sibling.
This is the direct equivalent of Unity’s GetComponent<Path3D>() on the same GameObject.
EffectProperty Uses Interface-Based Lookup#
EffectProperty activates confetti and particle effects via IParticleMachine:
// EffectProperty
if (confettiMachine.GetComponent(typeof(IParticleMachine)) == null) { ... }
particleMachine = confettiMachine.GetComponent<IParticleMachine>();Interface-based lookup enables multiple particle machine implementations, such as CPU, GPU, and custom, without changing the property code.

confetti behaviorProcessSceneObject.GetProperty() Polymorphic Fallback#
The ISceneObject.GetProperty<T>() implementation shows the full lookup chain, but it still needs to be implemented for Godot. We are considering this structure:
// ProcessSceneObject
public T GetProperty<T>() where T : ISceneObjectProperty
{
var property = FindProperty(typeof(T));
if (property == null) throw new PropertyNotFoundException(this, typeof(T));
return (T)property;
}
private ISceneObjectProperty FindProperty(Type type)
{
var property = this.GetComponent(type) as ISceneObjectProperty;
if (property != null) return property;
foreach (Component component in this.GetComponents<Component>())
if (component is ISceneObjectProperty prop && type.IsAssignableFrom(prop.GetType()))
return prop;
return null;
}Exact type via GetComponent first.
Scan all sibling components for interface assignability as a fallback.
This handles both concrete types (MoveProperty) and interfaces (IMoveProperty) uniformly.
Godot-Specific Pain Points#
Generics with a Node base mean every component lookup goes through OfType<T>() or as T casts.
ComponentExtensions centralizes that boilerplate so property code does not repeat it.
Components (sibling) cannot be Resources, because resources do not participate in the scene tree, and there is no _Ready, _Process, signals, or FindChildren discovery, which makes validation difficult and may lead to corrupt data if the resource cannot be loaded.
%UniqueName with GetNode<T>("%Name") is used because the % syntax only works for direct children with unique names (% is a unique name per scene).
So it is not possible to go across scenes with it and have multiple Nodes with the same unique name inside one scene.
The letter one is the main reason why we cannot use it.
The ProcessSceneObjects are always literally named “ProcessSceneObjects” and this would collide.
Editor validation is built into Unity’s [RequireComponent].
TinkerFlow builds a custom [Tool] validator that scans FindChildren("*") at edit time.
BUT never forget the [Tool] attribute.
Godot gives no warnings if it is missing, and FindChildren("*") will not work because of this, returning nothing…
Ownership and serialization are automatic in Unity, but in Godot you have to set Owner = parent.Owner ?? parent manually, because this is how add_child works at the moment. ComponentExtensions centralizes that boilerplate so property code does not repeat it.
At the End#
The sibling model works well because it keeps the ProcessEngine contracts identical across engines without being too overcomplicated.
Godot developers get a Unity-familiar API, which is both a benefit and a constraint.
Unity developers porting to Godot keep their mental model (without paying the runtime fee).
The RequireComponentValidator catches missing dependencies at edit time, not runtime. Editor-time validation runs under Engine.IsEditorHint() and removes itself in builds.
Zero runtime overhead, and warnings at the cheapest time to fix, inside the Editor. The runtime should be fine if the files are untouched.
Maybe someone will have a good idea here.
But every component lookup allocates an enumeration and runs OfType<T>() and will show up in the profiler as a spike (we need to improve that later).
@startuml
package "Unity Components" {
node "GameObject Door [GameObject]" as GO {
component "ProcessSceneObject"
component "MoveProperty"
component "MeshRenderer"
ProcessSceneObject -down- MoveProperty
MoveProperty -down- MeshRenderer
}
}
package "TinkerFlow Components" {
node "Node3D Door" as DOOR <<Node3D>>
node "ProcessSceneObject" as PSO <<Node>>
node "MoveProperty" as MPG <<Node>>
node "ModifyParentProperty" as MPP <<Node>>
node "CollisionShape3D" as CS3 <<Node>>
DOOR <-down-> PSO : child-parent relation
DOOR <-down-> MPG : child-parent relation
DOOR <-down-> MPP : child-parent relation
DOOR <-down-> CS3 : child-parent relation
PSO .> MPG: GetProperty / GetComponent
MPG .> MPP: GetComponent
MPP .> CS3: GetComponent
CS3 .> MPP: GetComponent
MPP .> MPG: GetComponent
MPG .> PSO: GetComponent
}
@endumlThe hierarchy is a deeper parent node, ProcessSceneObject, which makes scene tree navigation more verbose and maybe more difficult.
We might hide ProcessSceneObjects and the Properties in the future with the “Internal” feature.
(Instanced scenes work naturally because owned: false includes them, which hides them from the main tree btw).
Take a look at the next blog post.