17
State machines
MelekTaus edited this page 2026-10-04 18:44:21 +02:00

A state machine asset is a named set of states and the moves allowed between them: its graph holds one State node per state (a title and a Name setting) and one edge per transition. The states are edited in the State machine tab, which is the graph canvas the Behavior tab uses — so placing, dragging, the header's magnet (Snap), renaming in the inspector, connecting, Delete and undo are the graph's. Double-clicking the asset (or creating it) opens the tab, like every asset with a tab; a click only selects. Right-click on its canvas offers a State and nothing else, named by its number (State 3 for the third); no behavior palette offers one.

Scope

A machine's Scope is a dropdown in its own inspector, since the machine is the asset and its current state hangs off its id. Local (the default) gives every behavior instance a current state of its own — two doors open independently. Global keeps one current state for the whole run, so every behavior using the machine sees the same one, and a second behavior can branch on a state it never set itself. Scene shares one the same way, but only for as long as the scene lasts: every Load scene puts the machine back into its start state, and that entry is announced again, so an On state entered in the new scene hears it. A global machine stays quiet there, because it never left the state it was in. Scope also decides who hears a move: see Start state and events below.

Transitions

A transition is an arrow from one state to another. Right-click a state, choose Make transition, then click the state it should lead to; anywhere else, or Escape, drops the attempt. It joins two whole states, so there is nothing to aim at on either node and no pins are drawn.

Underneath it is an ordinary graph edge, which is the whole point: connecting, undo, the clean-up when a state is deleted and the save format all come from the graph and needed nothing new. A state therefore still carries an in and an out port — a port is the only anchor an edge has in this editor — the editor simply never draws them and never lets them be grabbed. Between two states the edge is a straight arrow rather than the usual curve, running border to border with a little air at each end. Two transitions between the same pair are pushed apart, so a there-and-back pair lies side by side instead of on top of each other.

There may also be several transitions the same way round between two states, alarm and seen from Patrol to Chase, say. They are told apart only by their name, so a further one is given a name of its own the moment it is made (Transition 2, Transition 3, skipping any name already leaving that state), and the inspector refuses a rename that would make two of them alike, the empty name included. They are drawn side by side like a there-and-back pair. In a behavior graph a second edge between the same two ports is still refused (Already connected).

A transition may also lead from a state back into itself: after Make transition, click the same state again. It is drawn as a loop over the top edge of the node, and the inspector lists it once, under out. Taking it re-enters the state: On state exited and then On state entered fire for it, which is what it is for (attack again, restart a timer). A state may have more than one such loop, under different names; each further one is drawn outside the last. Only states may do this; any other node connected to itself is still refused.

A transition can be named. Select a state and the inspector lists its transitions in two groups, out and in, each row with the state at the far end, a name field and a button to remove it. The arrow itself is a handle too (#289): a click on it, or on its name, picks the transition, the inspector then shows its two states and its name field, and Delete or a right-click → Delete transition removes it. Ctrl/Shift-click and the rubber band pick several. The name is drawn on the arrow. Names need not be unique across the machine: what must not repeat is two transitions of the same name leaving the same state (the inspector warns when they do). That is the point of naming them — a single State transition node asking for alarm moves every state that has an alarm.

A transition does not restrict Set state: that node still puts the machine into any state it is wired to. What the arrows are for at runtime is State transition.

A behavior reads it through four nodes, each with a machine input that is a picker while unconnected (or takes a State machine node, the literal every kind has):

  • State branch grows one exec output per state, named after the state, and continues along the one the machine is in. Below them sits else, and it carries everything the states do not: no start state and no Set state yet, a state the machine no longer has (deleted, or of another machine), or a machine that arrived as a value and therefore has no state outputs at all. A flow that enters this node always leaves it somewhere — a branch nobody can see taken is the hardest kind to debug. else is derived like the states rather than fixed, so it sits under them, the way the way out of a Branch sits under what it is an exception to.
  • Set state is the mirror image: one exec input per state, one exec output. The input the flow arrives through names the new state — so OnStart → Set state (Open) and On key down → Set state (Closed) are two edges into one node. The interpreter hands every node the exec input a pulse entered through (run(…, entry)); this is the first node to read it.
  • Set state by name does the same job with a text instead of a port, for the machine that arrives as a value and has no ports to offer. It resolves the text as a state id first, then as a name; a name two states share resolves to nothing, the same rule State transition follows for two arrows of one name, and for the same reason. Missing the target is not silence: the node has a second exec output not found that carries the flow on, so a typo, a deleted state or an empty machine can be logged or answered with a default instead of stopping there.
  • State transition takes the transition of a chosen name out of the current state, then continues. The name is picked in the inspector from the transitions of the machine on its machine input — the options depend on another field, which no field kind can express, so that picker is written by hand. Leaving it at (unnamed) follows the one transition nobody named. If the current state carries no transition of that name, or two of it, the machine stays where it is — choosing between two arrows the graph never picked between would be a guess. It reads the machine's transitions through the project lookup the interpreter already carries.

The ports are keyed by the state's node id and labelled by its name: renaming a state keeps every edge. Deleting one is the awkward case, because the port lives in a behavior while the state lives in the machine — so nothing cleans up there, and the edge stays behind. It is not lost, though: the behavior's node shows the key as a ghost port with the edge dashed in the warning colour, and Disconnect on its right-click menu removes it. Put the state back and the edge works again. Which machine the ports belong to is read off the graph, not off a running value — the inline pick, a State machine literal wired in, or an input of the behavior's Start node of the kind State machine with a machine picked next to its name in the inspector. A variable of that kind read by a Variables node works the same way: the machine is picked next to the variable's row. The pick only names the states, the value the variable holds at run time is not checked against it. An Item at or anything else feeding the input yields no state ports, exactly as Run behavior knows its behavior only as a setting. That machine is still not stuck: Set state by name names the state as text, State branch leaves along else, and Current state answers as always. The price is spelled out, not hidden — a name is typed, so a typo is possible where a port made it impossible, which is what not found is there for. Picking another machine orphans the edges of the old states, the way any setting that decides a node's ports does.

A Start input with a machine picked has that machine picked in advance on every node running the behavior. Another pick there is allowed, but the node warns ("machine" expects "Doors"): the called graph asks for the states of its own machine, so with another one every state would read as never entered. A local machine handed in is the caller's: the called graph reads and moves the very state the calling instance holds (see The current state while playing below).

Two more read it as data rather than as a flow, both pure readers (no exec ports, answered whenever they are asked):

  • Current state hands out two texts: name for showing (Join, a Text object) and id for comparing, since a rename moves the name but never the id. Both are empty while the machine is in no state, and a state the machine does not know keeps its id but has no name.
  • Is in state grows one boolean output per state — the data twin of State branch, for the places a flow cannot go (Branch, And, a property). Every output is answered, so a state nobody is in reads false rather than nothing.

Start state and events

A machine may name the state every instance begins in. Right-click a state and choose Set as start state; the same entry then reads Remove start state, which is the only switch there is. The start wears a ring on the right of its title in the tab. It is kept on the asset (start, a state node's id) and not on the node, because only one state can be the start and a single field is what makes that structural instead of a rule someone has to keep. A start pointing at a deleted state reads as no start — the dead id stays in the document and means nothing.

Without a start the old behaviour stands: the machine is in no state until Set state runs.

Two event nodes hear the moves, each with a machine input and one exec output per state, like State branch:

  • On state entered runs when the machine enters a state, along that state's output.
  • On state exited runs when it leaves one. Coming out of no state there is nothing to leave, so nothing runs.

The rules, all of them worth knowing before wiring a graph on them:

  • A Set state into the state the machine is already in is no move: nothing is written and nothing fires. That is what keeps a Set state run every frame from firing entered every frame. A transition back into the same state is the exception: State transition along it is a move, so exited and entered fire for the state, every time it is taken.
  • The start state counts as entered. A local machine is entered per instance, right after that instance's On start; a global one is entered once for the whole run, before the first instance exists, and every instance created with the scene hears that one entry. An instance spawned later joins a global machine where it already is, without an entered of its own.
  • Order is exited of the old state, then entered of the new, and both fire after the exec flow that caused the move has finished — never in the middle of it. Moves the events themselves cause are delivered in the same way, round after round until none are left; a graph whose events keep moving the machine stops the run with an endless-loop error, like the step limit.
  • Who hears it follows the scope: a local machine is heard by the instance that moved it alone, a global one by every instance with a matching event node — which is the point of sharing a state. A move made inside a Run behavior, however deep, counts as a move of the instance that runs the call: its own On state entered and On state exited hear it. The event nodes of the called graph itself stay silent, like every event there.

The current state while playing

While a run is on, the State machine tab marks the states it is in: weakly for every state some instance holds, and strongly for the state held by the instance of the selected scene object (the behavior itself or the object it hangs under). Without a selection nothing is strong, which keeps "many instances, many states" readable instead of picking one of them at random. The marks are the play colour, inside the border so a selected state still shows both, and they are gone the moment the run stops. Play repaints the tab the way it repaints the Behavior tab (PlaySession.tick); an editor run has no session to do it, so the editor's frame does it instead.

A local machine's current state is the behavior instance's own, kept beside its variables (NodeContext.states, machine id → state id): two crates running the same behavior are in states of their own. A behavior run through Run behavior has no states of its own: it reads and moves those of the instance that runs the call, however deep, so a state one call sets is still set in the next one, and the call begins in the start state like the instance does. Two characters running the same behavior therefore stay apart, each with its calls. A global one is kept for the run instead (SharedState.states, built once per run in core/graph/sharedState.ts) and every instance reads and moves that one — which bag a node touches is decided by the machine's scope, not by the node (stateBag). Neither survives a stop: both begin the next Play at the start state again, or in no state where there is none, the way a Variables asset starts at its declared values. A state machine is a kind like a texture — a variable, a struct field, a list item (tagged {"$statemachine": id}), a port — and a statemachine asset is never placed.