ziggysm is a small Zig-flavored DSL for writing deterministic, step-driven state machines that cooperate with an external “driver” instead of blocking.
You write state machine logic in a direct, readable style (with explicit suspension points), then drive it by repeatedly calling a generated .step(...) function until the machine stops.
ziggysm is useful when you want:
- Protocol logic that looks like straight-line code, but never blocks
- Deterministic, easily testable control flow (no threads required)
- Explicit orchestration of I/O, timeouts, retries, device access, scheduling, etc.
- A single state machine instance that can be paused/resumed safely and predictably
Typical domains: embedded drivers, device protocols, game loops, cooperative schedulers, async-ish workflows without an async runtime.
A ziggysm definition generates a Zig struct you instantiate and drive. The generated machine has (conceptually) three parts:
state: an internal enum tracking where execution will resumedata: storage for$statevariables (persistent across yields)branches: internal storage used to implement$call/$returnfor procedures
The driver repeatedly calls:
pub fn step(sm: *Machine, resume_val: ReturnValue) !ResultEach call runs the machine forward until it hits one of these boundaries:
- it yields an external request (
$yield ...), returning aResult.<suspender>variant, or - it stops (
$return;from a top-level machine/submachine), returningResult.stop
Because step() returns !Result, it may also return an error if a yielded operation was marked with $try and the driver resumes it with an error.
A suspender describes an external action that the state machine can request. Think “an operation the outside world performs for me”.
$suspender read_byte() u8;
$suspender write_byte(data: u8) void;Suspenders can return error unions too:
$suspender read_byte_with_timeout(deadline: u64) error{Timeout}!u8;The state machine never calls suspenders directly; it only yields them.
$yield is the suspension point. When a machine yields, it returns control to the driver along with a request.
$yield read_byte() -> $state b;
$yield write_byte(${b});- The first
$yieldasks the driver to performread_byte()and then resumes with the returnedu8. - The second
$yieldasks the driver to performwrite_byte(b)and then resumes when that action is complete.
Under the hood:
- Yielding produces a
Result.<suspender>request containing the suspender arguments. - Resuming must pass a
ReturnValue.<suspender>with the suspender return value (or error union value).
If you resume with the wrong tag, the generated code panics (it treats that as a bug in the driver).
Because the machine can suspend and resume, values that must live across yields must be stored in persistent state.
$state name: T = init;declares a persistent variable stored in the machine instance.-> $state namedeclares a new persistent variable and assigns it the yielded result.-> ${name}assigns into an existing persistent variable.${name}reads a persistent variable inside expressions.
Example:
$state retries: u8 = 0;
$yield read_byte() -> $state b;
$if(${b} == 0) {
${retries} += 1;
$return;
}Important: ${name} is a text replacement performed by the compiler in state-machine scopes. It expands to a field access into the machine’s data storage. This replacement is not performed in plain top-level code.
If a block may contain $yield or $call (anything that can suspend), use the $... versions of control flow.
$while(true) {
$yield read_byte() -> $state b;
$if(${b} == 0) { $return; }
$yield write_byte(${b});
}$loop { ... } is an infinite loop variant.
(You can still write normal Zig code in raw blocks, but anything that needs to suspend must use the ziggysm control forms.)
$proc defines a reusable coroutine-like routine that can yield. Use $call to invoke it.
$proc WriteTwice(value: u8) void {
$yield write_byte(${value});
$yield write_byte(${value});
}
$statemachine Example() void {
$yield read_byte() -> $state b;
$call WriteTwice(${b});
$return;
}$callis implemented as a CALL/RET mechanism with a stored “return state”.- Calling the same procedure recursively is detected at runtime and causes a panic.
- Nested calls are fine as long as you don’t re-enter the same procedure name while it already has a pending return.
Also: -> ... after $call is currently parsed, but in the current implementation it does not move a procedure return value into that target. In other words, treat $call as “control flow + side effects” today.
$submachine defines a named mode/state that you can transition to using $jump.
$statemachine Main() void {
$jump Boot();
}
$submachine Boot() void {
// ...
$jump Running();
}
$submachine Running() void {
// ...
}$jump Name(args...) performs a state transition (a branch). It does not return.
The syntax allows $call targeting a $submachine, and it behaves like “jump with a remembered return address”.
However, $return; in a submachine stops the whole machine (it does not RET back to the caller), so in practice:
- use
$jumpfor submachine transitions - use
$proc+$callfor call/return-style reuse
The generated machine exposes two unions:
ReturnValue(inputs to resume the machine)Result(outputs describing what the machine wants next)
ReturnValue is a tagged union containing:
launch(used for the first call)- one variant per used suspender, with the suspender’s return type as payload
Examples of what the driver passes back:
- For
u8return:. { .read_byte = 42 } - For
voidreturn:.write_byte = {} - For
error{Timeout}!u8return:- success:
.{ .read_byte_with_timeout = 42 }(the error union value is “success 42”) - error:
.{ .read_byte_with_timeout = error.Timeout }
- success:
Result is a tagged union containing:
stop- one variant per used suspender, where the payload is a tuple-like struct of the suspender argument types
Example yields:
.read_byte(no args). { .write_byte = .{ 10 } }
var sm: MyMachine = .{};
var in: MyMachine.ReturnValue = .launch;
while (true) {
const out = try sm.step(in);
switch (out) {
.stop => break,
.read_byte => {
const b: u8 = device.readByte();
in = .{ .read_byte = b };
},
.write_byte => |args| {
device.writeByte(args[0]); // tuple-style struct
in = .{ .write_byte = {} };
},
}
}Notes:
- The generated code checks that
inmatches the last yielded suspender; resuming with the wrong variant is considered a bug and panics. step()may execute multiple internal instructions per call, but it only returns at yield/stop (or error).
If a suspender returns an error union, you choose whether the machine should propagate it automatically.
$try compiles into try resume_val.<suspender> at resume time:
$yield $try read_byte_with_timeout(${deadline}) -> $state b;If the driver resumes with error.Timeout, then step() returns that error immediately.
If you omit $try, the resume value is used without try. This is useful when you want custom error policy, but note:
$statevariables store the success type (not the full error union) in the current implementation.- That means if you want to store into
$statefrom an error union return, you generally must use$try(otherwise it won’t type-check).
For manual policies, prefer handling errors in raw Zig blocks, or design suspenders so the return type matches the policy you want to encode.
$suspender read_byte() u8;
$suspender write_byte(data: u8) void;
$statemachine EchoUntilZero() void {
$while(true) {
$yield read_byte() -> $state b;
$if(${b} == 0) { $return; }
$yield write_byte(${b});
}
}$suspender write_byte(data: u8) void;
$statemachine LoopWriteZeros() void {
$loop {
$yield write_byte(0);
}
}$suspender write_byte(data: u8) void;
$proc WriteTwice(value: u8) void {
$yield write_byte(${value});
$yield write_byte(${value});
}Because a ziggysm machine is driven by explicit step(...) calls, it’s straightforward to test deterministically:
- Start with
.launch - Assert which request is yielded
- Feed back a completion value (or error)
- Repeat until
.stop
This makes protocol code testable without real devices, timers, threads, or I/O.
- Determinism: the machine only makes progress when the driver calls
step(). - Explicit effects: external work is always represented as yielded requests.
- No hidden blocking: the machine cannot block internally; it can only yield.
- Driver-controlled scheduling: the driver chooses when/how to fulfill requests and when to resume the machine.
- Separation of concerns: protocol logic lives in the machine, side effects live in the driver.
- Machine: the generated Zig type instance that holds persistent state and a resume position.
- Suspender: a declared external operation the machine can request.
- Yield: a suspension point that returns a request to the driver.
- Driver: the outer loop that interprets requests and feeds completions back in.
- Persistent state: values stored across yields, declared via
$state. - Procedure: a reusable coroutine-like routine declared with
$proc. - Submachine: a named mode/state declared with
$submachine, entered via$jump.
This section expands on the semantics and mental model.
Write your logic as if it’s normal structured code, but whenever you need something from the outside world (I/O, time, scheduling), you:
- yield a request, and
- wait to be resumed with the result.
The compiler lowers your direct-style code into a state machine, and step() runs it until the next yield/stop boundary.
A $yield foo(args...) is a contract:
- Machine → Driver: “Please perform
foo(args...).” - Driver → Machine: “Here is the completion value for
foo.” (or error union value)
This makes external interaction boundaries explicit and easy to trace, log, and test.
Once you can suspend, “locals on the stack” are no longer a safe assumption. $state makes persistence explicit:
- it lives inside the machine struct (
data) - it survives yields
- it’s referenced via
${name}replacement
ziggysm compiles in three broad stages:
The input is a mix of:
- plain Zig code (passed through verbatim at top level)
- top-level ziggysm declarations:
$suspender ...;$statemachine ... { ... }$submachine ... { ... }$proc ... { ... }
Inside blocks, the parser recognizes ziggysm statements:
$state name: Type = init;$yield ...;$call ...;$jump ...;$if (cond) { ... }$while (cond) { ... }$loop { ... }$return ...;(value only allowed in procedures)$break/$continue(loop control)
Any other text is treated as raw Zig code and preserved as “execute blocks”.
The compiler lowers each machine into a small instruction set (a linear program with labels), roughly:
EXEC <zig code block>SETST <state var> = <expr>YIELD <suspender>(args...) [try] [-> target]BR <label>and conditional branches for$if/$whileCALL <label>andRETfor proceduresSTOP
Key lowering behaviors:
$statebecomes a declared entry in the machine’sdatastruct plus an initializingSETST.${name}inside code blocks becomes an access to a scoped data field (see next section).$jumpbecomes aBRto the submachine label.$callbecomesCALL, and$returnin procedures becomesRET.
The generated step() function is essentially a large switch over sm.state with a loop label, and a “fallthrough” mechanism using continue:
- Each IR label/instruction offset maps to a
Stateenum variant. - Branching sets
sm.stateandcontinues the switch label. - Yielding sets
sm.stateto a special “yield resume state” and returns aResult.<suspender>request. - Resuming a yield:
- verifies the
ReturnValuetag matches the expected suspender (panic if not) - assigns
resume_val.<suspender>into the chosen target (optionally withtry) - continues running subsequent instructions until the next yield/stop
- verifies the
Internally, $state variables are stored in a data struct with scoped names:
- locals and parameters become
"<scope>:<name>" - procedure return slots use just
"<procedure_name>"as their internal name
${name} is replaced at codegen time by:
sm.data.<scoped_field_name>Replacements are only allowed inside machine/proc/submachine scopes. Plain top-level code does not allow ${...} replacement.
$call is implemented with:
- a per-procedure slot in
branchesstoring the return state - a runtime check that the slot is
nullbefore setting it (detects recursive re-entry of the same procedure) RETrestores the saved state and clears the slot
This supports structured call/return without an actual stack, but it intentionally prevents recursive calls to the same procedure.
The compiler takes exactly one positional input file and an optional output path.
ziggysm [options] <input.zigsm>
Options:
-
-o, --output <path>
Write the generated Zig code to<path>. If omitted, the generated code is written to stdout. -
-h, --help
Parsed as an option. (Whether it prints usage depends on the argument parsing behavior; the currentmaindoes not explicitly branch on it.)
Notes:
- The current compiler also prints debug dumps (AST / IR) to stdout during compilation. If you use
--output, your final Zig code goes to the file, while stdout still contains the debug output.