Custom Instructions#
An Instruction derives from Instruction and implements an operation in Run. Its attributes, serialized Signals, title, and execution contract establish the pattern shared by other node types.
A minimal Instruction#
This complete Instruction resolves a String Signal, logs the result, and finishes synchronously:
using System;
using UnityEngine;
using VisualScript.Icons;
using VisualScript.Runtime;
[Title("Log Text")]
[Description("Prints text in the Console")]
[Category("Debug/Log Text")]
[Parameter("Text", "The text to print")]
[Keywords("Print", "Console", "Debug")]
[Image(typeof(IconSpeech), ColorTheme.Type.TextLight)]
[Serializable]
public class InstructionMyLogText : Instruction
{
[SerializeField] private GetString m_Text = new GetString();
protected override string GetTitle(GameObject source) => $"Log {this.m_Text}";
protected override Awaitable Run(Args args)
{
Debug.Log(this.m_Text.Get(args));
return Done;
}
}InstructionMyLogText resolves m_Text only when Run executes and returns the shared Done awaitable. Its parts separate editor configuration from runtime behavior:
| Part | Controls |
|---|---|
| Attributes | Search, documentation, category, and appearance. |
| Serialized fields | Designer configuration stored with the Event. |
Run |
Runtime behavior and completion. |
Return Done when the work finishes synchronously. It's a shared, pre-completed awaitable, so returning it allocates nothing.
Taking Signals, not values#
The field is GetString, not string. Signal wrappers preserve designer choice at runtime.
A string field accepts one literal. A GetString field accepts any String Signal, including a variable, game object's name, or concatenation.
The same pattern applies to other value types. This partial example resolves a game object and an amount; the operation using them is omitted:
[SerializeField] private GetGameObject m_Target = new GetGameObject();
[SerializeField] private GetNumber m_Amount = new GetNumber();
protected override Awaitable Run(Args args)
{
GameObject target = this.m_Target.Get(args);
float amount = this.m_Amount.GetFloat(args);
// …
}Resolve Signals inside Run with the supplied args. The Instruction instance is shared, while each result depends on context, so never cache the resolved value in a field.
Instructions that take time#
Run returns an Awaitable, so an Instruction can suspend the containing list:
protected override async Awaitable Run(Args args)
{
Debug.Log("Starting");
await this.WaitForSeconds(2f);
Debug.Log("Finished");
}The list waits between the two log calls and resumes after WaitForSeconds completes.
Four helpers are available, and all of them respect cancellation:
| Helper | Suspends until |
|---|---|
NextFrame() |
The next frame. |
NextFixedFrame() |
The next physics step. |
WaitForSeconds(duration, timeMode) |
The duration elapses. |
While(func) / Until(func) |
The predicate flips. |
Raw waits don't check Event cancellation
A plain await Awaitable.WaitForSecondsAsync(2f) doesn't check whether the Event was canceled. After the wait, an Instruction can continue and access an object that's been destroyed.
The helpers check ParentCancelToken every frame and return when cancellation is requested. Check the same token in a custom loop:
while (!this.ParentCancelToken.Get)
{
await this.NextFrame();
}NextFrame preserves the cancellation check while yielding control to Unity.
Reporting a result#
By default an Instruction reports success. Set CurrentResult to change what happens next:
| Result | Effect |
|---|---|
InstructionResult.Success |
Continue. The default. |
InstructionResult.Failure |
Continue, but mark this as not having run its body. |
InstructionResult.Restart |
Jump back to the top of the list. |
InstructionResult.Exit |
Stop the list. |
Failure lets Else respond when the preceding branch didn't run. It doesn't stop the list. This implementation reports the branch result explicitly:
protected override async Awaitable Run(Args args)
{
if (this.m_Conditions.Check(args, Conditions.DEFAULT_OPERATION))
{
await this.m_Instructions.Run(args, this.ParentCancelToken);
this.CurrentResult = InstructionResult.Success;
}
else
{
this.CurrentResult = InstructionResult.Failure;
}
}PreviousResult exposes what the preceding Instruction reported. Else and Else If use it to decide whether to run.
Nested lists#
An Instruction can hold its own Conditions and Instructions. These two attributes tell the editor to draw the nested lists; the remaining class members are omitted:
[NestInnerList(nameof(m_Conditions))]
[NestOuterList(nameof(m_Instructions))]
[Serializable]
public class InstructionMyIf : Instruction
{
[SerializeField] private Conditions m_Conditions = new Conditions();
[SerializeField] private Instructions m_Instructions = new Instructions();
// …
}Pass this.ParentCancelToken when running a nested list, so canceling the outer Event cancels the inner one too.
Handling cancellation#
Override OnCancel when there's cleanup to do, and propagate to any nested list:
public override void OnCancel()
{
base.OnCancel();
this.m_Instructions.Cancel();
}OnCancel runs on the Instruction that was executing when the Event stopped. It doesn't undo operations that already completed.
Exposing parameters to nested lists#
If your Instruction makes extra values available to its nested list, implement IParameterScope so the editor offers them. See Args.
Checklist#
[Serializable], derived fromInstruction.- Attributes filled in, especially
[Description]and[Parameter]. - Fields typed as Signals rather than raw values.
GetTitlereturns a readable phrase.- Long-running work uses the cancellation-aware helpers.
- Nested lists receive
ParentCancelTokenand are canceled inOnCancel.
Where to go next#
- Custom Conditions — use the same shape for a synchronous boolean check.
- Custom Triggers — start an Event instead of running inside one.
- Args — follow the context
Runreceives.