How the chart is generated
The studio never stores a hand-drawn diagram. Every chart you see is generated from the machine's own structure, on demand — the same Machine value you run in production is the single source of truth for the picture.
A rendered chart is fed by two independent streams:
| Feed | What it carries | When it's fetched |
|---|---|---|
| Structure (the graph) | nodes, edges, hierarchy, transition labels | once, when you open a machine |
| State (the snapshot) | the active configuration, context, pending effects | live, streamed over Server-Sent Events |
The structure is laid out once; the snapshot only re-highlights it. Nothing is re-positioned as you drive the machine.
Generating the chart
Opening a machine resolves its structure in Go, ships it as JSON, and lays it out in the browser. The engine stays pure — it only describes; the browser draws.
Structure comes from a Machine; the browser is a pure renderer. Every statechart rule stays in the engine.
1. The engine resolves the structure
A Machine knows its full shape. Describe() returns a MachineDescriptor — the recursive tree of states, their types (atomic, compound, parallel, final, history), and their transitions. RenderGraphJSON() then flattens that tree into a flat node/edge graph, resolving every transition target to a concrete node id:
descriptor := machine.Describe()
graph := fate.RenderGraphJSON(descriptor)For the three-state traffic light, the graph is:
{
"id": "traffic-light",
"initial": "s_red",
"nodes": [
{ "id": "s_red", "label": "red", "path": "red", "type": "atomic", "parent": "", "initial": true },
{ "id": "s_green", "label": "green", "path": "green", "type": "atomic", "parent": "", "initial": false },
{ "id": "s_yellow", "label": "yellow", "path": "yellow", "type": "atomic", "parent": "", "initial": false }
],
"edges": [
{ "id": "s_red__NEXT__1", "source": "s_red", "event": "NEXT", "target": "s_green" },
{ "id": "s_green__NEXT__1", "source": "s_green", "event": "NEXT", "target": "s_yellow" },
{ "id": "s_yellow__NEXT__1", "source": "s_yellow", "event": "NEXT", "target": "s_red" }
]
}Each node carries its type, its parent (the enclosing compound/parallel node, or "" at the top level), and a path — the dotted address used later to match the live active state. Each edge carries its event, the resolved target, and any guard / actions / internal flags. The browser never has to re-resolve a target; the engine has already done it.
Layout is not in the graph
The graph is structure only — no x/y coordinates. Positioning is the browser's job (step 3), which is why the same graph can be laid out top-down, re-tidied, or hand-arranged without the engine knowing or caring.
2. The server exposes it
The studio is a thin HTTP layer over the engine. It mounts a handful of routes per registered machine:
| Route | Returns | Purpose |
|---|---|---|
GET /m/{name}/graph | Graph JSON | the structure, fetched once on open |
GET /m/{name}/describe | MachineDescriptor JSON | the raw descriptor |
GET /sim/{name}/stream | text/event-stream | live snapshots, one per event |
POST /sim/{name}/send | snapshot | dispatch an event |
POST /sim/{name}/{timer,invoke,reset,undo,import} | snapshot | drive effects / history |
GET /api/machines | machine list | the gallery on the index page |
Each browser tab is an isolated session (a fate_sid cookie), so two people can drive the same machine independently.
3. The browser lays it out
With the graph in hand, the studio app computes positions with ELK (the Eclipse Layout Kernel, compiled to JavaScript):
- Hierarchy → nesting. A compound or parallel node becomes a container; its children are laid out inside it. ELK sizes the container around them.
- Orthogonal edge routing. ELK computes clean, bend-pointed wire paths that avoid overlapping nodes — the studio draws edges along those exact bend points.
- Layered, top-down. States flow in the direction of their transitions.
The laid-out graph is then handed to React Flow (@xyflow/react), which renders it with custom node components — one per state type — plus a minimap, pan/zoom, and drag. A node carries its outgoing transitions as rows; clicking a row sends that event in simulate mode.
4. Live simulation, frame by frame
Opening a machine for simulation starts an Actor server-side and a Server-Sent Events stream client-side. From then on, every interaction is the same short loop — and because layout never changes, the browser only toggles highlight classes:
The snapshot's path (e.g. review.bpkb.checking | review.head_vd) is split on | for parallel regions and matched against each node's path to light up the active configuration. Because layout never changes, highlighting is just a class toggle — it stays smooth on machines with hundreds of states.
Effects ride the same snapshot: a pending after timer or an invoke shows up in the snapshot's timers / invocations, the studio renders a Fire or Resolve/Reject control, and acting on it POSTs back — exactly how a real adapter performs effects, made visible.
5. Keeping dense charts readable
Real machines have transitions that every state shares — a global TERMINATE, a "return to here" hub. Drawn as lines, these dominate the picture: in the LORA survey machine, three such events account for a third of all edges (one TERMINATE from every state converging on done, plus two reassign/reschedule hubs).
The studio detects them structurally — events that converge (many sources → one target) or diverge (one source → many targets), while leaving backbone chains (many → many) alone:
Such events are excluded from layout and rendered as a chip on the node instead of a wire, with a legend and a draw as edges toggle. On survey that drops the drawn edges from 125 to 83 without hiding any behaviour.
The rendering library
The studio UI is a small single-page app — React 18, built with Vite, using @xyflow/react for the canvas and elkjs for layout. It is compiled to static assets and embedded into the Go binary with go:embed, so the studio ships as one self-contained executable with no runtime asset server.
Crucially, all of this lives in the separate fate-studio module. The engine stays standard-library-only; you only pull in a browser toolchain if you choose to run the studio.
Next
- The studio — what it is, how to embed it, and a real production case.
- How it works — the engine's compute-vs-perform model.
- Concepts — the active configuration,
MachinevsActor.