State Machine

A state machine allows for a controlled change from one state to another. It sets up a well-defined set of possible states and what transitions are possible between them. It's up to you to 'drive' the machine, telling it when to transition.

State machines are defined with a plain object. Properties list of possible states, with values being what state(s) that are possible to change to, or null if no further changes are possible.

Machine definition

An example of a simple state machine is a light switch. It has two states: on and off. When the light is on, the only other state is off. And vice-versa:

{
  on: "off",
  off: "on"
}

With this machine definition, it would be illegal to have a state dimmed, or to turn it off when it is already off. In this case, the machine never reaches a final state, it can always oscillate between on / off. Note too that we can automatically and reliably advance the state of the machine, because each state indicates what follows.

It's possible to have several possible next states by using a string array:

{
  on: ["off", "half_bright"],
  half_bright: ["on", "off"],
  off: "on"
}

The example below is intended to start with plain bread, with a few ways of getting to the eventual final state of sprinkled_on_soup or eaten. Once a machine is in its final state, it cannot change to another state unless it is reset.

{
  plain: ["toasted", "buttered", "eaten"],
  toasted: ["buttered", "eaten", "diced"],
  buttered: ["eaten", "marmaladed"],
  marmaladed: "eaten",
  diced: "sprinkled_on_soup",
  sprinkled_on_soup: null,
  eaten: null
}

Why?

Behaving according to a current state is a common pattern in programming interactivity. This is often solved by using different variables track state. A downside is that you have to be mindful what variables or conditions alter state as well as when and where to enforce rules about state changes.

A state machine therefore can help you catch errors and makes coding simpler when you know there are a fixed number of well-defined states to handle, and they are only activated according to a logic you have defined.

Playground

Try out some state machines in this playground.

  1. Edit the description or choose a demo
  2. Click Use description to load it have it checked for errors.
  3. If successful, you can see available states. Select a state and then 'Change state'

(Note that properties are enclosed in " marks here because it's represented as JSON)

1. Machine description


      

Control

Simple usage

A simple way of using the state machine is the functional, immutable approach. Create the machine with its description and initial state:

const machine = StateMachine.init({
  on: "off",
  off: "on"
}, "on");

The machine description is a simple object that 1) lists all possible states (as its top-level properties), and 2) for each state, what other state(s) can be transitioned to, or null if it is a final state.

The following example has four possible states (wakeup, sleep, coffee, breakfast, bike). sleep can only transition to the wakeup state, while wakeup can transition to either coffee or breakfast.

Use null to signify the final state. Multiple states can terminate the machine if desired.

// repl-pad#1
import { StateMachine } from "https://unpkg.com/ixfx/dist/flow.js"

const description = { 
 sleep: `wakeup`,
 wakeup: [`coffee`, `breakfast`],
 coffee: `bike`,
 breakfast: `bike`,
 bike: null
}
let sm = StateMachine.init(description, `sleep`);

StateMachine.init returns MachineState which captures the definition of the machine and its current state:

// repl-pad#1
// Current state
sm.value; // eg. 'bike'
// List of unique states visited
sm.visited; // eg. ['sleep', 'wakeup']
// Original machine definition
sm.machine; 

To attempt to change state, use StateMachine.to

// repl-pad#1
// Transition existing machine to state 'wakeup'
sm = StateMachine.to(sm, 'wakeup');
sm.value; // 'wakeup'

If this is a legal transition, you'll get a new MachineState object. If not, an exception will be thrown. Note that the state is immutable - a transition results in a new object.

Here are some helper functions:

// repl-pad#1
// String array of possible next states
StateMachine.possible(sm);

// Returns _true_ if state machine cannot transition further
StateMachine.done(sm);

// Try to automatically move to next state
sm = StateMachine.next(sm);

Class-based usage

ixfx has a class StateMachine.WithEvents which wraps the functional implementation described above and also provides events for listening for event changes.

The same format is used to define possible transitions. Now there's an mutable object, so const can be used:

// repl-pad#2
import { StateMachine } from "https://unpkg.com/ixfx/dist/flow.js"
const description = { 
 sleep: `wakeup`,
 wakeup: [`coffee`, `breakfast`],
 coffee: `bike`,
 breakfast: `bike`,
 bike: null
}
const sm = new StateMachine.WithEvents(description, { initial: "sleep" });

Change the state by name:

// repl-pad#2
sm.state = `wakeup`

In some cases, you might want to ask the machine to transition to its next possible state, regardless of its current state. If multiple states are possible, it will use the first one.

// repl-pad#2
sm.next();

Reset the machine back to its initial state with reset(). This is the only way to continue after reaching the final state.

// repl-pad#2
sm.reset();

Check status

if (sm.state === `coffee`) ...
if (sm.isDone) ...

The change event is fired whenever state changes, and stop when the machine reaches a final state.

sm.addEventListener(`change`, (evt) => {
 console.log(`State change from ${evt.priorState} -> ${evt.newState}`);

 // Prints for example:
 // State change from wakeup -> breakfast
});

sm.addEventListener(`stop`, (evt) => {
 console.log(`Machine has finished in state: ${evt.newState}`);
});

Simple machines

StateMachine.fromList creates transitions that steps through a series of states and then terminates.

// repl-pad#3
import { StateMachine } from "https://unpkg.com/ixfx/dist/flow.js"
// Machine that can go: init -> one -> two -> three -> [end]
const sm1 = StateMachine.init(StateMachine.fromList(`init`, `one`, `two`, `three`));

Once in the 'three' state, will be considered done, since there is no possible transition from there.

StateMachine.fromListBidirectional is the same idea, but allow back-and-forth between states.

// repl-pad#3
// Machine that can go: init <-> one <-> two <-> three
const sm2 = StateMachine.init(StateMachine.fromListBidirectional(`init`,`one`, `two`, `three`));

In the above example, sm2 will never be done, because it's always possible for it to transition to some state.

Driver

When using state machines, it's common to have a big switch statement (or lots of ifs) to alter behaviour depending on the current state. These behaviours in turn might trigger a state change. Since this is such a common pattern, the StateMachine.driver is provided.

With it, you set up state handlers for different states and guiding the machine to subsequent states.

Each handler has an if field, a single string or array of strings corresponding to the state(s) that handler applies to. While one handler can handle multiple different states, there can't be multiple handlers per state.

The other part of the handler is then field. At its simplest, it is an object that tells what state to transition to, for example:

const handlers = [{
  if: `sleeping`, // If we're in the 'sleeping' state
  then: { next: 'walking' } // Go to 'walking' state
}]

Note: The use of if and then for the handlers shouldn't be mistaken for regular Javascript if .. else control structures.

The then field can be an array of functions, all of which return the same kind of object. When the handler is run, it executes these functions to determine what to do. Functions defined under then don't have to return a value - they could just be things you want to run when the state machine is in that state.

const handlers = [{
  if: `walking`,  // If we're in the 'walking' state
  then: [
    () => {  // Randomly either go to 'resting' or 'running' state next
      if (Math.random() > 0.5) return { next: 'resting' }
      else return { next: 'running' }
    }
  ]
}];

Once we have the state machine and the handlers, the driver can be initialised. This would likely happen once when your sketch is initialised.

// Set up driver (note the use of await for both lines)
const driver = await StateMachine.driver(states, handlers);

And then, perhaps in a timing-based loop, call run(), which will execute a state handler for the current state.

// Call .run every second
setInterval(async () => {
  await driver.run();
}, 1000);

Here's a complete example:

// States
const states = {
  sleeping: 'waking',
  waking: ['resting','sleeping'],
  resting: ['sleeping', 'walking'],
  walking: ['running', 'resting'],
  running: ['walking']
};

const handlers = [
  { 
    // If we're in the 'sleeping' state, move to next state
    if: 'sleeping',
    then: { next: true }
  },
  { 
    // If we're in the 'waking' state, randomly either go to 'resting' or 'sleeping' state
    if: 'waking',
    then: [
      () => {
        if (Math.random() > 0.5) {
          return { next: 'resting' }
        } else {
          return { next: 'sleeping' }
        }
      }
    ]
  }
];

// Set up driver
const driver = await StateMachine.driver(states, handlers);

Once you have the state machine and driver set up, you need to call .run() whenever you want the driver to do its thing. This might be called for example in a loop based on a timer.

driver.run();

If you use asynchronous event handlers, call await driver.run() instead.

Some other things to do with the driver:

// Check current state
driver.getValue(); // eg. 'resting'

// Manually transition state
driver.to('walking');

So far, handlers have returned an object describing what state to transition. Instead of hardcoding the state, you can use { next: true } to transition to next available state. An alternative is { reset: true }. When that is returned, the machine goes back to its initial state.

Each result can also have a score field. This is only useful if you have several results under then. By default, the highest scoring result determines what happens.

With this in mind, we can re-write the earlier example, assigning random scores for each possible next state:

...
  { 
    if: 'waking',
    then: [
      // Two functions, each returns a result with a random score each time they are executed
      () =>  { score: Math.random(), next: 'resting' },
      () =>  { score: Math.random(), next: 'sleeping' }
    ]
  }
...

In practice you might want to weight the random values so one choice is more or less likely than another. See Random for more on that.

Each handler also has an optional resultChoice field, which can be 'first', 'highest', 'lowest' or 'random'. By default, 'highest' is used, picking the highest scoring result. In our example, we might use resultChoice: 'random' to evenly pick between choices. With that enabled, we no longer need scores.

...
  {
    if: 'waking',
    resultChoice: 'random',
    then: [
      // Because of resultChoice 'random', the driver
      // will randomly pick one of these options when in the 'waking' state
      { next: 'resting' },
      { next: 'sleeping' }
    ]
  }
...

When calling driver.run(), a result is returned with some status information, if that's needed:

const result = await driver.run();
result.value;   // state at the end of .run()
result.visited; // string array of unique states that have been visited
result.machine; // original machine description

Demo

In the demo below, the driver is used to autonomously change states based on an 'energy' level, also affected by current activity.