EcspanseStateMachine

ECSpanse State Machine is a state machine implementation for ECSpanse. It is an Ecspanse component you include in your entities.

Features

Installation

If available in Hex, the package can be installed by adding ecspanse_state_machine to your list of dependencies in mix.exs:

def deps do
[
{:ecspanse_state_machine, "~> 0.3.0"}
]
end

How to Use

  1. Systems Setup
  2. Add a state machine
  3. Listen for state changes
  4. Command a state change
  5. Stopping a state machine

Systems Setup

As part of your ESCpanse setup, you will have defined a manager with a setup(data) function. In that function, chain a call to ESCpanseStateMachine.setup

def setup(data) do
data
# register the state machine's systems
|> EcspanseStateMachine.setup()
# Be sure to register the Ecspanse System Timer!
|> Ecspanse.add_frame_end_system(Ecspanse.System.Timer)
# register your systems too

ECSpanseStateMachine will add the systems it needs for you.

Add a state machine

The state machine is an ECSpanse component. You add it to your entity's spec in the components list. EcspanseStateMachine.new is a convenience API function to create the state machine component.

  1. Create a state machine component spec
state_machine =
EcspanseStateMachine.new(
:idle,
[
[name: :idle, exits: [:patrol, :fight], timeout: 5_000],
[name: :patrol, exits: [:fight, :idle], timeout: 10_000, default_exit: :idle],
[name: :fight, exits: [:idle, :die]],
[name: :die]
]
)
  1. Include the component in your entity
Ecspanse.Command.spawn_entity!({
Ecspanse.Entity,
components: [state_machine]
})

Defining States

A state definition is a keyword list with the following keys. :name is the only required key but most states will also include :exits.

States that have at least one exit have a default exit. The default exit is the first exit in the :exits list unless specified by the :default_exit keyword.

States that have timeout will transition to the default exit. The Api provides a convenience function for transitioning to the default exit.

Examples
# This is a terminal state since it has no exits. The state machine will stop once it enters a terminal state.
[name: :die]
# The :fight state can transition to :idle or :die. You must call a transition function on the api to change from the :fight state since there is no :timeout.
[name: :fight, exits: [:idle, :die]],
# :idle can transition to :patrol or :fight. You can use the api to transition to either state.
# After 5 seconds (the :timeout), the state will transition to :patrol.
# :patrol is the default exit state since it is first in the :exits list and :default_exit isn't specified
[name: :idle, exits: [:patrol, :fight], timeout: 5_000]
# :patrol can transition to :fight or :idle. You can use the api to transition to either state.
# After 10 seconds (the :timeout), the state will transition to :idle.
# :idle is the default exit state since it is specified as the :default_exit.
[name: :patrol, exits: [:fight, :idle], timeout: 10_000, default_exit: :idle]

Starting your state machine

The default behavior is to automatically start a state machine. If you don't want that behavior, then you can 'set auto_start to false' and call EcspanseStateMachine.start when you're ready.

Auto start is an option, the third parameter to EcspanseStateMachine.new(). Here's an example of turning off auto_start and then starting the state machine later.

state_machine =
EcspanseStateMachine.new(
:idle,
[
[name: :idle, exits: [:patrol, :fight], timeout: 5_000],
[name: :patrol, exits: [:fight, :idle], timeout: 10_000, default_exit: :idle],
[name: :fight, exits: [:idle, :die]],
[name: :die]
],
auto_start: false
)
entity = Ecspanse.Command.spawn_entity!({
Ecspanse.Entity,
components: [ state_machine]
})
# some time later
EcspanseStateMachine.start(entity.id)

Listen for state changes

ECSpanseStateMachine publishes Started, Stopped, and StateChanged events. State changed is the primary event. It's your chance to take action after a transition.

defmodule OnStateChanged do
use Ecspanse.System,
event_subscriptions: [EcspanseStateMachine.Events.StateChanged]
def run(
%EcspanseStateMachine.Events.StateChanged{
entity_id: entity_id,
from: from,
to: to,
trigger: _trigger
},
_frame
) do
# respond to the transition
end
end

Command a state change

State changes happen when a timeout elapses or upon request. Call EcspanseStateMachine.transition to trigger a transition.

EcspanseStateMachine.transition(entity_id, :fight, :idle)

Here were changing state from :fight to :idle.

Stopping a state machine

The state machine will automatically stop when it reaches a state no exits.

You can stop a state machine anytime by calling EcspanseStateMachine.stop.

EcspanseStateMachine.stop(entity_id)

Telemetry

ECSpanse State Machine implements telemetry for the following events.

event namemeasurementmetadatadescription
ecspanse_state_machine.startsystem_timestate_machineExecuted on state machine start
ecspanse_state_machine_stopdurationstate_machineExecuted on state machine stop
ecspanse_state_machine.state.startsystem_timestate_machine, stateExecuted on entering a state
ecspanse_state_machine.state.stopdurationstate_machine, stateExecuted on exiting a state

Mermaid State Diagrams

ECSpanseStateMachine generates Mermaid.js state diagrams for your state machines.

EcspanseStateMachine.format_as_mermaid_diagram(entity_id)

Here's an example output.

---
title: Simple AI
---
stateDiagram-v2
[*] --> idle
fight --> die
fight --> idle
idle --> fight
idle --> patrol: ⏲️
patrol --> fight
patrol --> idle: ⏲️
die --> [*]

Which produces the following state diagram when rendered