Skip to content

Repository files navigation

NuGet NxGraph NuGet NxGraph.Serialization NuGet NxGraph.Serialization.Abstraction License: MIT .NET 8 Build Publish

NxGraph

NxGraph is a lean, high-performance finite state machine / stateflow library for .NET with:

  • a fluent authoring DSL
  • explicit branching through director nodes
  • sync and async runtimes
  • stepped execution for Unity (Update()-loop friendly)
  • a unified fault model: per-node retries, failure edges (.OnError), and timeouts
  • composites: subgraphs (with history), parallel regions, and blackboard-selected dynamic regions
  • durable suspend/resume via serializable snapshots (shallow, or deep with composite internals)
  • scoped blackboards (typed, zero-boxing shared memory)
  • graph validation
  • observers, tracing, replay, and Mermaid export
  • optional graph serialization via a codec-based serializer

The core package targets net8.0 and netstandard2.1.


Table of contents


Why NxGraph

  • Simple runtime model: graphs are backed by dense node/transition arrays and each node has at most one success edge plus an optional failure edge.

  • Predictable branching: run-one fan-out happens through director nodes — the data-built ChoiceState/SwitchState<T>, which serialize, or their delegate-backed RelayChoiceState/RelaySwitchState<TKey> twins; run-many fan-out through parallel composites — see Fan-out at a glance.

  • Authoring ergonomics: build flows with StartWithAsync, .ToAsync(...), .If(...), .Switch(...), .WaitForAsync(...)/.WaitFor(...), and .ToWithTimeoutAsync(...)/.ToWithTimeout(...) — every construct has twins in both runtimes.

  • Unity-ready sync runtime: StateMachine.Execute() advances exactly one node per call, drop it into MonoBehaviour.Update().

  • Diagnostics built in: validate graphs, inspect Mermaid output, attach observers, capture replay logs, or emit Activity traces.

  • Both async and sync: use AsyncStateMachine for async logic and StateMachine for sync-only flows.


Packages

NxGraph

The core package. Includes:

  • graph model and FSM runtimes
  • fluent DSL
  • validation
  • Mermaid export
  • replay recording / playback
  • tracing observer

NxGraph.Serialization

Optional serializer package for persisting graphs to JSON or MessagePack using your own logic codec.

NxGraph.Serialization.Abstraction

Optional interfaces for consumers who only need serialization contracts.


Install

Core package:

dotnet add package NxGraph

Optional graph serialization:

dotnet add package NxGraph.Serialization

Optional serialization abstractions only:

dotnet add package NxGraph.Serialization.Abstraction

Build from source:

dotnet build -c Release
dotnet test -c Release

Quick start

Async quick start

using NxGraph;
using NxGraph.Authoring;
using NxGraph.Fsm;
using NxGraph.Fsm.Async;

static ValueTask<Result> Acquire(CancellationToken _) => ResultHelpers.Success;
static ValueTask<Result> Process(CancellationToken _) => ResultHelpers.Success;
static ValueTask<Result> Release(CancellationToken _) => ResultHelpers.Success;

AsyncStateMachine fsm = GraphBuilder
    .StartWithAsync(Acquire).SetName("Acquire")
    .ToAsync(Process).SetName("Process")
    .ToAsync(Release).SetName("Release")
    .ToAsyncStateMachine();

Result result = await fsm.ExecuteAsync();

Sync quick start

using NxGraph;
using NxGraph.Authoring;
using NxGraph.Fsm;

StateMachine fsm = GraphBuilder
    .StartWith(() => Result.Success).SetName("Start")
    .To(() => Result.Success).SetName("End")
    .ToStateMachine();

// Execute() advances one node per call; loop to run to completion:
Result result = Result.InProgress;
while (result == Result.InProgress)
    result = fsm.Execute();

For a single-node graph Execute() returns Result.Success (or Result.Failure) immediately. For multi-node graphs it returns Result.InProgress after each intermediate node, signalling that more nodes remain. See Sync execution, stepped model for the Unity pattern.


Authoring DSL

Linear flows

var graph = GraphBuilder
    .StartWithAsync(_ => ResultHelpers.Success).SetName("Start")
    .ToAsync(_ => ResultHelpers.Success).SetName("Step1")
    .ToAsync(_ => ResultHelpers.Success).SetName("Step2")
    .Build();

Branching with If

bool IsPremium() => true;

var graph = GraphBuilder
    .StartWithAsync(_ => ResultHelpers.Success).SetName("Entry")
    .If(IsPremium)
        .ThenAsync(_ => ResultHelpers.Success).SetName("Premium")
        .ElseAsync(_ => ResultHelpers.Success).SetName("Standard")
    .Build();

Branching with Switch

int RouteKey() => 2;

var graph = GraphBuilder
    .StartWithAsync(_ => ResultHelpers.Success).SetName("Entry")
    .Switch(RouteKey)
        .CaseAsync(1, _ => ResultHelpers.Success)
        .CaseAsync(2, _ => ResultHelpers.Success)
        .DefaultAsync(_ => ResultHelpers.Failure)
    .End().SetName("Router")
    .Build();

Data-built branching (serializable)

The .If(predicate) / .Switch(selector) overloads above take delegates, and a closure cannot ride a serialization payload — a graph that branches through them cannot round-trip, and therefore cannot survive suspend and resume. When the decision is data — a comparison against a blackboard slot — pass a condition or a key instead, and the branch becomes an ordinary part of the payload:

var world = new BlackboardSchema("world");
BlackboardKey<bool> alarmRaised = world.Register("alarmRaised", false);
BlackboardKey<int> tier = world.Register("tier", 0);

var graph = GraphBuilder
    .StartWithAsync(_ => ResultHelpers.Success).SetName("Entry")
    .If(new IsTrue(alarmRaised))
        .ThenAsync(_ => ResultHelpers.Success).SetName("Evacuate")
        .ElseAsync(_ => ResultHelpers.Success).SetName("Patrol")
    .WithSchema(world)
    .Build();

var routed = GraphBuilder
    .StartWithAsync(_ => ResultHelpers.Success).SetName("Entry")
    .Switch(tier)                                   // the key is the tested value
        .CaseAsync(1, _ => ResultHelpers.Success)   // cases are literals
        .CaseAsync(2, _ => ResultHelpers.Success)
        .DefaultAsync(_ => ResultHelpers.Success)
    .End().SetName("Router")
    .WithSchema(world)
    .Build();

.If(...) with conditions builds a ChoiceState, .Switch(key) builds a SwitchState<T>; both serialize with zero serializer options and both render labelled arms in the Mermaid export (true / false, the case literal, otherwise).

A condition implements IConditionbool Evaluate(in BehaviorContext ctx), reading the machine-bound blackboards through the same context behaviors use. It reuses none of the fault model: a condition that is false is not a failure, so branching never spends the node's retry/failure edge. Conditions are side-effect free by contract, which is what makes short-circuit evaluation safe; a genuine wiring fault (an unbound key, a key declared with another type) throws rather than answering false. The standard set is deliberately tiny — KeyEquals<T> (whose expected side is a literal or another key), IsTrue, and Not — and .If(ConditionMatch.All, …) / .If(ConditionMatch.Any, …) combine several.

Two shapes, two jobs: a switch is a lookup, so its case values are literals and a value cased twice is rejected at build time — at most one arm can match, and the arms carry no order. Ordered, first-match-wins rules, where an earlier arm may shadow a later one or different arms test different keys, are a chain of choices, which is what if/else if is; lower to that rather than reaching for an ordered rule table.

Custom directors

.If(predicate) and .Switch(selector) compile down to the delegate-backed director nodes RelayChoiceState and RelaySwitchState<TKey>; their data-built twins are ChoiceState and SwitchState<T> (above). A director is a node implementing IDirector (IAsyncDirector for the async runtime) whose SelectNext() picks the next node at runtime — implement it yourself when the routing decision doesn't fit a predicate or a key/case map. Override EnumerateStaticTargets() to surface the nodes you can route to: the validator and the Mermaid exporter walk it, and the validator warns when a custom director exposes none (its branches would be invisible to reachability analysis and diagrams).

Fan-out at a glance

Every fan-out construct answers two questions: how many successors run, and when the set is chosen. The four quadrants:

How many run Chosen statically (declared in the graph) Chosen dynamically (at runtime)
One of many Conditional — .If(...) / .Switch(...) declare the branches and the routing rule; the data-built forms additionally serialize Director — IDirector selects any node in code; RelayChoiceState/RelaySwitchState<TKey> are the built-ins
Many at once Parallel — .Parallel(regions...) runs all region graphs Dynamic parallel — .Parallel(selector, ...) runs the subset a blackboard selector picks
Many in one flat graph Token runtime — .ForkTo(...) + JoinState fan tokens out and merge them mid-graph (all / any / M-of-N) The same fork/join graph — which tokens reach a join, and when, is decided by each token's own path at runtime

The FSM runtimes keep exactly one active node, and many-at-once execution lives inside the parallel composites, which join back into the single-active flow at the composite boundary. When the flow genuinely needs several active nodes in one flat graph — a forked path that rejoins the parent flow mid-graph, the same node active k times, M-of-N merges — that is the token runtime: a third runtime beside the sync/async machines (TokenMachine/AsyncTokenMachine), not a change to them.

None of these overlap in time. Every construct in the table interleaves cooperatively — one node per region (or per token) per round, on the caller's thread — so two 20-second API calls placed in parallel regions still take about 40 seconds wall-clock. For genuine wall-clock concurrency, run the calls inside one node with .ToAllAsync(...): the same two calls overlap and finish in about 20 seconds. Regions structure logic; nodes structure time.

Waits and timeouts

var delayed = GraphBuilder
    .StartWithAsync(_ => ResultHelpers.Success).SetName("Start")
    .WaitForAsync(250.Milliseconds()).SetName("Cooldown")
    .ToAsync(_ => ResultHelpers.Success).SetName("Finish")
    .Build();

var timed = GraphBuilder
    .StartWithAsync(_ => ResultHelpers.Success).SetName("Start")
    .ToWithTimeoutAsync(2.Seconds(), _ => ResultHelpers.Success, TimeoutBehavior.Fail)
        .SetName("TimedWork")
    .ToAsync(_ => ResultHelpers.Success).SetName("AfterTimeout")
    .Build();

With TimeoutBehavior.Fail an expired timeout is an ordinary node failure — it consumes the node's retry budget and follows its failure edge like any other Result.Failure (see the next section). TimeoutBehavior.Throw raises a TimeoutException instead.

The sync runtime has frame-stepped twins. .WaitFor(TimeSpan) returns Result.InProgress across ticks until the duration elapses (a Stopwatch timestamp comparison — no timers, no allocation); .ToWithTimeout(timeout, ...) runs the wrapped logic once per tick and produces the timeout outcome when it overstays the deadline. Because the sync runtime has no cancellation, the deadline is detected between ticks — a node cannot be interrupted mid-execution. Both are sync-only (WaitFor's node-level InProgress is rejected by the async loop); all timeout overloads, sync and async, take the timeout first.

var syncFlow = GraphBuilder
    .StartWith(() => Result.Success)
    .WaitFor(250.Milliseconds())
    .ToWithTimeout(2.Seconds(), () => Result.Success)
    .Build();

Error handling: retries and failure edges

A node returning Result.Failure flows through one unified fault model: first its per-node RetryPolicy re-runs it in place, then its failure edge (if any) routes to a handler, and only when neither applies does the machine terminate with Failure.

var graph = GraphBuilder
    .StartWithAsync(CallFlakyService).SetName("Call")
        .Retry(maxAttempts: 3, backoff: 100.Milliseconds(), BackoffKind.Exponential)
        .OnErrorAsync(_ => Cleanup()).SetName("Cleanup")
    .Build();
  • .Retry(maxAttempts, backoff, kind) re-runs the node in place; BackoffKind is Fixed, Linear, or Exponential. Backoff delays apply to the async runtime; the sync runtime retries on the next tick.
  • .OnError(...) / .OnErrorAsync(...) set the failure destination. The success chain continues from the original node, so failure handlers branch off without disturbing the happy path; .OnError(StateToken) wires an already-built detached chain as the handler.
  • Retries fire before the failure edge: with the graph above, Call runs up to 3 times before Cleanup is entered.

Loops with Goto

.Goto("name") wires a back-edge to a named node, resolved at Build() — unknown or ambiguous names fail the build:

int laps = 0;

var loop = GraphBuilder
    .StartWith(() => Result.Success).SetName("Gather")
    .To(() => ++laps < 3 ? Result.Success : Result.Failure).SetName("Craft")
    .Goto("Gather") // Craft's success edge loops back to Gather
    .Build();

A Goto consumes the node's one success edge and closes the chain, so the loop needs an exit: a node that eventually returns Failure (routed by .OnError or terminating the run), or a director (If/Switch) placed inside the loop.

Named outcomes

Terminal nodes can report which outcome ended the run, beyond Success/Failure:

const int Delivered = 1;

var graph = GraphBuilder
    .StartWithAsync(ProcessOrder).SetName("Process")
    .ToAsync(_ => ResultHelpers.Success).SetName("Deliver").WithOutcome(Delivered, "Delivered")
    .Build();

AsyncStateMachine fsm = graph.ToAsyncStateMachine();
await fsm.ExecuteAsync();
Console.WriteLine($"{fsm.LastOutcome}: {fsm.LastOutcomeName}"); // "1: Delivered"

.WithOutcome(code, name) tags a node; when a run terminates at that node, the machine exposes the code via LastOutcome and the registered name via LastOutcomeName (0 / null when the terminal node has no outcome). Branch graphs give each terminal branch its own code, so callers can tell how the flow ended. LastOutcome resets at every run start and survives suspend/resume (it is part of the snapshot).

Naming nodes

Names are optional but strongly recommended for diagnostics, Mermaid export, replay, and observer output.

var graph = GraphBuilder
    .StartWithAsync(_ => ResultHelpers.Success).SetName("Initial")
    .ToAsync(_ => ResultHelpers.Success).SetName("Second")
    .Build()
    .SetName("SampleGraph");

Agents / context injection

Use typed state machines when your states need shared mutable context or services.

using NxGraph;
using NxGraph.Authoring;
using NxGraph.Fsm;
using NxGraph.Fsm.Async;

public sealed class AppAgent
{
    public int Counter { get; set; }
}

public sealed class WorkState : AsyncState<AppAgent>
{
    protected override ValueTask<Result> OnRunAsync(CancellationToken ct)
    {
        Agent.Counter++;
        return ResultHelpers.Success;
    }
}

AsyncStateMachine<AppAgent> fsm = GraphBuilder
    .StartWithAsync(new WorkState()).SetName("Work")
    .ToAsyncStateMachine<AppAgent>()
    .WithAgent(new AppAgent());

await fsm.ExecuteAsync();

Blackboards (scoped shared memory)

The agent is who is acting (an Enemy, a workflow context); a blackboard is the graph's working memory. They are orthogonal channels — both reach nodes simultaneously. Blackboards are typed-key slot stores with three scopes: Global (one user-owned board shared by every machine — world state), Graph (one board per machine/entity), and Node (transient per-visit scratch). Reads and writes are zero-boxing, zero-allocation array accesses.

using NxGraph.Blackboards;

// Schemas are code: declare keys once, share the schema across N boards.
static class WorldKeys
{
    public static readonly BlackboardSchema Schema = new("world", BlackboardScope.Global);
    public static readonly BlackboardKey<bool> AlarmRaised = Schema.Register<bool>("AlarmRaised");
}

static class EnemyKeys
{
    public static readonly BlackboardSchema Schema = new("enemy"); // Graph scope (default)
    public static readonly BlackboardKey<int> TargetDistance = Schema.Register<int>("TargetDistance", 10);
}

sealed class ChaseState : AsyncState<Enemy>
{
    protected override ValueTask<Result> OnRunAsync(CancellationToken ct)
    {
        // One call site — the key's schema scope picks the board:
        if (Bb.Get(WorldKeys.AlarmRaised))          // routed → shared global board
            Bb.GetRef(EnemyKeys.TargetDistance)--;   // routed → this machine's board
        return ResultHelpers.Success;
    }
}

// Declare schemas on the graph (opt-in bind-time validation), bind boards per machine:
Graph graph = GraphBuilder
    .StartWithAsync(new ChaseState())
    .If(bb => bb.Get(WorldKeys.AlarmRaised))         // blackboard-driven branching
        .ThenAsync((bb, ct) => ResultHelpers.Success)
        .ElseAsync((bb, ct) => ResultHelpers.Success)
    .WithSchema(EnemyKeys.Schema)
    .WithSchema(WorldKeys.Schema)
    .Build();

Blackboard world = new(WorldKeys.Schema);            // one world board for everyone

AsyncStateMachine<Enemy> fsm = graph.ToAsyncStateMachine<Enemy>()
    .WithBlackboard(world)                           // routed by schema scope
    .WithBlackboard(new Blackboard(EnemyKeys.Schema)) // this enemy's own memory
    .WithAgent(enemy);

Node scope (transient per-visit scratch). A BlackboardSchema with BlackboardScope.Node declares keys whose values live for one node visit: they reset to their registered defaults at every transition boundary (success transition, failure-edge reroute, run start, reset, resume), while in-place retries of the same visit keep the scratch — partial progress across attempts is the point. Node boards are machine-owned: declare the schema on the graph with .WithSchema(...) and every machine auto-creates its own board (two machines over one shared Graph never see each other's scratch); binding one with WithBlackboard throws. They are deliberately not durable — resuming a StateMachineSnapshot restores Node keys to defaults.

static class ScratchKeys
{
    public static readonly BlackboardSchema Schema = new("scratch", BlackboardScope.Node);
    public static readonly BlackboardKey<int> BytesSent = Schema.Register<int>("BytesSent");
}

// bb.GetRef(ScratchKeys.BytesSent) accumulates across retries of one visit,
// and is back to 0 when the machine moves to the next node.
Graph graph = GraphBuilder.StartWith(new UploadState()).Retry(3)
    .WithSchema(ScratchKeys.Schema)
    .Build();

Anti-pattern: don't use Dictionary<string, object> as a context — every access pays string hashing plus boxing. A BlackboardKey<T> slot is a schema-checked array read: type-safe, allocation-free, and serializable (see BlackboardSerializer in NxGraph.Serialization — each board is its own durability artifact alongside the graph payload and machine snapshot; Node boards are transient and never serialize).

Like the state machines, a blackboard is owned by a single runner at a time (not thread-safe).

Step I/O (ports)

Steps communicate through the blackboard — there is deliberately no hidden output→input piping between nodes. The shipped convention for this step's output feeds that step's input is a port: an ordinary Graph-scoped BlackboardKey<T>, one per producing step, named for the datum, not the step (io.Register<string>("draft"), not "writeDraft.out" — several steps may legally produce the same port, e.g. a retry-rewrite step). Producers Set, consumers Get; a consumer that can run before any producer reads the key's registered default — register a meaningful default or guard in the step. Declare the schema on the graph with .WithSchema(io) and bind a board per machine with .WithBlackboard(new Blackboard(io)) — the existing paths, unchanged.

The port DSL overloads read/write ports around the step lambda, so the common produce→transform→consume chain needs no manual bb.Get/bb.Set and the graph stays a shareable template:

static class FlowIo
{
    public static readonly BlackboardSchema Schema = new("flow-io"); // Graph scope (default)
    public static readonly BlackboardKey<string> Draft = Schema.Register<string>("draft", "");
    public static readonly BlackboardKey<string> Final = Schema.Register<string>("final", "");
}

// Async: produce → pipe → consume.
Graph graph = GraphBuilder
    .Start()
    .ToAsync(FlowIo.Draft, (bb, ct) => new ValueTask<string>("a draft"))                  // producer: value → port
    .ToAsync(FlowIo.Draft, FlowIo.Final, (draft, bb, ct) => new ValueTask<string>($"[polished] {draft}")) // pipe
    .ToAsync(FlowIo.Final, (text, bb, ct) => PublishAsync(text))                          // consumer: port → Result
    .WithSchema(FlowIo.Schema)
    .Build();

Result result = await graph.ToAsyncStateMachine()
    .WithBlackboard(new Blackboard(FlowIo.Schema))
    .ExecuteAsync();
// Sync twin — the same shapes without the CancellationToken.
Graph graph = GraphBuilder
    .Start()
    .To(FlowIo.Draft, bb => "a draft")
    .To(FlowIo.Draft, FlowIo.Final, (draft, bb) => $"[polished] {draft}")
    .To(FlowIo.Final, (text, bb) => Publish(text))
    .WithSchema(FlowIo.Schema)
    .Build();

Producer and pipe steps cannot fail — their lambdas return the value, and the relay writes it to the port and returns Success. A step that both computes a value and can fail keeps using the plain context relay (.To(bb => ...)) with an explicit bb.Set(...) on its success path; exceptions thrown by a step propagate exactly as from every relay.

Node-scope trap: a Node-scoped key cannot pipe — Node scratch resets on the success transition, so the producer's write is back at its default before the consumer runs. The port overloads reject Node-scoped keys at wiring time with an ArgumentException. Global-scoped keys are accepted (legitimate for world-state ports) but shared across machines — two machines over one template would overwrite each other's values, so the default recommendation stays Graph scope.

Event entry points

One graph can respond to several externally-raised, typed events, each entering the flow at its own entry chain. An event is a run trigger, not an interrupt: one event starts exactly one ordinary run at the chain registered for its CLR type, with the payload delivered type-safely through a Graph-scoped BlackboardKey<TEvent> — the same "a port is an ordinary key" convention as step I/O, which is also what makes event payloads durable for free.

public sealed record OrderPlaced(string OrderId, decimal Amount);
public readonly record struct OrderCanceled(string OrderId);

var shop = new BlackboardSchema("shop"); // Graph scope (default)
BlackboardKey<OrderPlaced> orderPlaced = shop.Register<OrderPlaced>("orderPlaced");
BlackboardKey<OrderCanceled> orderCanceled = shop.Register<OrderCanceled>("orderCanceled");

Graph graph = GraphBuilder.StartWithEvents()
    .On(orderPlaced, e => e
        .ToAsync(orderPlaced, (order, bb, ct) => ReserveStockAsync(order))  // payload via the consumer sugar
        .ToAsync((bb, ct) => ChargeAsync(bb.Get(orderPlaced)))              // or via bb.Get
        .WithOutcome(1, "Placed"))
    .On(orderCanceled, e => e
        .ToAsync(orderCanceled, (order, bb, ct) => RefundAsync(order))
        .WithOutcome(2, "Canceled"))
    .Otherwise(e => e.ToAsync((bb, ct) => LogUnsolicitedAsync()))           // optional plain-run entry
    .WithSchema(shop)
    .Build();

AsyncStateMachine machine = graph.ToAsyncStateMachine().WithBlackboard(new Blackboard(shop));

Result placed = await machine.ExecuteAsync(new OrderPlaced("o-1", 42m)); // dispatch by CLR event type
Result canceled = await machine.ExecuteAsync(new OrderCanceled("o-1"));
Console.WriteLine(machine.LastOutcomeName); // per-entry outcome via .WithOutcome + LastOutcome

The sync twin raises with machine.Execute(evt) — the call arms the run and advances one tick; subsequent plain Execute() ticks continue it (normal frame-stepping). The async stepped twin is machine.StepAsync(evt), legal only as a run's first step. Entry chains use the full DSL vocabulary — .OnError, .Retry, .WithOutcome, ports, composites — and may converge on shared nodes.

Rules that keep the model simple:

  • One event = one run. The machine must be idle; restart policies apply verbatim (under RestartPolicy.Manual a raise after a terminal run throws the usual "call Reset()" error, and raising while running throws). There is no internal queue, no mid-run delivery, no deferral.
  • Dispatch is by CLR event type — one entry per type, enforced at wiring time. Node-scoped and schema-less keys are rejected at wiring time too; Global keys are allowed but shared across machines (prefer Graph scope).
  • A plain run (ExecuteAsync() / Execute()) routes to the Otherwise chain; without one it throws pointing at the raise API. A raised entry never leaks into a later plain run.
  • Machines sharing one graph each raise their own events against their own boards, exactly like agents and blackboards — sequential runs only.

A host that wants buffering feeds the machine from its own queue — three lines with a Channel<T>:

var queue = Channel.CreateUnbounded<OrderPlaced>();
await foreach (OrderPlaced evt in queue.Reader.ReadAllAsync(ct))
    await machine.ExecuteAsync(evt, ct); // one queued event = one run

Serialization: the dispatch table rides the graph payload (version 7) as plain structure; keys never serialize, so a deserialized graph raises by resolving the event's runtime-stable type name and the delivery key by name against the machine's bound board — see Serialization.

Behaviors (declarative state composition)

A state can be authored as a sequence of small, reusable behaviors — plain data objects whose fields are literals or blackboard bindings — instead of an opaque lambda or a hand-written State subclass. .ToBehaviors(...) / .ToBehaviorsAsync(...) build one node that runs its entries in order, fail-fast: the first non-Success entry stops the sequence and the node returns Failure (deliberately the opposite of .ToAll's run-all-then-combine — sequence entries may depend on earlier entries' writes). Everything above the node is unchanged: .Retry re-runs the whole list (keep behaviors idempotent), .OnError reroutes, .WithOutcome codes.

var stats = new BlackboardSchema("stats"); // Graph scope (default)
BlackboardKey<string> playerName = stats.Register("playerName", "Hero");
BlackboardKey<int> score = stats.Register("score", 0);
BlackboardKey<int> comboHits = stats.Register("comboHits", 3);
BlackboardKey<int> hitIndex = stats.Register("hitIndex", -1);

Graph graph = GraphBuilder.Start()
    .ToBehaviors(
        new Log(LogSeverity.Info, playerName), // message bound to a key, resolved per run
        new SetValue<int>(score, 100),         // literal write — the typed copy/constant primitive
        new Repeat(comboHits, hitIndex,        // key-bound trip count — resolved once at entry
            new Log("combo hit")),             // body runs comboHits times; hitIndex = 0, 1, 2…
        new Log("checkpoint saved"))           // literal message, Info severity by default
    .WithSchema(stats)
    .Build();

Result result = graph.ToStateMachine(observer)      // Log lands in the observer's OnLogReport
    .WithBlackboard(new Blackboard(stats))
    .Execute();

Every field is a BlackboardValue<T> — literal and key convert implicitly, and any scope binds (Node scratch included: behavior bindings resolve within one visit, so the ports restriction doesn't apply). Log emits "[{severity}] {message}" through the node's report channel (observer OnLogReport, never the console — and no string is even formatted on observer-less machines). The standard set is deliberately tiny — Log, SetValue<T>, and Repeat, each implementing both behavior interfaces so a single instance authors either runtime (AsyncRepeat is the async-body twin, and Repeat<TAgent>/AsyncRepeat<TAgent> deliver the agent to typed body entries per iteration).

Repeat is the one control-flow behavior — a bounded, leaf-level For: the trip count (literal or key-bound) is resolved once at entry, a key-bound count of zero or less runs nothing and succeeds, the optional index key is written 0-based before each iteration, and each iteration walks the body in order, fail-fast. The node fault model is untouched — a .Retry re-runs all iterations, so the idempotency note compounds with the trip count — and only the async paths can observe cancellation between iterations. Anything condition-driven (While, guards) stays at the node level: use a condition director plus a .Goto back-edge, which validators, Mermaid, and snapshots can see.

Custom behaviors implement IBehavior (sync), IAsyncBehavior (async), or both. Agent-typed behaviors (IBehavior<TAgent> / IAsyncBehavior<TAgent>) receive the machine-bound agent as a call parameter per execution via the typed composites (.ToBehaviors<TAgent>(...)), which participate in the standard agent stamping — behaviors themselves are never stamped, so instances stay shareable across graphs and machines. Untyped composites reject agent-typed entries at wiring time; typed composites accept a mix and run plain entries agent-blind.

When to write a custom behavior vs a custom State: a behavior is the right shape for a small, reusable, data-configured step — fields that an editor (or a payload) could inspect and rebind, no timing, output via ctx.Report/the blackboard. Write a State subclass when the logic needs the node lifecycle (OnEnter/OnExit), multi-tick InProgress progress, timeouts/cancellation shape, or when it is one-off orchestration that nothing else will reuse. Behaviors are also the first node logic that serializes without a user codec — see Serialization.


Execution

Async execution

AsyncStateMachine sm = graph.ToAsyncStateMachine(observer: null);
Result result = await sm.ExecuteAsync();

Sync execution, stepped model

StateMachine.Execute() is the stepped entry point. Each call advances the machine by exactly one node and returns:

Return value Meaning
Result.InProgress Node completed; there are more nodes to run. Call Execute() again.
Result.Success Machine finished successfully, no more nodes.
Result.Failure A node failed or threw. Machine is now in Failed status.

Blocking / non-Unity loop:

StateMachine sm = graph.ToStateMachine();
Result result = Result.InProgress;
while (result == Result.InProgress)
    result = sm.Execute();

Multi-frame nodes: A node can return Result.InProgress from its own OnRun() to signal it needs another frame (e.g. a countdown timer or a wait-for-input node). The machine stays on that node and invokes it again on the next Execute() call.

Unity integration

Call Execute() from MonoBehaviour.Update(). The machine advances one node per frame and the main thread is never blocked:

public class FsmRunner : MonoBehaviour
{
    private StateMachine _fsm;

    void Start()
    {
        _fsm = GraphBuilder
            .StartWith(new PatrolState()).SetName("Patrol")
            .To(new AlertState()).SetName("Alert")
            .To(new AttackState()).SetName("Attack")
            .ToStateMachine();
        _fsm.SetRestartPolicy(RestartPolicy.Ignore);
    }

    void Update()
    {
        Result r = _fsm.Execute();
    }
}

Nested machines

Both StateMachine and AsyncStateMachine implement the node interface directly, so a machine can be passed as a node inside another machine with no wrapper state required.

Sync, stepped:

StateMachine childFsm = GraphBuilder
    .StartWith(() => Result.Success).SetName("Init")
    .To(new RelayState(
            run: () => Result.Success,
            onExit: () => Console.WriteLine("child done")))
    .ToStateMachine();

StateMachine parentFsm = GraphBuilder
    .StartWith(childFsm).SetName("Child")
    .To(new RelayState(
            run: () => Result.Success,
            onExit: () => Console.WriteLine("parent done")))
    .SetName("Cleanup")
    .ToStateMachine();

// Each Execute() advances exactly one node — even one inside the child.
// 3 ticks: child node 1 → child node 2 (child done) → parent Cleanup
Result r = Result.InProgress;
while (r == Result.InProgress)
    r = parentFsm.Execute();

From Unity's Update() each call advances exactly one node across the whole hierarchy — no frame blocking.

Async:

AsyncStateMachine childFsm = GraphBuilder
    .StartWithAsync(_ => ResultHelpers.Success)
    .ToAsync(_ => ResultHelpers.Success)
    .ToAsyncStateMachine();

AsyncStateMachine parentFsm = GraphBuilder
    .StartWithAsync(childFsm)
    .ToAsync(_ => ResultHelpers.Success)
    .ToAsyncStateMachine();

Result result = await parentFsm.ExecuteAsync();

Nesting can be arbitrarily deep. Each level is stepped independently; the parent treats a running child as Result.InProgress and a completed child as Result.Success.

Composites: subgraphs, history, and parallel regions

Subgraphs

.SubGraph(child) nests a whole child graph as a single node of the parent — the DSL shorthand for the nested-machine pattern above. With history: true, a child that failed resumes at its last-active node when the parent re-enters the composite (e.g. after a failure edge and a Goto back), instead of restarting from its start node; a child that completed restarts from the top:

Graph child = GraphBuilder
    .StartWithAsync(_ => ResultHelpers.Success)
    .ToAsync(_ => ResultHelpers.Success)
    .Build();

Graph flow = GraphBuilder
    .StartWithAsync(_ => ResultHelpers.Success).SetName("Prepare")
    .SubGraph(child, history: true).SetName("Work")
    .ToAsync(_ => ResultHelpers.Success).SetName("Finish")
    .Build();

The sync twin takes a ParallelStepMode (the same enum the parallel composites use): .SubGraph(mode, child) nests the child as a sync StateMachine node, .SubGraph(mode, child, history: true) builds a sync HistoryState. RunToJoin completes the child within one tick (and is therefore also runnable under the async machine via the sync-logic adapter); RoundPerTick advances one child node per parent tick, sync runtime only:

Graph flow = GraphBuilder
    .StartWith(() => Result.Success)
    .SubGraph(ParallelStepMode.RoundPerTick, child, history: true)
    .To(() => Result.Success)
    .Build();

Parallel regions (AND-states)

.Parallel(regions...) runs N region graphs via cooperative interleaving: each round advances every still-running region by one node, and the composite joins when all regions reach a terminal result — Success only if every region succeeded, otherwise Failure through the parent's unified fault model (failure edges, retries). This is deliberately not thread-concurrent, which keeps the hot path allocation-free:

AsyncStateMachine fsm = GraphBuilder
    .Start()
    .Parallel(tents, fire, scouts) // three region graphs, one node each per round
    .ToAsyncStateMachine();

Result joined = await fsm.ExecuteAsync();

The sync twin takes a ParallelStepMode: RunToJoin completes the whole join inside one Execute() call, while RoundPerTick advances one round per call and returns Result.InProgress in between — so region progress aligns 1:1 with game-loop frames:

StateMachine fsm = GraphBuilder
    .Start()
    .Parallel(ParallelStepMode.RoundPerTick, watchtower, gateCrew)
    .ToStateMachine();

// From Update(): each call advances every still-running region by one node.
Result r = fsm.Execute();

RoundPerTick is sync-only (the async loop rejects node-level InProgress); RunToJoin composites also run under the async machine via the sync-logic adapter. Validating a graph destined for the async runtime with new GraphValidationOptions { StrictAsyncCompatible = true } flags reachable RoundPerTick composites (including nested sync machines left in their default per-tick mode) as errors instead of a mid-run surprise.

Dynamic (some-of-many) regions

The selector overloads pick which regions run at every composite entry, from the machine-bound blackboard:

RegionMask SelectDefenses(BlackboardContext bb)
{
    RegionMask mask = RegionMask.Bit(0);                    // archers always
    if (bb.Get(Threat) >= 2) mask |= RegionMask.Bit(1);     // cauldrons
    if (bb.Get(Threat) >= 3) mask |= RegionMask.Bit(2);     // catapults
    return mask;
}

StateMachine fsm = GraphBuilder
    .Start()
    .Parallel(ParallelStepMode.RunToJoin, SelectDefenses, archers, cauldrons, catapults)
    .ToStateMachine()
    .WithBlackboard(board);

The selector runs once per composite execution and fixes the selected set for that run; unselected regions are never stepped. An empty mask is a vacuous join (immediate Success); mask bits at or above the region count throw; up to 64 regions per composite. Selectors execute on the measured zero-allocation path, so compose masks with RegionMask.Bit(i) | ... as above — allocation-free — while RegionMask.Of(params) allocates its array and belongs at setup time only. Both variants exist in both runtimes (.Parallel(selector, ...) for async, .Parallel(mode, selector, ...) for sync). See NxFSM.Examples/ParallelDemo for runnable sync and async demos.

In-node concurrency: .ToAllAsync(...)

Parallel regions are cooperative, not thread-concurrent — and that is a recorded design decision, not an oversight: truly concurrent regions would make every shared Global/Graph board access a data race (forcing a thread-safety mode onto the blackboards), deliver observer callbacks concurrently (breaking the event-ordering contracts the observer tests codify), allocate task machinery in what is today a zero-allocation round loop, and have no meaningful sync twin. The library's position is regions structure logic, nodes structure time: when two 20-second I/O calls must overlap in wall-clock time, run them inside a single node with .ToAllAsync(...) — every work starts with the node's own CancellationToken, all are awaited, and the results join afterwards: Success iff every work returned Success, else one ordinary node Failure feeding the unified fault model (.Retry(...) re-runs all works; .OnError(...) sees one node failure). There is no early abort — a failing work never cancels its siblings, so partial effects are never timing-dependent (a consumer wanting first-failure-cancels composes a linked CTS inside its works); a throwing work propagates its exception after all works settle, and cancelling the machine's token cancels all works.

Disjoint keys are the contract: the works share one routed BlackboardContext, which is not thread-safe — concurrent works must touch disjoint keys. Same-key access from two works is a data race; distinct keys write to distinct slots and are genuinely safe. The recommended shape is one output port per work, combined by the next node:

static class ScoutIo
{
    public static readonly BlackboardSchema Schema = new("scout-io"); // Graph scope (default)
    public static readonly BlackboardKey<string> Weather = Schema.Register<string>("weather", "");
    public static readonly BlackboardKey<string> Terrain = Schema.Register<string>("terrain", "");
}

Graph graph = GraphBuilder
    .Start()
    .ToAllAsync( // both calls in flight at once — ~max(t1, t2), not t1 + t2
        async (bb, ct) => { bb.Set(ScoutIo.Weather, await FetchWeatherAsync(ct)); return Result.Success; },
        async (bb, ct) => { bb.Set(ScoutIo.Terrain, await FetchTerrainAsync(ct)); return Result.Success; })
    .ToAsync(ScoutIo.Weather, (weather, bb, ct) => // the next node combines the ports
        PlanRouteAsync(weather, bb.Get(ScoutIo.Terrain)))
    .WithSchema(ScoutIo.Schema)
    .Build();

The sync twin .ToAll(...) runs its works sequentially, in order, within one tick — wall-clock overlap is an async-only mechanic, exactly like retry backoff — with identical join semantics, including no early abort, so a graph moved between runtimes sees the same board effects; as plain ILogic it also runs under the async machine via the adapter, and it stays allocation-free per tick. .ToAllAsync allocates per execution by design (task materialization is dwarfed by the I/O it overlaps). The join is deliberately all-or-nothing: for some-of-many joining ("succeed when m of n arrive"), use the token runtime's JoinPolicy.Quorum — that is the logical quorum tool, and duplicating it at node level would blur the two models. See NxFSM.Examples/ReadmeExamples/InNodeConcurrencyExample.cs for the runnable version.

Token runtime: fork, join, and mid-graph merge

Parallel composites join at the composite boundary. When forked paths must rejoin the parent flow mid-graph — or the same node must be active for several work items at once — use the token runtime (NxGraph.Tokens): N pooled tokens flow through one flat graph, scheduled in cooperative rounds (one node per token per round, deliberately not thread-concurrent).

using NxGraph.Tokens;

JoinState join = new(JoinPolicy.All(2)); // or JoinPolicy.Any (merge), JoinPolicy.Quorum(m)

Graph graph = GraphBuilder
    .StartWith(() => Result.Success).SetName("Load")
    .ForkTo(
        b => b.To(() => Result.Success)   // branch 0 continues the arriving token
              .To(join)                   // converge by routing chains to the same JoinState
              .To(() => Result.Success),  // the surviving token carries on past the join
        b => b.To(() => Result.Success)   // every other branch spawns a new token
              .To(join))
    .Build();

TokenMachine machine = graph.ToTokenMachine();      // sync twin; frame-stepped like StateMachine
machine.SetStepMode(ParallelStepMode.RunToJoin);    // or RoundPerTick (default): one round per Execute()
Result result = machine.Execute();

AsyncTokenMachine asyncMachine = graph.ToAsyncTokenMachine(); // async twin: ExecuteAsync / StepAsync
  • Fork (.ForkTo(branch, branch, ...)): a token passing through continues into the first branch; each remaining branch spawns a new token. Forks carry no logic and no outgoing edge — the branches replace it.
  • Join (JoinState + JoinPolicy): arriving tokens park until the policy's count is met, then the join fires — one token continues along the join's ordinary success edge, the consumed ones retire, and the join re-arms. Any fires on every arrival, which makes it a mid-graph merge point; All(n) is the classic AND-join; Quorum(m) fires at m-of-n, and the late leftovers absorb benignly at run end.
  • Per-token fault model: a failing token consumes its node's .Retry(...) budget in place (the async machine honors backoff, the sync one retries next round), then follows the failure edge, else that token dies. The machine fails if any token died or a join starved (parked tokens at a join that never fired); surviving tokens always run to their natural end first.
  • Per-token scratch: Node-scoped blackboard keys are per token — each token carries its own transient board, reset exactly when that token's attempt counter resets.
  • Durability: Suspend() captures a TokenMachineSnapshot (the multiset of live tokens plus join bookkeeping) at a round boundary; Resume(snapshot) restores it on either token machine. The FSM StateMachineSnapshot contract is untouched.
  • Boundaries: fork/join nodes are interpreted only by the token machines — the FSM runtimes throw on them (and the validator flags token graphs with an Info). Graphs without fork/join nodes run identically under either family. Fork/join graphs ride GraphSerializer payloads like any other graph — branch order and join policies are plain structure — and TokenMachineSnapshot serializes independently with any serializer.
  • Visualization: graph.ToMermaid() renders fork/join first-class — bar shapes ([[...]]), solid fork-labeled branch edges (an AND-split, not a dashed runtime choice), and the join's policy in its label. The diamond above exports as:
flowchart LR
  n0(["Load"])
  n1["Audio"]
  n2[["Join : All(2)"]]
  n3["Ready"]
  n4["Terrain"]
  n5[["Fork"]]
  End(("End"))
  n0 --> n5
  n1 --> n2
  n2 --> n3
  n3 --> End
  n4 --> n2
  n5 -- fork --> n1
  n5 -- fork --> n4
Loading

See NxFSM.Examples/ReadmeExamples/TokenRunnerExamples.cs for the runnable version, including an M-of-N quorum flow and the Mermaid export.

Durable suspend / resume

Both runtimes can pause a run at a step boundary, persist it, and continue later — on the same machine, a fresh machine, another process, or even the other runtime (snapshots are interchangeable):

using System.Text.Json;

AsyncStateMachine first = graph.ToAsyncStateMachine();
await first.StepAsync();                              // advance one node
StateMachineSnapshot snapshot = first.Suspend();      // primitives-only record

string json = JsonSerializer.Serialize(snapshot);     // any serializer works

// Later — possibly after a process restart, on the sync runtime:
StateMachine second = graph.ToStateMachine();
second.Resume(JsonSerializer.Deserialize<StateMachineSnapshot>(json)!);

Result result = Result.InProgress;
while (result == Result.InProgress)
    result = second.Execute();                        // continues at the next node
  • Suspend() is legal between StepAsync()/Execute() calls of a stepped run, or on an idle/terminal machine; it captures the current node, status, retry attempts, and LastOutcome.
  • Resume(snapshot) requires a graph that is structurally equivalent (same node indices); re-attach agents/blackboards before continuing.
  • A fully durable flow persists three artifacts: the graph payload (GraphSerializer), one machine snapshot (shallow or deep, below), and one BlackboardSerializer payload per bound board — see Serialization.
  • Node-scoped blackboards are transient by definition and are not part of the durable flow: Resume(snapshot) restores Node keys to their registered defaults — a node suspended mid-visit loses its scratch.
  • Composite-internal progress (positions inside parallel regions or history children) is not part of the flat snapshot; a shallow-resumed composite starts its visit fresh. Use the deep pair below when suspension points live inside composites.

Deep suspend — capturing composite internals

SuspendDeep()/ResumeDeep(...) are the opt-in deep pair on both machines: same gates and rules as the shallow pair, but the snapshot additionally carries the composite tree — nested machine positions, a history composite's remembered child position, and the sync RoundPerTick composites' mid-visit bookkeeping (per-region done bits, dynamic-parallel deselection included):

using System.Text.Json;

AsyncStateMachine first = flow.ToAsyncStateMachine();    // flow nests a history subgraph
await first.StepAsync();                                 // child failed; parent is at the repair step
StateMachineDeepSnapshot deep = first.SuspendDeep();     // position + composite internals

string json = JsonSerializer.Serialize(deep);            // still plain records — any serializer works

// Later — fresh machine over an equivalent (rebuilt) graph:
AsyncStateMachine second = rebuiltFlow.ToAsyncStateMachine();
second.ResumeDeep(JsonSerializer.Deserialize<StateMachineDeepSnapshot>(json)!);
// on re-entry the history child resumes at its last-active node — not from its start
  • StateMachineDeepSnapshot = the shallow snapshot (Self) plus one CompositeSnapshot per composite that holds durable state; child machines carry their own deep snapshots recursively. Everything stays primitives/arrays — zero-configuration serializable, caller-serialized.
  • Capture is sparse and interface-driven (ISuspendableComposite): the sync nested machine, AsyncHistoryState/HistoryState, and the sync ParallelState/DynamicParallelState implement it; the async parallel composites run their regions to terminal within one execution, hold no durable state, and correctly contribute nothing. A composite absent from the snapshot re-enters fresh.
  • Custom containers opt in by implementing ISuspendableComposite — the third leg of the container contract beside ISubGraphProvider and IBlackboardSettable forwarding.
  • The shallow pair and StateMachineSnapshot are untouched — keep using them when flows put suspension points at the top level. Deep snapshots inherit the same rules: equivalent graph per nesting level, cross-runtime interchangeable, Node-scoped scratch resumes as defaults at every level.

See NxFSM.Examples/ReadmeExamples/FeatureExamples.cs for the runnable version.

Restart policy

Control what happens after the machine reaches a terminal status (Completed, Failed, or Cancelled):

Policy Behaviour
RestartPolicy.Auto (default) Automatically resets to Ready, ideal for Unity Update() loops
RestartPolicy.Manual Stays terminal; re-execution throws until Reset() is called explicitly
RestartPolicy.Ignore Stays terminal; further Execute() calls are no-ops that return the cached result
fsm.SetRestartPolicy(RestartPolicy.Auto);

// Backwards-compatible alias:
fsm.SetAutoReset(true);  // maps to RestartPolicy.Auto
fsm.SetAutoReset(false); // maps to RestartPolicy.Manual

Additional notes on execution:

  • reentrancy is guarded per machine instance, calling Execute() from inside a node throws
  • async execution accepts cancellation tokens
  • observer exceptions bubble to the caller by default
  • graphs are immutable after Build() and can be shared across machine instances

Validation

Build() already validates the graph. In DEBUG, invalid graphs throw immediately.

You can also validate a graph explicitly:

using NxGraph.Diagnostics.Validations;

Graph graph = GraphBuilder
    .StartWithAsync(_ => ResultHelpers.Success)
    .ToAsync(_ => ResultHelpers.Success)
    .Build();

GraphValidationResult validation = graph.Validate();
if (validation.HasErrors)
{
    foreach (GraphDiagnostic diagnostic in validation.Diagnostics)
    {
        Console.WriteLine(diagnostic);
    }
}

graph.ValidateAndThrowIfErrorsDebug();

Validation checks include:

  • broken transitions
  • reachability from the start node
  • self-loops (configurable)
  • terminal path analysis for director-driven graphs

Observability

Observers

Async observer:

using NxGraph.Fsm;
using NxGraph.Graphs;

public sealed class ConsoleObserver : IAsyncStateMachineObserver
{
    public ValueTask OnStateMachineStarted(NodeId graphId, CancellationToken ct = default)
    {
        Console.WriteLine($"FSM started: {graphId}");
        return ValueTask.CompletedTask;
    }

    public ValueTask OnStateEntered(NodeId id, CancellationToken ct = default)
    {
        Console.WriteLine($"Entered: {id.Name}");
        return ValueTask.CompletedTask;
    }

    public ValueTask OnTransition(NodeId from, NodeId to, CancellationToken ct = default)
    {
        Console.WriteLine($"Transition: {from.Name} -> {to.Name}");
        return ValueTask.CompletedTask;
    }

    public ValueTask OnStateExited(NodeId id, CancellationToken ct = default)
    {
        Console.WriteLine($"Exited: {id.Name}");
        return ValueTask.CompletedTask;
    }
}

Sync observer (IStateMachineObserver), all callbacks are void with default no-op implementations; override only what you need:

using NxGraph.Fsm;
using NxGraph.Graphs;

public sealed class DiagnosticObserver : IStateMachineObserver
{
    // Node lifecycle
    public void OnStateEntered(NodeId id) => Console.WriteLine($">> {id.Name}");
    public void OnStateExited(NodeId id)  => Console.WriteLine($"<< {id.Name}");
    public void OnTransition(NodeId from, NodeId to) =>
        Console.WriteLine($"   {from.Name} -> {to.Name}");
    public void OnStateFailed(NodeId id, Exception ex) =>
        Console.WriteLine($"FAIL {id.Name}: {ex.Message}");

    // Machine lifecycle
    public void OnStateMachineStarted(NodeId graphId) =>
        Console.WriteLine($"FSM started: {graphId.Name}");
    public void OnStateMachineCompleted(NodeId graphId, Result result) =>
        Console.WriteLine($"FSM done: {result}");
    public void OnStateMachineReset(NodeId graphId) { }

    // Status changes (e.g. Created → Starting → Running → Completed)
    public void StateMachineStatusChanged(NodeId graphId, ExecutionStatus prev, ExecutionStatus next) { }

    // Log messages emitted by State.Log()
    public void OnLogReport(NodeId nodeId, string message) =>
        Console.WriteLine($"[{nodeId.Name}] {message}");
}

State logging

Custom sync states can emit structured log messages through the observer without taking a direct dependency on a logger:

using NxGraph.Fsm;

public sealed class WorkState : State
{
    protected override Result OnRun()
    {
        Log("starting heavy computation");
        // ... do work ...
        Log("computation complete");
        return Result.Success;
    }
}

Log(message) routes to IStateMachineObserver.OnLogReport when an observer is attached, and is a no-op otherwise.

Tracing

On .NET 8+, TracingObserver emits Activity spans/tags for state machine and node execution.

using NxGraph.Fsm;

IAsyncStateMachineObserver observer = new TracingObserver();
AsyncStateMachine fsm = graph.ToAsyncStateMachine(observer);
await fsm.ExecuteAsync();

This integrates naturally with OpenTelemetry pipelines listening to the ActivitySource named "NxGraph".

Replay

Capture a machine run and replay the event stream later:

using NxGraph.Diagnostics.Replay;
using NxGraph.Fsm;

ReplayRecorder recorder = new();
AsyncStateMachine fsm = graph.ToAsyncStateMachine(recorder);
await fsm.ExecuteAsync();

StateMachineReplay replay = new(recorder.GetEvents().Span);
replay.ReplayAll(evt =>
{
    Console.WriteLine($"{evt.Type}: {evt.SourceId} -> {evt.TargetId} | {evt.Message}");
});

byte[] bytes = replay.Serialize();
ReplayEvent[] roundTripped = StateMachineReplay.Deserialize(bytes);

Replay persistence is its own binary event format; it is separate from graph serialization.


Visualization

Export graphs to Mermaid for docs, PRs, or operations runbooks.

using NxGraph.Diagnostics.Export;

string mermaid = GraphBuilder
    .StartWithAsync(_ => ResultHelpers.Success).SetName("Start")
    .ToAsync(_ => ResultHelpers.Success).SetName("Process")
    .ToAsync(_ => ResultHelpers.Success).SetName("End")
    .Build()
    .ToMermaid();

Console.WriteLine(mermaid);

Serialization

NxGraph.Serialization serializes graphs using an application-provided logic codec.

Text codec example:

using System.Text.Json;
using NxGraph;
using NxGraph.Authoring;
using NxGraph.Graphs;
using NxGraph.Serialization;

public sealed class ExampleState : IAsyncLogic
{
    public string Data { get; set; } = string.Empty;

    public ValueTask<Result> ExecuteAsync(CancellationToken ct = default)
        => ResultHelpers.Success;
}

public sealed class ExampleLogicCodec : ILogicTextCodec
{
    public string Serialize(IAsyncLogic data)
        => JsonSerializer.Serialize((ExampleState)data);

    public IAsyncLogic Deserialize(string payload)
        => JsonSerializer.Deserialize<ExampleState>(payload)
           ?? throw new InvalidOperationException("Failed to deserialize ExampleState.");
}

Graph graph = GraphBuilder
    .StartWithAsync(new ExampleState { Data = "start" }).SetName("Start")
    .ToAsync(new ExampleState { Data = "end" }).SetName("End")
    .Build()
    .SetName("ExampleGraph");

GraphSerializer serializer = new(new ExampleLogicCodec());

await using MemoryStream stream = new();
await serializer.ToJsonAsync(graph, stream);
stream.Position = 0;

Graph roundTripped = await serializer.FromJsonAsync(stream);

Notes:

  • graph serialization is optional and lives in a separate package
  • serializer usage is instance-based
  • JSON and MessagePack are both supported through GraphSerializer
  • your codec controls how node logic is persisted and restored
  • nested machines, history/parallel composites, and token fork/join nodes serialize out of the box; dynamic parallel composites need a GraphSerializerOptions.SelectorRegistry (the selector delegate rides as a named key — author graphs with the delegate instance RegionSelectorRegistry.Register returns), and custom ISubGraphProvider containers need a GraphSerializerOptions.ContainerCodec (you own the reconstruction recipe; the serializer recurses into SubGraphs for you, order-preserving)
  • event entry dispatchers serialize out of the box (payload version 7): the dispatch table — key names, runtime-stable event type names, targets, and the Otherwise target — is plain structure. Blackboard keys never ride the graph payload (schemas are code), so a deserialized graph raises by resolving the event's type name and the delivery key by name against the machine's bound Graph board, with targeted errors on a missing name or a changed value type
  • behavior composites serialize out of the box for the standard set (payload version 8): Log and SetValue<T> ride as self-describing field lists with zero options configured — the default BehaviorRegistry reconstructs them, closing SetValue<T> (and BehaviorState<TAgent>'s agent type) from runtime-stable type names. Key bindings ride by name and rebind against the machine's bound boards at execution. Custom behaviors implement ISerializableBehavior (writing through the small neutral field model: strings, bools, numerics, enums, bindings) and register a reconstruction factory on GraphSerializerOptions.BehaviorRegistry; a behavior that does neither fails with a targeted error naming that option. The agent never rides — re-attach it via SetAgent/WithAgent
  • Repeat bodies ride as nested behavior entry lists (payload version 9): all four repeat forms serialize with zero options via the default registry, bodies encode recursively under exactly the top-level entry rules (user behaviors nested in a body follow the same ISerializableBehavior + factory contract), the count binding and index key rebind by name, and read-side nesting is capped at 32 as a crafted-payload guard. Pre-v9 payloads read unchanged
  • data-built branches serialize out of the box (payload version 10): a ChoiceState's condition list and a SwitchState<T>'s key, literal cases and default target ride as two sparse sections, and the standard conditions (KeyEquals<T>, IsTrue, Not) reconstruct with zero options through the default ConditionRegistry — nested Not conditions encode recursively under the same rules and the same read-side depth cap. Custom conditions implement ISerializableCondition and register a factory on GraphSerializerOptions.ConditionRegistry, exactly as custom behaviors do. Keys never ride typed: the switch's key and KeyEquals<T>'s key rebind by name against the machine's bound boards at execution. Pre-v10 payloads read branch-free — which is the point of the whole feature: a graph that branches can now be suspended, stored and resumed

Examples

The solution includes a runnable examples project with:

  • a simple async FSM
  • an AI enemy example (typed agent injection)
  • Mermaid export example
  • a serialization round-trip example
  • a sync Dungeon Crawler example using the DSL, observers, director nodes, loops, and named states
  • a blackboard demo (scoped shared memory, schema declarations, per-entity boards)
  • a step I/O ports demo (typed produce → pipe → consume chains over Graph-scoped port keys)
  • parallel-region demos: the sync Stronghold Siege (ParallelStepMode.RoundPerTick frame ticking, RunToJoin waves, blackboard-selected dynamic regions) and the async Expedition Camp (cooperative round-robin interleaving under AsyncStateMachine, dynamic region selection)

Run it with:

dotnet run --project NxFSM.Examples

Benchmarks

Benchmarks live in NxGraph.Benchmarks and use BenchmarkDotNet. The suite covers both AsyncStateMachine and StateMachine (sync), and also measures equivalent Stateless scenarios for comparison.

Run them with:

dotnet run --project NxGraph.Benchmarks -c Release

Results

Runtime: .NET 8.0.27 (RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI) · BenchmarkDotNet v0.13.12 · ShortRun job · 2026-07-19. Indicative numbers from a dev machine — compare rows within this table, not across README revisions.

Benchmark chart

Async AsyncStateMachine:

Scenario Mean Alloc
Single node (RelayState.Success) ★ 246 ns 0 B
Single node + NoopObserver 301 ns 0 B
Timeout wrapper (immediate success) 382 ns 0 B
Chain × 10 nodes 1,107 ns 0 B
Director-driven × 10 nodes 1,225 ns 0 B
Chain × 50 nodes 4,851 ns 0 B

Sync StateMachine:

Scenario Mean Alloc
Single node ★ 56 ns 0 B
Single node + SyncNoopObserver 62 ns 0 B
Timeout wrapper (immediate success) 69 ns 0 B
Chain × 10 nodes 385 ns 0 B
Director-driven × 10 nodes 467 ns 0 B
Chain × 50 nodes 1,480 ns 0 B

★ baseline

Key observations:

  • Zero allocations: both runtimes are fully alloc-free after graph construction.
  • Sync is ~4× faster on a single node: 56 ns vs 246 ns, reflecting the absence of async machinery and Interlocked operations.
  • Observer overhead is constant and runtime-dependent: +6 ns for sync, +55 ns for async, independent of chain length.
  • Timeout wrapper overhead mirrors the same split: +13 ns sync, +136 ns async over the bare single node.
  • Per-node cost falls with chain length: async 246 ns for 1 node → 111 ns/node for 10 → 97 ns/node for 50; sync 56 ns → 39 ns/node → 30 ns/node.
  • Director nodes add ~118 ns (async) / ~82 ns (sync) over the plain 10-node chain of the same runtime.

Testing

Run the full test suite:

dotnet test -c Release

The tests cover:

  • sync and async execution
  • stepped execution (SteppedExecutionTests), one-node-per-tick semantics, multi-frame nodes, restart policies
  • reentrancy and cancellation
  • observers and log reports
  • replay
  • validation
  • Mermaid export
  • serialization round-trips

FAQ

Why is there only one direct success transition per node?
Branching is modeled explicitly through directors — ChoiceState/SwitchState<T> when the decision is data, RelayChoiceState/RelaySwitchState<TKey> when it is code — which keeps execution simple and predictable. A node can additionally carry one failure edge (.OnError) for the fault path. When several paths must run at once, use the parallel composites instead of extra edges — see Fan-out at a glance; a token runner with free-form fan-out in one flat graph is a recorded, deliberately deferred design.

Can I share a graph across machines?
Yes. Graph is immutable after build and can be reused across multiple state machine instances.

Do observer exceptions get swallowed?
No. They bubble by default.

When should I name nodes?
Almost always. Names improve logs, observer output, replay traces, and Mermaid diagrams.

Does the core package include Mermaid export and replay?
Yes. Those features are part of NxGraph itself; graph serialization is the optional extra package.

Can I use NxGraph in Unity?
Yes. Use StateMachine (the sync runtime) and call Execute() from MonoBehaviour.Update(). Execute() advances exactly one node per call so the main thread is never blocked. Set RestartPolicy.Auto for automatic reset between runs, or RestartPolicy.Ignore to freeze the machine in its terminal state until you explicitly call Reset(). See Unity integration for a full example.

What does Result.InProgress mean?
The machine has more nodes to process but is returning control to the caller (e.g. to avoid blocking a frame in Unity). Call Execute() again on the next frame. A node can also return Result.InProgress from its own OnRun() to signal it needs multiple frames (e.g. a frame-based timer).


Roadmap

  • sync twins for the remaining async-only constructs (history subgraphs, waits, timeouts)
  • validator and Mermaid-export awareness of composite interiors
  • continued ergonomics improvements around DSL authoring and serialization

Contributing

PRs are welcome. Please run formatting and tests before submitting:

dotnet test

License

MIT. See LICENSE for details.

About

NxGraph is a zero-allocation runtime, high-performance finite state machine (FSM) framework for .NET 8+, designed for scenarios where execution speed, memory efficiency, and runtime safety are critical.

Topics

Resources

Stars

105 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages