C# Target
Generate high-performance state machines for .NET applications.
Table of Contents
- Quick Start
- Code Generation
- Datamodels
- Runtime Integration
- Event Handling
- Invoke Support
- Unity Integration
- W3C Compliance
Quick Start
1. Generate C# Code
# Generate from SCXML
scxml-gen traffic.scxml -t csharp -o TrafficLight.cs --namespace MyApp.StateMachines
2. Build the Generated Project
The generator produces a ready-to-build .NET 8 project — no NuGet packages to configure manually.
cd output/
dotnet build # restores Jint automatically
dotnet run # runs Program.cs demo
Generated project layout:
output/
├── TrafficLight.cs # Generated state machine
├── TrafficLight.csproj # Main project (net8.0)
├── TrafficLight.sln # Solution file
├── Program.cs # Ready-to-run demo
└── ScxmlGen.Runtime/ # Bundled runtime source
├── TranspiledStateMachine.cs
├── Executor/
├── Trace/
└── ...
Code-only mode: Use
--code-onlyto generate just the.csfile when integrating into an existing project that already referencesScxmlGen.Runtime.
3. Use the State Machine
using MyApp.StateMachines;
using ScxmlGen.Runtime.Executor;
var machine = new TrafficLight();
// ContinuousExecutor handles delayed events autonomously — no manual PumpEvents() needed
using var executor = new ContinuousExecutor(machine);
executor.Start();
await executor.SendAsync("timer");
Console.WriteLine(machine.IsInState("green")); // true
Code Generation
CLI Options
scxml-gen input.scxml -t csharp [options]
| Option | Description | Example |
|---|---|---|
-o, --output |
Output file path | -o Output.cs |
--namespace |
C# namespace | --namespace MyApp.Machines |
--code-only |
Generate only the .cs file (no project, no runtime) |
--code-only |
--bundle-auto |
Auto-bundle invoke children | --bundle-auto |
--bundle |
Manually bundle children | --bundle child.scxml |
Generated Code Structure
using System;
using System.Collections.Generic;
using ScxmlGen.Runtime;
namespace MyNamespace
{
public class MyStateMachine : TranspiledStateMachine
{
// State constants
public const int S_IDLE = 0;
public const int S_RUNNING = 1;
// Event constants (O(1) integer dispatch)
public const int EVT_START = 1;
public const int EVT_STOP = 2;
public const int EVT_UNKNOWN = 99999;
// Datamodel fields (native-csharp)
protected int counter;
protected List<string> items;
public MyStateMachine() : base()
{
Name = "MyStateMachine";
InitializeStates();
}
// State entry/exit methods
protected virtual void OnEntryIdle() { ... }
protected virtual void OnExitIdle() { ... }
// Transition handling
protected override void ProcessEvent(string eventName) { ... }
}
}
Datamodels
ECMAScript (Jint)
Full JavaScript expression support via the Jint engine.
<scxml datamodel="ecmascript" initial="start">
<datamodel>
<data id="user" expr="{name: 'Alice', age: 30}"/>
<data id="items" expr="[]"/>
</datamodel>
<state id="start">
<onentry>
<script>
items.push(user.name);
user.age++;
</script>
</onentry>
<transition cond="items.length > 0" target="done"/>
</state>
<final id="done"/>
</scxml>
Features:
- Full ECMAScript 5.1+ support
- JSON object literals
- Array methods (push, pop, map, filter)
- XML DOM support for W3C compliance
- 100% W3C conformance (183/183 tests)
Native-CSharp
Compile-time C# code generation with full type safety.
<scxml datamodel="native-csharp" initial="init">
<datamodel>
<data id="counter" type="int" expr="0"/>
<data id="items" type="List<string>" expr="new List<string>()"/>
<data id="config" type="Dictionary<string, object>"
expr="new Dictionary<string, object>()"/>
</datamodel>
<state id="init">
<onentry>
<script>
// Direct C# code
for (int i = 0; i < 5; i++) {
items.Add($"Item {i}");
}
// LINQ
var filtered = items.Where(x => x.Contains("2")).ToList();
// .NET APIs
config["timestamp"] = DateTime.UtcNow;
</script>
</onentry>
<transition cond="items.Count >= 5" target="done"/>
</state>
<final id="done"/>
</scxml>
Supported Types:
- Primitives:
int,long,double,float,bool,string - Collections:
List<T>,Dictionary<K,V>,HashSet<T> - Nullable:
int?,string? - Custom classes (must be fully qualified)
Null Datamodel
Minimal state machine without expression evaluation.
<scxml datamodel="null" initial="idle">
<state id="idle">
<transition event="start" target="running"/>
</state>
<state id="running">
<transition event="stop" target="idle"/>
</state>
</scxml>
Only In() predicate is supported for conditions.
Runtime Integration
TranspiledStateMachine Base Class
public interface IScxmlStateMachine
{
// Properties
string Name { get; }
string SessionId { get; }
bool IsFinished { get; } // true once a top-level <final> is reached
bool IsStarted { get; }
IReadOnlySet<string> ActiveStates { get; }
IReadOnlySet<string> FinalStates { get; }
// Core methods
void Start();
void Start(IReadOnlyDictionary<string, object?>? initData);
void Send(string eventName, IReadOnlyDictionary<string, object?>? data = null);
void Send(Event evt);
bool IsInState(string stateId);
void PumpEvents(); // drain pending timer-fired events (manual use; executor does this automatically)
// Data model access
object? GetVariable(string name);
void SetVariable(string name, object? value);
}
Event Dispatch Methods
var machine = new TrafficLight();
machine.Start();
// String-based (convenient)
machine.Send("timer");
// Integer-based (O(1) performance - use EVT_* constants)
machine.SendById(TrafficLight.EVT_TIMER);
// With event data
machine.Send("submit", new Dictionary<string, object?> {
{ "username", "alice" }
});
machine.SendById(TrafficLight.EVT_SUBMIT, new Dictionary<string, object?> {
{ "username", "alice" }
});
Trace API
using ScxmlGen.Runtime.Trace;
// Console debugging (create custom listener)
public class ConsoleTraceListener : TraceAdapter
{
public override void OnStateEnter(string stateId, long timestampMicros)
=> Console.WriteLine({{content}}quot;Enter: {stateId}");
public override void OnStateExit(string stateId, long timestampMicros)
=> Console.WriteLine({{content}}quot;Exit: {stateId}");
}
// JSONL file output
using var writer = new JsonlTraceWriter("trace.jsonl");
machine.SetTraceListener(writer);
machine.Start();
machine.Send("go");
// Invoke-aware tracing for parent/child machines
public class MyListener : InvokeAwareTraceAdapter
{
public override void OnStateEnter(string stateId, long timestampMicros, string? invokeId)
{
if (invokeId != null)
Console.WriteLine({{content}}quot;Child {invokeId} entered: {stateId}");
else
Console.WriteLine({{content}}quot;Parent entered: {stateId}");
}
}
ITraceListener Interface
public interface ITraceListener
{
void OnSessionStart(string sessionId, string scxmlName, string datamodel, long timestampMicros);
void OnSessionEnd(string sessionId, IReadOnlySet<string> finalStates, long timestampMicros);
void OnStateEnter(string stateId, long timestampMicros);
void OnStateExit(string stateId, long timestampMicros);
void OnTransitionFired(string? from, string to, string? eventName, long timestampMicros);
void OnEventReceived(Event evt, long timestampMicros);
void OnEventProcessed(Event evt, long timestampMicros);
void OnVariableChanged(string name, object? oldValue, object? newValue, long timestampMicros);
void OnActionExecute(string actionType, IReadOnlyDictionary<string, object?> details, long timestampMicros);
}
IInvokeAwareTraceListener Interface
Extended interface with invokeId for parent/child context:
public interface IInvokeAwareTraceListener : ITraceListener
{
void OnStateEnter(string stateId, long timestampMicros, string? invokeId);
void OnStateExit(string stateId, long timestampMicros, string? invokeId);
// ... all methods with invokeId parameter
}
Executor API
using ScxmlGen.Runtime.Executor;
// ── ContinuousExecutor (recommended for production) ───────────────────────────
// Runs the machine on a dedicated background thread. Delayed events (<send delay="..."/>)
// are processed automatically via the wakeup listener — no manual PumpEvents() needed.
using var executor = new ContinuousExecutor(machine);
executor.Start();
// Send events (synchronous overload blocks until processed)
executor.Send("go");
// Async overload — awaitable, integrates with async/await code
await executor.SendAsync("go");
await executor.SendAsync("next");
// Wait for the machine to reach a <final> state
while (!machine.IsFinished)
await Task.Delay(50);
// Dispose() stops the background thread cleanly
// ── RunToCompletionExecutor (simple, synchronous cases) ───────────────────────
// Processes each event synchronously on the caller's thread.
// Use for test harnesses or scenarios without delayed events.
var executor2 = new RunToCompletionExecutor(machine2);
executor2.Start();
executor2.Send("go");
Console.WriteLine(executor2.IsRunning);
IStateMachineExecutor Interface
public interface IStateMachineExecutor : IDisposable
{
IScxmlStateMachine StateMachine { get; }
bool IsRunning { get; }
void Start();
void Start(IReadOnlyDictionary<string, object?>? initData);
void Send(string eventName);
void Send(Event evt);
void Send(string eventName, IReadOnlyDictionary<string, object?>? data);
Task SendAsync(string eventName);
Task SendAsync(Event evt);
Task SendAsync(string eventName, IReadOnlyDictionary<string, object?>? data);
void PumpEvents();
}
Event Data
// Directly on the machine
machine.Send("submit", new Dictionary<string, object?> {
{ "username", "alice" },
{ "password", "secret" }
});
// Via executor (thread-safe, awaitable)
await executor.SendAsync("submit", new Dictionary<string, object?> {
{ "username", "alice" },
{ "password", "secret" }
});
// Access in SCXML (ECMAScript)
<transition event="submit" cond="_event.data.username === 'alice'" target="authenticated"/>
State Inspection
// Check specific state
if (machine.IsInState("processing")) {
Console.WriteLine("Still processing...");
}
// Get all active states (for parallel states)
foreach (var state in machine.ActiveStates) {
Console.WriteLine({{content}}quot;Active: {state}");
}
// Check completion
if (machine.IsFinished) {
Console.WriteLine({{content}}quot;Final states: {string.Join(", ", machine.FinalStates)}");
}
Event Introspection
IReadOnlySet<string> all = machine.GetAllEvents();
IReadOnlySet<string> enabled = machine.GetEnabledEvents(); // guard-aware
IReadOnlySet<string> forState = machine.GetEventsForState("s1");
IReadOnlySet<string> enabledFor = machine.GetEnabledEventsForState("s1");
Event Handling
Internal Events (Raise)
<state id="validate">
<onentry>
<if cond="isValid">
<raise event="valid"/>
<else/>
<raise event="invalid"/>
</if>
</onentry>
<transition event="valid" target="success"/>
<transition event="invalid" target="error"/>
</state>
Delayed Events (Send)
<state id="waiting">
<onentry>
<send event="timeout" delay="5s"/>
</onentry>
<transition event="timeout" target="timedOut"/>
<transition event="response" target="success"/>
</state>
Note: Delayed events use Timer internally. When wrapped in ContinuousExecutor, timer-fired
events are processed automatically on the background thread — no manual PumpEvents() or async context
required. For manual use without an executor, call machine.PumpEvents() periodically.
External Events
// From application code (directly on machine)
machine.Send("userInput", inputData);
// Via executor (thread-safe)
executor.Send("userInput", inputData);
// From another machine (via invoke)
parentMachine.Send("done.invoke.child1", resultData);
Invoke Support
Inline Child Machine
<state id="processing">
<invoke id="worker">
<content>
<scxml initial="work">
<state id="work">
<onentry>
<send target="#_parent" event="progress"/>
</onentry>
<transition event="complete" target="done"/>
</state>
<final id="done">
<donedata>
<content expr="result"/>
</donedata>
</final>
</scxml>
</content>
</invoke>
<transition event="done.invoke.worker" target="complete"/>
</state>
External Child Machine (Bundled)
# Bundle child at build time
scxml-gen parent.scxml -t csharp -o Parent.cs --bundle child.scxml
<!-- parent.scxml -->
<state id="processing">
<invoke src="child.scxml" id="worker"/>
<transition event="done.invoke.worker" target="complete"/>
</state>
Unity Integration
MonoBehaviour Wrapper
using UnityEngine;
using MyGame.StateMachines;
public class CharacterController : MonoBehaviour
{
private CharacterStateMachine _machine;
void Awake()
{
_machine = new CharacterStateMachine();
}
void Start()
{
_machine.Start();
}
void Update()
{
// Process input events
if (Input.GetKeyDown(KeyCode.Space))
{
_machine.Send("jump");
}
// React to state
if (_machine.IsInState("airborne"))
{
ApplyGravity();
}
}
void OnDestroy()
{
_machine.Stop();
}
}
ScriptableObject State Machine
[CreateAssetMenu(menuName = "StateMachines/AI Behavior")]
public class AIBehaviorAsset : ScriptableObject
{
public AIStateMachine CreateInstance()
{
return new AIStateMachine();
}
}
W3C Compliance
The C# target achieves 100% W3C compliance across all supported datamodels.
| Datamodel | Tests | Pass Rate |
|---|---|---|
| ECMAScript (Jint) | 183/183 | 100% |
| Null | 6/6 | 100% |
| Native-CSharp | 11/11 | 100% |
| Total | 200/200 | 100% |
Running Tests
cd scxml-csharp
dotnet test
Key Compliance Features
- Full
_eventobject support (name, type, origin, origintype, sendid, data, invokeid, raw) - XML DOM support for test557/test561 (getElementsByTagName, getAttribute, etc.)
- System variables (_sessionid, _name, _ioprocessors)
- Invoke with parent/child communication
- History states (shallow and deep)
- Parallel states with correct done.state events
- Late binding support
Performance
Benchmarks
| Operation | ECMAScript | Native-CSharp | Null |
|---|---|---|---|
| State transitions/sec | ~50,000 | ~500,000 | ~1,000,000 |
| Memory per instance | ~100KB | ~1KB | ~500B |
| Startup time | ~10ms | <1ms | <1ms |
Optimization Tips
- Use native-csharp for performance-critical applications
- Avoid ECMAScript in tight loops
- Reuse machine instances when possible
- Use null datamodel for simple event-driven workflows
See Also
- Datamodels - Detailed datamodel comparison
- W3C Compliance - Full test results
- Feature Matrix - Feature support by target
- Target Overview - Compare all targets