Scene Graph Feedback Thread

There really are a couple of design flaws with spawn on components right now.

component_a := class(component):
    ComponentB: component_b
    OnSimulate<override>()<suspends>: void =
        ComponentB.DoSomething()

component_b := class(component):
    DoSomething<public>(): void =
        spawn{SomeLoop()}
    SomeLoop<private>()<suspends>: void =
        loop:
            Print("Tick")
            Sleep(0.0)

When will task for SomeLoop be cancelled?

  • never, it will keep running
  • when ComponentA is disposed or detached
  • when ComponentB is disposed or detached
  • something else

The answer is, that the task spawned in a method of component_b is being cancelled when component_a leaves the scene. I could easily mention several cases that show that this implicit component ownership is cursed.

Let’s say we have a player and a npc. The npc freezes the player (just an example task) and then dies/despawns in the meantime. That freeze would just cancel, even tho it is not supposed to be like that.

Or imagine a turret spawning a projectile and spawning a task that makes the bullet follow a specific object. If the turret is being destroyed, that task would be canceled and the bullet would stop moving (which doesn’t make any sense at all).

This basically makes very little sense from a design perspective. Tasks/spawn{} should not depend on a random component. IF you want to have tasks that are dependent on a component, do not make it this way. Make it explicit, like component.Spawn(Callback, Args), which then makes every programmer aware of what the resulting task is tied to.

Here is my convoluted workaround for reference because there is no way that i will ever use spawn{} again the way it currently is:

    component_task_base<public> := class<abstract><unique>:
        var Finished: logic = false

        Run()<suspends>: void
        Cancel<public>(): void
        Await<public>()<suspends>: ?any
            
    component_task(args_type: type, res_type: type) := class(component_task_base):
        Callback: type{__(:args_type)<suspends>: res_type}
        Args: args_type

        CancelEvent: event() = event(){}
        FinishedEvent: event(?res_type) = event(?res_type){}

        Run<override>()<suspends>: void =
            if (Finished?):
                false
            else:
                MaybeResult: ?res_type = race:
                    block:
                        Result := Callback(Args)
                        set Finished = true
                        option. Result
                    block:
                        CancelEvent.Await()
                        false
                FinishedEvent.Signal(MaybeResult)
        Cancel<override>(): void =
            CancelEvent.Signal()
            set Finished = true
        Await<override>()<suspends>: ?res_type =
            if (Finished?):
                false
            else:
                FinishedEvent.Await()

    advanced_component<public> := class(component):
        var TickHandle<private>: ?cancelable = false
        var QueuedTasks<private>: []component_task_base = array{}

        Spawn<public>(Callback(:args_type)<suspends>: res_type, Args: args_type where args_type: type, res_type: type): component_task_base =
            ComponentTask := component_task(args_type, res_type):
                Callback := Callback
                Args := Args

            set QueuedTasks += array. ComponentTask
            if (not TickHandle?):
                Handle := TickEvents.PostPhysics.Subscribe(SpawnTasksFromQueue)
                set TickHandle = option. Handle
            ComponentTask

        SpawnTasksFromQueue<private>(:any): void =
            for (TaskInfo: QueuedTasks, TaskInfo.Finished = false):
                spawn{TaskInfo.Run()}
            set QueuedTasks = array{}
            if (TickHandle?.Cancel()):
                set TickHandle = false

This is not a perfect solution and i wish there would be some official API for this instead, but here is some example usage:

component_a := class(advanced_component):
    ComponentB: component_b
    OnSimulate<override>()<suspends>: void =
        ComponentB.DoSomething()

component_b := class(advanced_component):
    DoSomething<public>(): void =
        Spawn(SomeLoop, ()) #This will not be randomly canceled when ComponentA is detached and is *properly* owned by ComponentB instead.
    SomeLoop<private>()<suspends>: void =
        loop:
            Print("Tick")
            Sleep(0.0)

Also for anyone that uses this code, feel free to enjoy a very nice feature, that task fails to provide called “Cancel”…