Skip to content

Processes & Commands

Any operation - a lerp, a transfer, an animation - follows the same lifecycle: start, run, complete. This means you can swap any operation for any other without changing the component that runs it.

Want an object to slide into place with a smooth lerp? Later decide it should snap instantly? Or animate along a curve? All three are interchangeable. The component just says "run this operation" and does not care what the operation actually does.

Instant actions work the same way. Setting a parent, firing an event, toggling a flag - these are one-shot operations that share the same lifecycle, so they can be used anywhere a process is expected.


The Lifecycle

Every process follows the same state flow:

State Diagram

stateDiagram-v2
    [*] --> Idle
    Idle --> Running : Begin()
    Running --> Paused : Pause()
    Paused --> Running : Unpause()
    Running --> Complete : natural completion
    Running --> Complete : Complete()
    Paused --> Running : Begin() [resets]
    Complete --> Running : Begin()
    Complete --> [*]
Action What Happens
Begin Starts execution. An optional callback fires when it finishes naturally.
Pause Freezes execution without losing progress.
Unpause Resumes from where it was paused.
Complete Snaps to the final state instantly. Safe to call in any state.
State Meaning
Running Actively executing
Complete Reached final state, either naturally or via Complete
Paused Frozen mid-execution via Pause

Two Layers: Processes and Operations

The system separates what runs (processes) from what does the work (operations).

Processes

A process manages lifecycle and orchestration. It knows when to start, pause, resume, and complete. Processes can hold other processes (via ProcessContainer) or hold operations (via Command).

Operations

An operation is a unit of work held by a command. Operations can be instant (complete synchronously) or timed (self-tick until done). They expose a minimal interface: IsComplete, Complete(), Pause(), Unpause(). How an operation begins depends on its domain - a targeted operation takes a target and a callback, a non-targeted operation takes just a callback.

When a command runs, it begins all its operations in parallel and completes when every operation has finished.


Technical API

IProcess

The core interface for all processes:

public interface IProcess
{
    void Begin(Action onComplete = null);
    void Pause();
    void Unpause();
    void Complete();

    bool GetIsRunning();
    bool GetIsComplete();
    bool GetIsPaused();
    bool HasDefinedDuration => Duration >= 0f;
    float Duration { get; }
}

None of these methods take an execution. Each run's state lives on the ambient execution (JungleExecution.Current), which the framework establishes around every entry point — Begin, scheduled ticks, and completion callbacks all push the run's scope so the parameterless methods resolve the right per-target state. One authored graph can therefore run concurrently for many subjects without copies. Hosts never touch executions (see Running processes from a host).

IOperation

The interface for units of work held by commands:

public interface IOperation
{
    bool GetIsComplete();
    void Complete();
    void Pause();
    void Unpause();
}

Operations do not define a Begin method here. The specific begin signature lives on the domain operation interface (e.g., an attachment operation takes a target + callback, a resource flow operation takes a tank + callback). This keeps the base interface minimal while allowing each domain to pass exactly the data it needs.

Running processes from a host

The IProcess methods above are the internal graph contract — composites use them to drive their children within an already-established ambient scope. A host (a component or value that owns an authored process) never calls them directly and never sees a JungleExecution. Instead it uses the ProcessRuns extension methods, which own pooled executions and push the scope around every call.

A host triggers a process and, by default, keeps nothing:

using (JungleContext.Push(item))     // optional subject; pushed, not passed
    process.BeginRun();              // each call starts a run (Parallel by default)

process.CompleteAll();               // handle-free control of EVERY live run
process.PauseAll();
process.UnpauseAll();
bool live    = process.AnyLive();    // AnyRunning / AnyPaused / AnyLive query the slot

To control one specific run — the parallel-runs case, or a membership host ending one member — keep the ProcessRun handle BeginRun returns:

ProcessRun run = process.BeginRun();
run.Complete();   // exactly this run; stale-safe (no-ops once the run has ended)

// Membership host (detector, cycle reaction) — one run per object, kept in a map:
using (JungleContext.Push(item)) memberRuns[item] = process.BeginRun();
if (memberRuns.Remove(item, out var r)) r.Complete();   // end that member's run

Key points: - Run multiplicity is a policy, not context. ProcessRetriggerStrategy (default Parallel) decides what a fresh trigger does to live runs — Parallel, Ignore, CompleteAndRestart, Complete, Restart, TogglePause, Buffer. A process can own its policy via IRetriggerable; a caller may override per BeginRun. - Retrigger is subject-scoped by default. Non-Parallel strategies apply only to live runs whose captured subject matches the trigger's ambient subject (RetriggerScope.SameSubject) — re-triggering object A never disturbs object B's run. Pass RetriggerScope.AllRuns to affect every live run. Buffer queues the begin until the in-scope runs finish (coalesced per subject; dropped by an explicit CompleteAll). - Retrigger lives at the entry point only. Nested containers (ProcessList) carry no retrigger setting; a fresh Begin on a running container is ignored (complete-then-restart from paused), mirroring Command. - Subject ≠ run identity. JungleContext.Push seeds the subject the run captures (read by the Context value-source); selecting a run is done with a handle, never by subject. - CompleteAll/PauseAll/AnyLive are handle-free and act on all of a slot's live runs; the ProcessRun handle is the opt-in path for one run.


Base Classes

Command

Abstract base class for processes that hold and execute operations in parallel. Handles all the IProcess plumbing (Begin/Pause/Unpause/Complete, callback tracking, sentinel logic) so subclasses only provide the operations and how to start them:

public abstract class Command : IProcess
{
    protected abstract int GetOperationCount();
    protected abstract IOperation GetOperationAt(int index);
    protected abstract void BeginOperationAt(int index, Action onComplete);
    protected virtual bool OnBegin() => true;
}
Method Purpose
GetOperationCount() How many operations this command holds
GetOperationAt(index) Return the operation at a given index (for pause/complete forwarding)
BeginOperationAt(index, onComplete) Start the operation with whatever domain-specific data it needs
OnBegin() Optional setup hook. Return false to abort (command completes immediately).

All operations begin simultaneously. The command completes when every operation has finished.

TargetedCommand\<TTarget, TOperation>

Generic base for targeted commands. Adds target resolution and typed operation access on top of Command:

public abstract class TargetedCommand<TTarget, TOperation> : Command, IGraphable
    where TTarget : UnityEngine.Object
    where TOperation : class, IOperation
{
    protected abstract TTarget ResolveTarget();
    protected abstract IList<TOperation> GetOperations();
    protected abstract void BeginOperation(TOperation op, TTarget target, Action onComplete);
}

This is the class you extend when building a command that operates on a specific Unity Object. The generic handles target resolution and bridges the typed operation list to the base Command plumbing.

// Example: a command that runs resource flow operations on a Tank
[Serializable]
public class ResourceFlowCommand : TargetedCommand<Tank, IResourceFlowOperation>
{
    [SerializeField] private TankValue tankValue;
    [SerializeReference] [JungleClassSelection]
    private List<IResourceFlowOperation> operations = new();

    protected override Tank ResolveTarget() => tankValue?.Object;

    protected override IList<IResourceFlowOperation> GetOperations() => operations;

    protected override void BeginOperation(
        IResourceFlowOperation op, Tank target, Action onComplete)
    {
        op.Begin(target, onComplete);
    }
}

ProcessContainer

Abstract base class for processes that hold and run child IProcess instances in parallel. Same parallel-completion pattern as Command, but for child processes instead of operations:

public abstract class ProcessContainer : IProcess
{
    protected abstract int GetProcessCount();
    protected abstract IProcess GetProcessAt(int index);
    protected virtual bool OnBegin() => true;
}

Children run in the container's own run, so they see the same Context, Context Value and Each Object.


Targeting

A command acts on what its own Target field resolves to. Nothing is pushed into it from outside: when the target depends on what started the run, the field says so with a run source.

  • Context: the object the run is about. A detector's On Added, a GameObject or Exclusive reaction, a cycle reaction, a Process Component or a signal sent with an object start their run with one.
  • Each Object: inside a Spawn, Spawn Over Time, For Each or List Match, the object being spawned or visited. Each object gets its own run; Context stays the outer object.
  • Context Value: not a target, but the value that started the run (a reaction's new value, a signal's value), for value fields.

A host starts a run with an object and a value like this:

using (JungleContext.PushValue(RunValue.Of(count)))
    process.BeginRun(subject);   // subject becomes Context, count becomes Context Value

Both stay on the run (JungleExecution.Subject, .Value, .EachObject) for its whole life, so steps that take several frames read the same values, and overlapping runs each keep their own.

When the target may be absent

A command that cannot find its target does nothing, and says so in the console. That message is what you want most of the time: it usually means a field was left empty or the wrong object was sent in.

Sometimes an empty target is the normal state. A slot that is currently empty, a hand holding nothing, a detector that sweeps up a mix of objects where only some carry the component the command needs. In those places the message is noise.

To silence it, right-click the command's Target field and tick Target may be absent. A small "may be absent" note appears under the target so you can see, at a glance, which commands were set that way. Right-click and tick it again to undo.

This only changes what is written to the console. The command skips its operations either way.

The option appears only when the target is worked out while the game runs — when it comes from a source, or is handed in by whatever fires the command. If you picked the component yourself in the Inspector, it is either there or it is not, and an empty field is always worth reporting.


How Octoputs Uses Processes

In Octoputs, processes drive the entire drag-and-drop lifecycle:

  • Attach phase -- A process animates the object to its attachment point
  • Detach phase -- A process animates the object away from its attachment point
  • Transfer -- AttachmentPointCommand with TransferAttachableOperation orchestrates moving objects between attachment points

Each phase is an IProcess. When a phase completes, the next one begins. This makes the animation and behavior sequence fully configurable in the Inspector without code changes.


Summary

flowchart TD
    IP["IProcess"] --> CMD["Command (base class)"]
    IP --> PC["ProcessContainer"]
    CMD --> CMDT["TargetedCommand&lt;TTarget, TOperation&gt;"]
    CMD -.->|holds| IOP["IOperation"]
  • Use Command to run operations in parallel with full lifecycle management
  • Use TargetedCommand\<TTarget, TOperation> when operations need a resolved target (the most common pattern)
  • Use ProcessContainer to orchestrate child processes in parallel
  • Use IOperation to define units of work -- instant or timed -- that commands execute
  • All processes share the same Begin/Pause/Complete lifecycle, making them interchangeable