Appearance
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.kapish0.1.0,kids.kapish.addressables0.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:
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 usesTask-based async viaIStateNodeAsync.Unity shell (
Carrot.StateMachines.Runtime) -- Four types that bridge the core engine to Unity.StateMachineHostprovidesMonoBehaviourlifecycle,Awaitable-based async (viaIStateNodeAwaitable), anddestroyCancellationTokenintegration.StateMachineLoaderbridges to Addressables.StateMachineTemplateAssetprovidesScriptableObjectpersistence.
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:
- Initialize -- either with a pre-built
StateMachine<IStateNode, StateEdge>instance or with aStateMachineTemplate+StateMachineHandlerRegistry(which builds the machine internally viaStateMachineFactory). - StartMachine -- enters the initial state using the async path. Calls
OnEnterAsyncfor awaitable nodes,OnEnterfor sync nodes. - Update -- ticks the current node with
Time.deltaTimeeach frame (skipped during transitions). - Fire/FireAsync -- triggers transitions.
Fireis synchronous.FireAsyncusesAwaitableand respects the_transitioningguard to prevent re-entrant transitions. - GoToAsync -- jumps directly to a state, bypassing triggers and guards.
- 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:
StateMachineTemplate-- the wire format. Contains nodes (id + type + params + meta), edges (from + to + trigger + guard + meta), initial state, schema ID, and schema version.StateMachineHandlerRegistry-- maps type strings toIStateNodeHandlerandIStateGuardHandlerfactories. Supports both interface implementations and delegate lambdas.StateMachineFactory.Build-- resolves each template node through the registry, builds edges with optional guards (including recursive composite guards:all,any,not), and constructs the finalStateMachineinstance.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--StateGuardAllrequires all children to passany--StateGuardAnyrequires at least one child to passnot--StateGuardNotnegates 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-basedStateNode.Edge(from, to, trigger, guard?)-- adds aStateEdgewith optionalFunc<bool>guard.InitialState(id)-- sets the starting state (defaults to the first added state).Build()-- produces aStateMachine<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 anIAddressableId, deserializes it via a providedFunc<string, StateMachineTemplate>, and builds the machine.LoadIntoHostAsync-- same as above but directly initializes aStateMachineHost.
Both accept a CancellationToken and delegate the JSON deserialization to the caller, maintaining library-agnosticism.
Key Design Decisions
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.Awaitable over Task. The Unity layer uses
Awaitablerather than wrappingTask. This provides zero-alloc async integrated with Unity's PlayerLoop, without the pitfalls ofTaskin Unity (thread marshalling, lifecycle mismatch).JSON-library-agnostic templates.
StateMachineTemplateAssetstores raw JSON strings and delegates deserialization to the consumer. This avoids forcing a dependency on Newtonsoft, System.Text.Json, or any other serializer.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.
Re-entrancy protection. Both the netstandard core and the Unity host guard against re-entrant transitions (
_transitioningflag).FireAsyncandGoToAsyncreturnfalseimmediately if a transition is already in progress.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.
Namespaced metadata. Template nodes and edges carry
Metadictionaries 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.signalsto 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.
}
}