Skip to content

kids.kapish.statemachines

unity

Technical reference for the kids.kapish.statemachines Unity package -- state machine runtime for menus, cutscenes, scene switching, and content-platform-driven flows.

Package Identity

  • Package name: kids.kapish.statemachines
  • Display name: Carrot.StateMachines
  • Version: 0.1.0
  • Minimum Unity: 6000.0
  • License: MIT
  • Dependencies: kids.kapish 0.1.0, kids.kapish.addressables 0.1.0

Assembly Structure

The package is split across three assembly definitions:

Carrot.StateMachines.Runtime (Runtime)

  • Path: Runtime/Carrot.StateMachines.Runtime.asmdef
  • Root namespace: Carrot.StateMachines
  • References: Carrot, Carrot.Precompiled, Carrot.StateMachines.Precompiled, Carrot.Addressables
  • Engine references: Yes
  • Platforms: All

Contains the four Unity-specific types: StateMachineHost, StateMachineLoader, StateMachineTemplateAsset, and IStateNodeAwaitable. This is the assembly consuming packages reference.

Carrot.StateMachines.Editor (Editor)

  • Path: Editor/Carrot.StateMachines.Editor.asmdef
  • Root namespace: Carrot.StateMachines.Editor
  • References: Carrot, Carrot.Precompiled, Carrot.Editor, Carrot.StateMachines.Runtime
  • Platforms: Editor only

Custom inspector for StateMachineTemplateAsset with JSON validation.

Carrot.StateMachines.Precompiled (Plugin -- no engine)

  • Path: Runtime/Plugins/Carrot/Carrot.StateMachines.Precompiled.asmdef
  • Root namespace: Carrot.StateMachines
  • Engine references: No
  • References: None

Wraps the precompiled Carrot.StateMachines.dll containing the entire netstandard state machine engine: StateMachine<TNode, TEdge>, builders, factory, template types, nodes, edges, guards, and the handler registry. This assembly has noEngineReferences: true, keeping the core engine Unity-independent.

Architecture

Two-Layer Design

The package follows a strict two-layer architecture:

  1. Netstandard core (Carrot.StateMachines.dll) -- Pure C# state machine engine with no Unity dependencies. Handles state graph construction, transition logic, guard evaluation, ticking, re-entrancy protection, and template resolution. This layer uses Task-based async via IStateNodeAsync.

  2. Unity shell (Carrot.StateMachines.Runtime) -- Four types that bridge the core engine to Unity. StateMachineHost provides MonoBehaviour lifecycle, Awaitable-based async (via IStateNodeAwaitable), and destroyCancellationToken integration. StateMachineLoader bridges to Addressables. StateMachineTemplateAsset provides ScriptableObject persistence.

This separation means the state machine logic is testable outside Unity and shareable with non-Unity C# contexts.

StateMachineHost Lifecycle

StateMachineHost is the central runtime component:

  1. Initialize -- either with a pre-built StateMachine<IStateNode, StateEdge> instance or with a StateMachineTemplate + StateMachineHandlerRegistry (which builds the machine internally via StateMachineFactory).
  2. StartMachine -- enters the initial state using the async path. Calls OnEnterAsync for awaitable nodes, OnEnter for sync nodes.
  3. Update -- ticks the current node with Time.deltaTime each frame (skipped during transitions).
  4. Fire/FireAsync -- triggers transitions. Fire is synchronous. FireAsync uses Awaitable and respects the _transitioning guard to prevent re-entrant transitions.
  5. GoToAsync -- jumps directly to a state, bypassing triggers and guards.
  6. StateChanged signal -- dispatches Signal<StateChange> (payload: StateChange { From, To } — both strings) after each successful transition.

The host uses destroyCancellationToken for all async operations, automatically cancelling transitions when the GameObject is destroyed.

Awaitable vs Task Async

The netstandard core uses Task-based async (IStateNodeAsync). The Unity layer introduces IStateNodeAwaitable which uses Unity's Awaitable type -- zero-alloc, PlayerLoop-integrated, available from Unity 6+. The host checks for IStateNodeAwaitable first and falls back to sync IStateNode if the node doesn't implement it.

This means consuming code should implement IStateNodeAwaitable for Unity-native async (scene loading, Addressable loading, animation waits) and plain IStateNode for simple synchronous states.

Template System

Templates provide a JSON-driven, data-first approach to state machine definition:

  1. StateMachineTemplate -- the wire format. Contains nodes (id + type + params + meta), edges (from + to + trigger + guard + meta), initial state, schema ID, and schema version.
  2. StateMachineHandlerRegistry -- maps type strings to IStateNodeHandler and IStateGuardHandler factories. Supports both interface implementations and delegate lambdas.
  3. StateMachineFactory.Build -- resolves each template node through the registry, builds edges with optional guards (including recursive composite guards: all, any, not), and constructs the final StateMachine instance.
  4. StateMachineTemplateAsset -- stores the raw JSON in a ScriptableObject. Deserialization is deferred to the consumer, keeping the framework JSON-library-agnostic.

The Meta dictionaries on nodes and edges use a Dictionary<string, Dictionary<string, object?>> structure -- the outer key is a namespace (e.g. "editor", "i18n", "platform"), the inner dictionary holds the metadata values. This allows multiple systems to annotate the same template without collision.

Guard Composition

Guards support a tree structure via template guards:

  • Leaf guards -- resolved by type string through the registry
  • all -- StateGuardAll requires all children to pass
  • any -- StateGuardAny requires at least one child to pass
  • not -- StateGuardNot negates a single child

StateMachineFactory.BuildGuard recursively builds this tree from StateMachineTemplateGuard instances.

Code-First Builder

For state machines defined entirely in code, StateMachineBuilder provides a fluent API:

  • .State(id, enter?, exit?, tick?) -- adds a callback-based StateNode
  • .Edge(from, to, trigger, guard?) -- adds a StateEdge with optional Func<bool> guard
  • .InitialState(id) -- sets the starting state (defaults to the first added state)
  • .Build() -- produces a StateMachine<StateNode, StateEdge>

The generic StateMachineBuilder<TNode, TEdge> variant accepts custom node/edge types for full control.

Addressable Loading

StateMachineLoader provides two async methods:

  • LoadAsync -- loads JSON text from an IAddressableId, deserializes it via a provided Func<string, StateMachineTemplate>, and builds the machine.
  • LoadIntoHostAsync -- same as above but directly initializes a StateMachineHost.

Both accept a CancellationToken and delegate the JSON deserialization to the caller, maintaining library-agnosticism.

Key Design Decisions

  1. Engine-free core. The entire state machine engine is a precompiled netstandard DLL with noEngineReferences: true. This makes the logic testable in pure C# and reusable outside Unity.

  2. Awaitable over Task. The Unity layer uses Awaitable rather than wrapping Task. This provides zero-alloc async integrated with Unity's PlayerLoop, without the pitfalls of Task in Unity (thread marshalling, lifecycle mismatch).

  3. JSON-library-agnostic templates. StateMachineTemplateAsset stores raw JSON strings and delegates deserialization to the consumer. This avoids forcing a dependency on Newtonsoft, System.Text.Json, or any other serializer.

  4. Handler registry pattern. Node and guard types are resolved by string keys through a registry rather than by reflection or convention. This is explicit, fast, and easy to debug -- you always know exactly which handlers are available.

  5. Re-entrancy protection. Both the netstandard core and the Unity host guard against re-entrant transitions (_transitioning flag). FireAsync and GoToAsync return false immediately if a transition is already in progress.

  6. ScriptableObject templates. Templates are stored as ScriptableObjects rather than plain JSON files. This gives them asset references, inspector tooling, and integration with Unity's asset pipeline while keeping the actual content as portable JSON.

  7. Namespaced metadata. Template nodes and edges carry Meta dictionaries keyed by namespace. This allows editor tooling, i18n, content platform metadata, and other concerns to coexist on the same template without interfering with each other.


Usage Guide

Practical guide to the kids.kapish.statemachines package -- state machine runtime for menus, cutscenes, scene switching, and content-platform-driven flows.

Installation

Add kids.kapish.statemachines as a dependency in your package's package.json:

json
{
  "dependencies": {
    "kids.kapish.statemachines": "0.1.0"
  }
}

Reference the runtime assembly in your .asmdef:

json
{
  "references": ["Carrot.StateMachines.Runtime"]
}

Code-First State Machines

Basic Menu Flow

Build a state machine inline with callbacks:

csharp
using Carrot.StateMachines;
using Carrot.StateMachines.Nodes;
using UnityEngine;

public class MenuFlow : MonoBehaviour
{
    [SerializeField] private StateMachineHost host;
    [SerializeField] private GameObject titlePanel;
    [SerializeField] private GameObject optionsPanel;
    [SerializeField] private GameObject creditsPanel;

    async void Start()
    {
        var machine = new StateMachineBuilder()
            .State("title",
                enter: () => titlePanel.SetActive(true),
                exit:  () => titlePanel.SetActive(false))
            .State("options",
                enter: () => optionsPanel.SetActive(true),
                exit:  () => optionsPanel.SetActive(false))
            .State("credits",
                enter: () => creditsPanel.SetActive(true),
                exit:  () => creditsPanel.SetActive(false))
            .Edge("title", "options", "open_options")
            .Edge("title", "credits", "open_credits")
            .Edge("options", "title", "back")
            .Edge("credits", "title", "back")
            .InitialState("title")
            .Build();

        host.Initialize(machine);
        await host.StartMachine();
    }

    // Wire these to UI buttons
    public void OnOptionsClicked() => host.Fire("open_options");
    public void OnCreditsClicked() => host.Fire("open_credits");
    public void OnBackClicked() => host.Fire("back");
}

Guarded Transitions

Add conditions to edges:

csharp
bool hasCompletedTutorial = false;

var machine = new StateMachineBuilder()
    .State("lobby")
    .State("tutorial")
    .State("game")
    .Edge("lobby", "game", "play", guard: () => hasCompletedTutorial)
    .Edge("lobby", "tutorial", "play", guard: () => !hasCompletedTutorial)
    .Edge("tutorial", "lobby", "complete")
    .InitialState("lobby")
    .Build();

When "play" fires, the first matching edge whose guard passes wins. If the tutorial is not complete, the player goes to tutorial; otherwise straight to game.


Async State Nodes

IStateNodeAwaitable

For states that need to load scenes, fetch data, or animate transitions, implement IStateNodeAwaitable:

csharp
using System.Threading;
using Carrot.StateMachines;
using Carrot.StateMachines.Nodes;
using UnityEngine;
using UnityEngine.SceneManagement;

public class SceneLoadState : IStateNodeAwaitable
{
    private readonly string sceneName;

    public SceneLoadState(string id, string sceneName)
    {
        this.Id = id;
        this.sceneName = sceneName;
    }

    public string Id { get; }

    public async Awaitable OnEnterAsync(CancellationToken ct)
    {
        var op = SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive);
        while (!op.isDone)
        {
            ct.ThrowIfCancellationRequested();
            await Awaitable.NextFrameAsync(ct);
        }
    }

    public async Awaitable OnExitAsync(CancellationToken ct)
    {
        await SceneManager.UnloadSceneAsync(sceneName);
    }

    // Sync stubs -- the host calls the async versions instead
    public void OnEnter() { }
    public void OnExit() { }
    public void OnTick(float dt) { }
}

Using Async Transitions

Use FireAsync instead of Fire when your state machine contains awaitable nodes:

csharp
public async void OnPlayClicked()
{
    bool transitioned = await host.FireAsync("play");

    if (transitioned)
    {
        Debug.Log($"Now in state: {host.CurrentStateId}");
    }
}

Direct State Jumps

Skip triggers and guards entirely with GoToAsync:

csharp
// Emergency return to title -- no edge required
await host.GoToAsync("title");

Listening to State Changes

csharp
var unsub = host.StateChanged.Add(change =>
{
    Debug.Log($"Transitioned: {change.From} -> {change.To}");
    analytics.TrackStateChange(change.From, change.To);
});
// call unsub() later to unsubscribe, or host.StateChanged.Remove(handler)

Add kids.kapish.signals to your asmdef references if using these signals from your own code.

Checking Available Transitions

csharp
if (host.CanFire("play"))
{
    playButton.interactable = true;
}

// Check if mid-transition (useful for disabling UI)
if (host.IsTransitioning)
{
    return;
}

Template-Driven State Machines

Defining a Handler Registry

Register factories that map template type strings to runtime nodes and guards:

csharp
using Carrot.StateMachines.Nodes;
using Carrot.StateMachines.Edges;
using Carrot.StateMachines.Templates;

var registry = new StateMachineHandlerRegistry();

// Register node handlers with lambdas
registry.RegisterNodeHandler("panel", (id, @params) =>
{
    string panelName = (string)@params["panel"];
    GameObject panel = panels[panelName];

    return new StateNode(id,
        enter: () => panel.SetActive(true),
        exit:  () => panel.SetActive(false));
});

registry.RegisterNodeHandler("scene", (id, @params) =>
{
    string sceneName = (string)@params["scene"];
    return new SceneLoadState(id, sceneName);
});

// Register guard handlers
registry.RegisterGuardHandler("flag", (@params) =>
{
    string flagName = (string)@params["flag"];
    return new StateGuard(() => gameFlags.IsSet(flagName));
});

Loading from a ScriptableObject Asset

csharp
[SerializeField] private StateMachineTemplateAsset templateAsset;
[SerializeField] private StateMachineHost host;

async void Start()
{
    StateMachineTemplate template = templateAsset.Deserialize(json =>
        JsonUtility.FromJson<StateMachineTemplate>(json));

    host.Initialize(template, registry);
    await host.StartMachine();
}

Loading from Addressables

csharp
using Carrot.StateMachines;
using Carrot.Addressables;

async void Start()
{
    await StateMachineLoader.LoadIntoHostAsync(
        host,
        myAddressableId,
        registry,
        json => JsonConvert.DeserializeObject<StateMachineTemplate>(json),
        destroyCancellationToken);

    await host.StartMachine();
}

Or load the machine directly:

csharp
var machine = await StateMachineLoader.LoadAsync(
    myAddressableId,
    registry,
    json => JsonConvert.DeserializeObject<StateMachineTemplate>(json),
    destroyCancellationToken);

host.Initialize(machine);
await host.StartMachine();

Template JSON Format

A StateMachineTemplate serializes to JSON like this:

json
{
  "SchemaId": "menu-flow",
  "SchemaVersion": 1,
  "InitialStateId": "title",
  "Nodes": [
    { "Id": "title", "Type": "panel", "Params": { "panel": "TitlePanel" }, "Meta": {} },
    { "Id": "options", "Type": "panel", "Params": { "panel": "OptionsPanel" }, "Meta": {} },
    { "Id": "game", "Type": "scene", "Params": { "scene": "GameScene" }, "Meta": {} }
  ],
  "Edges": [
    { "Id": "e1", "From": "title", "To": "options", "Trigger": "open_options" },
    { "Id": "e2", "From": "options", "To": "title", "Trigger": "back" },
    {
      "Id": "e3", "From": "title", "To": "game", "Trigger": "play",
      "Guard": { "Type": "flag", "Params": { "flag": "tutorial_complete" } }
    }
  ]
}

Composite Guards in Templates

json
{
  "Guard": {
    "Type": "all",
    "Children": [
      { "Type": "flag", "Params": { "flag": "tutorial_complete" } },
      { "Type": "flag", "Params": { "flag": "has_save_data" } }
    ]
  }
}

Built-in composite types: all (AND), any (OR), not (negation of a single child).


Template Assets in the Editor

Create a template asset via Create > Carrot > State Machine Template in the Project window.

The custom inspector shows:

  • Schema ID -- optional identifier for content platform filtering
  • Template JSON -- multi-line text area for the JSON content
  • Validate JSON -- button that checks JSON is parseable

Custom Node Types with the Generic Builder

For full control, use the generic StateMachineBuilder<TNode, TEdge>:

csharp
using Carrot.StateMachines;
using Carrot.StateMachines.Edges;

public class CutsceneNode : IStateNodeAwaitable
{
    public string Id { get; }
    public TimelineAsset Timeline { get; }

    public CutsceneNode(string id, TimelineAsset timeline)
    {
        Id = id;
        Timeline = timeline;
    }

    public async Awaitable OnEnterAsync(CancellationToken ct)
    {
        director.Play(Timeline);
        while (director.state == PlayState.Playing)
        {
            ct.ThrowIfCancellationRequested();
            await Awaitable.NextFrameAsync(ct);
        }
    }

    public async Awaitable OnExitAsync(CancellationToken ct) { }
    public void OnEnter() { }
    public void OnExit() { }
    public void OnTick(float dt) { }
}

// Build with custom node type
var machine = new StateMachineBuilder<CutsceneNode, StateEdge>()
    .State(new CutsceneNode("intro", introTimeline))
    .State(new CutsceneNode("battle", battleTimeline))
    .State(new CutsceneNode("ending", endingTimeline))
    .Edge(new StateEdge("intro", "battle", "next"))
    .Edge(new StateEdge("battle", "ending", "next"))
    .InitialState("intro")
    .Build();

Ticking States

The StateMachineHost calls OnTick(float dt) on the current node every Update frame (skipped during async transitions). Use this for per-frame state logic:

csharp
var machine = new StateMachineBuilder()
    .State("countdown",
        enter: () => timer = 3f,
        tick:  dt =>
        {
            timer -= dt;
            countdownText.text = Mathf.CeilToInt(timer).ToString();
        })
    .State("go",
        enter: () => countdownText.text = "GO!")
    .Edge("countdown", "go", "timer_done")
    .Build();

To trigger the transition from within tick logic, fire the trigger on the next frame to avoid re-entrancy:

csharp
tick: dt =>
{
    timer -= dt;
    if (timer <= 0)
    {
        // Safe: host.Fire is re-entrancy-protected and will just return false
        // during a transition. Call it outside the tick via a flag or Awaitable.
    }
}

Carrot