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 --
AttachmentPointCommandwithTransferAttachableOperationorchestrates 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<TTarget, TOperation>"]
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