Node Best Practices
A processing node's evaluation runs on worker threads, off the main thread, and is re-run
constantly as the user edits. That single fact drives every rule below. They apply equally to a
script node (a .cs fragment) and a plugin node (a DLL with
AddNode) — the body runs under the same evaluation contract either way.
Eval runs on a worker thread — read only your inputs
A node callback receives exactly what it needs and nothing else: its inputs, its parameters
(or TState), and a NodeContext. It must not touch Host, the player,
the project, the axes, or any plugin field. Those live on the main thread; reading them from a worker
is a data race.
- Plugin nodes: put every value the eval needs in the node's
TStatestruct. The host serializes it, value-copies it to the worker, and hands it to your eval byin. The node's UI callback (which is on the main thread) is where you editTState. - Script nodes: read
ctx.Param(i)(or the injected named param local) and your input pins. That's the whole input surface by construction.
Make the eval a static lambda — no captures
The host rejects a node whose eval captures state at registration time (it throws, so the plugin
fails to load loudly rather than shipping a latent race). A captured this, local, or field read from
a worker thread is exactly the bug the worker-thread rule forbids.
The fix is almost always one keyword — static:
// ❌ captures `_gain` — throws at registration
Host.Nodes.AddNode<Empty>("gain", "Gain", shape,
(double t, ReadOnlySpan<float> ins, in Empty s, NodeContext ctx, Span<float> outs)
=> outs[0] = ins[0] * _gain);
// ✅ state travels in TState; the lambda captures nothing
Host.Nodes.AddNode<GainState>("gain", "Gain", shape,
static (double t, ReadOnlySpan<float> ins, in GainState s, NodeContext ctx, Span<float> outs)
=> outs[0] = ins[0] * s.Gain);
The UI callback is exempt — it runs on the main thread and may capture freely. Only the eval
(and a prepare factory) is held to the no-capture
rule.
Don't keep state between functional samples
A functional node's eval runs once per output sample, and successive samples may run on
different worker threads. There is no safe place to stash a running value between calls — a static
field would be shared across threads and across unrelated evaluations.
If you need ordered, stateful logic — an integrator, smoothing, hysteresis, edge detection — use a discrete node instead. Its eval runs once per region, so you can loop the inputs in time order and keep local accumulators across actions:
// discrete: one pass over the region, local state is fine
int held = 50;
foreach (var p in a)
{
held = (held + p.Pos) / 2; // a simple running smooth
outp.Add(p.At, held);
}
Build expensive artifacts once with a factory
If a functional node needs a non-trivial precomputation (a lookup table, an interpolator, a parsed
dataset), don't rebuild it every sample and don't capture it. Register a
PrepareFunctional factory: it runs once per region eval, returns
the per-sample FunctionalSample closure, and the host caches that
closure for the rest of the eval.
Host.Nodes.AddNode<CurveState>("curve", "Curve", shape,
static (in CurveState s, NodeContext ctx) =>
{
float[] lut = BuildLut(s); // once per region eval
return (double t, ReadOnlySpan<float> ins, Span<float> outs)
=> outs[0] = Sample(lut, ins[0]); // per sample
});
Honor cancellation in long bodies
When the user keeps editing, the host cancels the in-flight eval and starts a fresh one; a
cancelled eval's output is discarded. A long discrete generator/modifier should poll
ctx.IsCancelled at natural loop boundaries and return early —
bailing doesn't change the result (it's thrown away regardless), it just frees the worker sooner.
for (int i = 0; i < a.Count; i++)
{
if ((i & 0x3FF) == 0 && ctx.IsCancelled) return; // every ~1024 actions
// … heavy per-action work …
}
IsCancelled is always false when the host supplies no cancellation (e.g. a one-shot preview), so
the check is safe to leave in unconditionally.
Don't cache NodeContext or DiscreteReader
Both are thin views over per-call scratch that the next evaluation on the same worker thread
reuses. Read them inside the callback; storing one in a field and reading it later throws
(InvalidOperationException — "stored beyond the node callback"). In particular,
ctx.Params and a DiscreteReader are only
valid for the duration of the call.
Outputs: ranges, sorting, and unwired inputs
- Functional output is a
floatin 0..100 written intoouts[k]; values outside are clamped. - Discrete output is appended via
DiscreteWriter.Add; the host sorts each output by time and clamps positions to 0..100, so you needn't pre-sort. - An unwired input feeds the neutral
50(functional) / an empty reader (discrete). Don't assume a pin is connected; produce something sensible regardless. - Write every output pin (see below). A functional output you leave unwritten holds whatever was in the span — not a defined value.
Multi-output nodes
A node may declare 1..16 outputs. The output names you pass to NodeShape
map by index to the outs span — outs[0] is the first declared name, outs[1] the second — and
each pin routes downstream independently. Write every output each eval; the host evaluates outputs
together, so a pin you skip is undefined (functional) or simply empty (discrete).
A functional combiner that splits one input into a primary and its reflection (the plugin-node
analogue of the shipped Mirror script):
struct MirrorState { public float Center; }
Host.Nodes.AddNode<MirrorState>("mirror", "Mirror",
new NodeShape(inputs: ["stroke"], outputs: ["main", "mirror"]),
static (double t, ReadOnlySpan<float> ins, in MirrorState s, NodeContext ctx, Span<float> outs) =>
{
outs[0] = ins[0]; // "main" — pass the stroke through
outs[1] = s.Center * 2f - ins[0]; // "mirror" — reflected about Center
},
ui: static (Ui ui, ref MirrorState s) => ui.DragFloat("Center", ref s.Center, 0.5f, 0f, 100f));
A discrete node that routes each action to one of two outputs by level — note each writer is sorted
and clamped on its own, and an output you never Add to stays empty:
struct SplitState { public int Threshold; }
Host.Nodes.AddNode<SplitState>("split", "Split by level",
new NodeShape(inputs: ["in"], outputs: ["low", "high"]),
static (ReadOnlySpan<DiscreteReader> ins, in SplitState s, NodeContext ctx,
ReadOnlySpan<DiscreteWriter> outs) =>
{
foreach (var p in ins[0])
(p.Pos < s.Threshold ? outs[0] : outs[1]).Add(p.At, p.Pos);
});
The same applies to the stateless and factory overloads — only the outs span widens; a
factory's per-sample closure receives the same multi-output Span<float>. Output pin names must be
distinct within the node (NodeShape throws otherwise), since they double as
link-target labels. For the script-node equivalent (named output locals via // !ofs:output), see
Script Nodes.
Prefer determinism
The host evaluates and re-evaluates freely and may cache results. Given the same inputs and params, a
node should produce the same output — no wall-clock time, no unseeded randomness. If you need
variation, drive it from a seed parameter (as the shipped Noise script does), so the result is
reproducible and a saved project re-evaluates identically.
Stateless nodes for pure functions
A node with no parameters and no UI — min, max, abs, a passthrough — should register through a
stateless AddNode overload (no TState). It persists no JSON, draws
no body widgets, and still gets the same input/output spans.
The node UI callback
The optional ui callback (NodeUi<TState>) draws the node's body widgets and is
the opposite of the eval in every way that matters: it runs on the main thread, once per frame
while the node is visible, and it may capture (it is not held to the no-capture rule). It receives
TState by ref — edit it synchronously and the host does the rest: it shallow-compares the struct
and, on a change, persists the new state and re-evaluates the region — recorded as a single undo step.
A node with no ui callback simply shows no body widgets.
struct GainState { public float Gain; public bool Invert; }
Host.Nodes.AddNode<GainState>("gain", "Gain",
new NodeShape(inputs: ["in"], outputs: ["out"]),
static (double t, ReadOnlySpan<float> ins, in GainState s, NodeContext ctx, Span<float> outs) =>
outs[0] = (s.Invert ? 100f - ins[0] : ins[0]) * s.Gain,
ui: static (Ui ui, ref GainState s) =>
{
ui.DragFloat("Gain", ref s.Gain, 0.01f, 0f, 4f);
ui.Checkbox("Invert", ref s.Invert); // mutate s by ref — the host detects the change
});
Use the Ui builder's widgets (Label, Button, Slider, DragFloat, Checkbox,
Combo, ColorEdit, …); they mutate the ref-passed fields directly, so you rarely need the bool
they return. Two rules:
- Replace reference fields, don't mutate them in place. Change detection compares value fields by value but reference fields (arrays, lists) by identity — so assign a new array/list to register a change; an in-place edit is missed.
- Don't make the
uicallbackstaticif it needs to capture — unlike the eval, capturing here is fine. (It can still bestaticwhen it only touchess, as above.)
Deferred UI writes (plugin nodes)
The ref TState is only valid for the duration of the synchronous callback, so you cannot hold it
across an await. For an async write — a file dialog, a background computation that finishes later —
grab a capture-safe Node handle from Ui.Node inside the
callback, then call Node.Update when the work completes. The mutation is
queued and applied on the main thread on the node's next UI pass.
ui: static (Ui ui, ref PathState s) =>
{
ui.Label(s.File.Length == 0 ? "(no file)" : s.File);
if (ui.Button("Choose…"))
_ = PickAsync(ui.Node()); // hand off the capture-safe handle, don't await here
}
// elsewhere in the plugin
static async Task PickAsync(Node node)
{
string? path = await Host.Dialogs.OpenFile("Pick data file");
if (path != null)
node.Update((ref PathState s) => s.File = path); // applied on the main thread
}
The same identity rule applies inside Node.Update: replace reference fields rather than mutating them
in place.
See also
- Script Nodes — the lightweight
.cs-fragment node path. Nodes— the plugin-side registration API and allAddNodeoverloads.NodeContext— region bounds, params, andIsCancelled.DiscreteReader/DiscreteWriter— the discrete I/O surface.