Skip to main content

State Machine

State machines are one of the basic tools for organizing your code. States allow you to switch between different behaviors, values, and setup/teardown logic based on user-defined conditions. A state machine is made up of three main components:

  • States
  • Triggers
  • Groups

A state machine is always in exactly one state at a time, and will transition between states when certain conditions (defined by the triggers) are met. State groups are a convenient way to bundle shared logic between multiple states, but the groups are not states themselves. They provide much of the same API as a state, but distribute behavior and triggers across all of their substates.

You can add a state machine to your custom component using the stateMachine property in the component's definition. It must be a StateMachineDefiner or StateMachineDefinition. These state machines exist on each entity with the component, and will be automatically created and destroyed when the object is added to and removed from the world.

ecs.registerComponent({
...
stateMachine: ({world, eid}) => {
// Define states here
},
})

You can also create and manage an independent state machine by calling the ecs.createStateMachine function directly:

const machineId = ecs.createMachine(world, eid, machineDef)
...
ecs.deleteStateMachine(world, machineId)

A state machine is a combination of states and the logic that transitions between those states. They are typically defined within a StateMachineDefiner function that is called when the machine is created. Here is an example of a state machine to move an object back and forth until it hits something.

const BackAndForth = ecs.registerComponent({
name: 'back-and-forth',
schema: {
forthX: ecs.f32,
forthY: ecs.f32,
forthZ: ecs.f32,
duration: ecs.f32,
},
stateMachine: ({world, eid, schemaAttribute}) => {
const position = {...ecs.Position.get(world, eid)}
const backX = position.x
const backY = position.y
const backZ = position.z

const {forthX, forthY, forthZ, duration} = schemaAttribute.get(eid)

const waitBeforeBack = ecs.defineState('waitBeforeBack').wait(1000, 'back')
const back = ecs.defineState('back').initial()
.onEvent('position-animation-complete', 'waitBeforeForth')
.onEnter(() => {
ecs.PositionAnimation.set(world, eid, {
autoFrom: true
toX: forthX,
toY: forthY,
toZ: forthZ,
duration,
})
})

const waitbeforeForth = ecs.defineState('waitBeforeForth').wait(1000, 'forth')
const forth = ecs.defineState('forth')
.onEvent('position-animation-complete', waitBeforeBack)
.onEnter(() => {
ecs.PositionAnimation.set(world, eid, {
autoFrom: true
toX: backX,
toY: backY,
toZ: backZ,
duration,
})
})

const stopped = ecs.defineState('stopped').onEnter(() => {
ecs.PositionAnimation.remove(world, eid)
})

ecs.defineStateGroup([back, forth]).onEvent(ecs.Physics.COLLISION_START_EVENT, stopped)
},
})

Here is another example of a machine of changing a character state:

const Character = ecs.registerComponent({
name: 'character',
schema: {
walkingSpeed: ecs.f32,
runningSpeed: ecs.f32,
staminaRate: ecs.f32,
jumpForce: ecs,f32,
},
stateMachine: ({world, eid, schemaAttribute}) => {
const idle = ecs.defineState('idle').initial()
const walking = ecs.defineState('walking')
const running = ecs.defineState('running')
const airborne = ecs.defineState('airborne')

const moving = ecs.defineStateGroup([walking, running])
const canJump = ecs.defineStateGroup([idle, walking, running])
const recovering = ecs.defineStateGroup([idle, walking])
const all = ecs.defineStateGroup()

const startMoving = ecs.defineTrigger()
const stopMoving = ecs.defineTrigger()

idle.onTrigger(startMoving, walking)
.onTick(() => {
if (world.input.getAction('forward')) {
startMoving.toggle()
}
})

moving.onTrigger(stopMoving, idle)
.onTick((currentState) => {
const {walkingSpeed, runningSpeed} = schemaAttrbiute.get(eid)
let speed = currentState === 'running' ? runningSpeed : walkingSpeed
ecs.physics.applyForce(world, eid, 0, 0, speed)
if (!world.input.getAction('forward')) {
stopMoving.toggle()
}
})

walking.onEvent('toggle-sprint', running)
running.onEvent('toggle-sprint', walking)

canJump.onEvent('jump', airborne)
airborne.onEnter(() => ecs.physics.applyForce(world, eid, 0, schemaAttribute.get(eid).jumpForce, 0))
.onEvent(ecs.Physics.COLLISION_START_EVENT, walking, {where: () => world.input.getAction('forward')})
.onEvent(ecs.Physics.COLLISION_START_EVENT, idle)

let stamina = 1.0
const updateStamina = (add: boolean) => {
const diff = schemaAttribute.get(eid).staminaRate * world.time.delta
stamina += add ? diff : -diff
}

running.onTick(() => updateStamina(false))
recovering.onTick(() => updateStamina(true))
all.listen(eid, 'powerup-collected', () => stamina = 1.0)
},
})

StateMachineDefiner

A StateMachineDefiner is a function that creates a state machine and all of its parts. Inside the function you define states and groups, as well as locally scoped variables and functions to use in them. It can be passed into a component's stateMachine prop and will be called when an entity is added to the world. Alternatively you can directly pass a StateMachineDefinition, which is outlined below.

The StateMachineDefiner function accepts an object with the following properties:

KeyTypeDescription
worldWorldThe world the machine is created in
eidEidThe entity owner of the machine
schemaAttributeWorldAttributeAccess to the component's schema
dataAttributeWorldAttributeAccess to the component's data

defineState()

Use this function to create a State from within a StateMachineDefiner

ParameterTypeDescription
namestringThe unique name of the state

Returns a StateDefiner

Example

stateMachine: ({world, eid}) => {
const foo = ecs.defineState('foo')
...
}

StateDefiner

note

StateDefiner functions are 'fluent', so they all return the instance itself. This means you can chain any of the following consecutively in a single statement.

.initial()

Mark this state to be the current state of the state machine when it is created.

Example

ecs.defineState('name').initial()

.onEnter()

Set a callback to run when entering this state.

ParameterTypeDescription
cb() => voidA function called when the state is entered

Example

ecs.defineState('name').onEnter(() => {
// Do something
})

.onTick()

Set a callback to run every frame.

ParameterTypeDescription
cb() => voidA function called once each frame while in this state

Example

ecs.defineState('name').onTick(() => {
// Do something
})

.onExit()

Set a callback to run when exiting this state.

ParameterTypeDescription
cb() => voidA function called when the state exits

Example

ecs.defineState('name').onExit(() => {
// Do something
})

.onEvent()

Call to add an EventTrigger from this state to another that can transition when a specific event is invoked

ParameterTypeDescription
eventstringThe event to listen for
nextStateStateIdThe next state to transition to
argsobject (see below)Optional arguments for determining when to transition

args

KeyTypeRequiredDescription
targetEidnoThe entity that is expected to receive an event (defaults to the state machine owner)
where(QueuedEvent) => booleanno An optional predicate to check before transitioning; returning false will prevent the transition from occurring

Example

ecs.defineState('example').onEvent(
ecs.input.SCREEN_TOUCH_START,
'other',
{
target: world.events.globalId,
where: (event) => event.data.position.y > 0.5
}
)

.wait()

Call to add a TimeoutTrigger from this state to another that transitions after a set amount of time.

ParameterTypeDescription
timeoutnumberThe duration in milliseconds before transitioning
nextStateStateIdThe next state to transition to

Example

ecs.defineState('example').wait(1000, 'other')

.onTrigger()

Call to add a CustomTrigger from this state to another that can transition at any time immediately by the user. Use ecs.defineTrigger() to create a TriggerHandle that can be manually invoked.

ParameterTypeDescription
handleTriggerHandleThe handle that will cause a transition when manually activated
nextStateStateIdThe next state to transition to

Example

const toOther = ecs.defineTrigger()
ecs.defineState('example').onTrigger(toOther, 'other')
...
toOther.trigger()

.listen()

Call to add ListenerParams to this state's set of listeners. An event listener will be automatically added when the state is entered, and removed on exit.

ParameterTypeDescription
targetEid or () => EidThe entity that is expected to receive an event
namestringThe event to listen for
listener(QueuedEvent) => voidThe function to call when the event is dispatched

Example

const handleCollision = (event) => { ... }
ecs.defineState('example').listen(eid, ecs.physics.COLLISION_START_EVENT, handleCollision)

defineStateGroup()

Use this function to create a StateGroup from within a StateMachineDefiner.

ParameterTypeRequiredDescription
substatesStateId[]noThe list of states that make up this group; excluding this parameter is equivalent to listing all states

Returns a StateGroupDefiner

Example

const foo = ecs.defineState('foo')
const fizz = ecs.defineState('fizz')
const buzz = ecs.defineState('buzz')
const fizzBuzz = ecs.defineStateGroup([fizz, 'buzz'])

StateGroupDefiner

note

StateGroupDefiner functions are 'fluent', so they all return the instance itself. This means you can chain any of the following consecutively in a single statement.

.onEnter()

Set a callback to run when entering this group from outside it.

ParameterTypeDescription
cb(string) => voidA function called when a substate is entered from a non-substate, optionally providing the specific state that was entered

Example

ecs.defineStateGroup(['a', 'b']).onEnter(() => {
// Do something
})

.onTick()

Set a callback to run every frame.

ParameterTypeDescription
cb(string) => voidA function called once each frame while in any substate, optionally providing the current state

Example

ecs.defineStateGroup(['a', 'b']).onTick(() => {
// Do something
})

.onExit()

Set a callback to run when exiting this state.

ParameterTypeDescription
cb(string) => voidA function called when a substate exits to a non-substate, optionally providing the specific state that was exited

Example

ecs.defineStateGroup(['a', 'b']).onExit(() => {
// Do something
})

.onEvent()

Call to add an EventTrigger from any state in this group to some other state that can transition when a specific event is invoked

ParameterTypeDescription
eventstringThe event to listen for
nextStateStateIdThe state to transition to
argsobject (see below)optional arguments for determining when to transition

args

KeyTypeRequiredDescription
targetEidnoThe entity that is expected to receive an event
where(QueuedEvent) => booleanno An optional predicate to check before transitioning; returning false will prevent the transition from occurring

Example

ecs.defineStateGroup(['a', 'b']).onEvent('click', 'c')

.wait()

Call to add a TimeoutTrigger from any state in this group to some other state that transitions after a set amount of time.

ParameterTypeDescription
timeoutnumberThe duration in milliseconds before transitioning
nextStateStateIdThe state to transition to

Example

ecs.defineStateGroup(['a', 'b']).wait(1000, 'c')

.onTrigger()

Call to add a CustomTrigger from any state in this group to some other state that can transition at any time immediately by the user. Use ecs.defineTrigger() to create a TriggerHandle that can be manually invoked.

ParameterTypeDescription
handleTriggerHandleThe handle that will cause a transition when manually activated
nextStateStateIdThe state to transition to

Example

const toC = ecs.defineTrigger()
ecs.defineStateGroup(['a', 'b']).onTrigger(toC, 'c')
...
toC.trigger()

.listen()

Call to add ListenerParams to this group's set of listeners. An event listener will be automatically added when the group is entered, and removed on exit.

ParameterTypeDescription
targetEid or () => EidThe entity that is expected to receive an event
namestringThe event to listen for
listener(QueuedEvent) => voidThe function to call when the event is dispatched

Example

const handleCollision = (event) => { ... }
ecs.defineStateGroup(['a', 'b']).listen(eid, ecs.physics.COLLISION_START_EVENT, handleCollision)

StateMachineDefinition

Instead of using the StateMachineDefiner to define states and transitions, you can pass a StateMachineDefinition, which is an object containing all of the states and their transitions.

KeyTypeRequiredDescription
initialStatestringyesName of the starting state of the state machine
states
Record<string, State>
yesA map that stores state names and their definition
groupsStateGroup[]noAn optional list of state groups

Example

const stateMachine = {
initialState: 'a'
states: {
'a': {
onExit: () => console.log('exit a'),
triggers: {
'b': [{ type: 'timeout', timeout: 1000 }],
},
},
'b': { onEnter: () => console.log('enter b') },
},
groups: [{
substates: ['a', 'b'],
listeners: [{
target: world.events.globalId,
name: ecs.input.SCREEN_TOUCH_START,
listener: (event) => console.log('touch'),
}]
}],
}

State

A state is the fundamental atomic unit of a state machine. They can be defined directly or with the fluent StateMachineDefiner API outlined above. A state machine is always in exactly one state at a time, and will transition according to user-defined triggers associated with the current state.

KeyTypeRequiredDescription
triggers
Record<string, Trigger[]>
yesAll outgoing transitions indexed by their destination state
onEnter() => voidnoA function called when the state is entered
onTick() => voidnoA function called once each frame while in this state
onExit() => voidnoA function called when the state exits
listenersListenerParams[]noParameters for event listeners that will be automatically added and removed when entering and exiting the state

Example:

const forth = {
triggers: {
'waitBeforeBack': {
type: 'event',
event: 'animation-complete',
},
},
onEnter: () => {
ecs.PositionAnimation.set(world, eid, {
autoFrom: true
toX: backX,
toY: backY,
toZ: backZ,
duration,
})
},
},

StateGroup

A state group is an abstract wrapper around multiple states of a state machine. It is a useful tool for organizing code, preventing repetitive statements, and simplifying logic. It is not a state itself and thus cannot be transitioned to. It can however have outgoing transitions, which are essentially shorthand for defining those same outgoing transitions individually on all substates. Its onEnter and onExit callbacks will not be invoked when transitioning between its substates.

KeyTypeRequiredDescription
substatesStateId[]noThe list of states that make up this group; excluding this parameter is equivalent to listing all states
triggers
Record<string, Trigger[]>
yesAll outgoing transitions indexed by their destination state
onEnter() => voidnoA function called when a substate is entered from a non-substate
onTick() => voidnoA function called once each frame while in any substate
onExit() => voidnoA function called when a substate exits to a non-substate
listenersListenerParams[]noParameters for event listeners that will be automatically added and removed when entering and exiting the group

Example:

const forth = {
triggers: {
'waitBeforeBack': {
type: 'event',
event: 'animation-complete',
},
},
onEnter: () => {
ecs.PositionAnimation.set(world, eid, {
autoFrom: true
toX: backX,
toY: backY,
toZ: backZ,
duration,
})
},
},

Trigger

There are multiple types of triggers for transitioning under various circumstances

EventTrigger

EventTriggers are used to optionally transition when a specified event is invoked. The event data can be used to make a runtime decision whether to transition or not.

KeyTypeRequiredDescription
type'event'yesA constant to indicate the type of the trigger
eventstringyesThe name of the event to listen for
targetEidnoThe entity that is expected to receive an event
where(QueuedEvent) => booleannoAn optional predicate to check before transitioning; returning false will prevent the transition from occurring

Example:

const example = {
triggers:
'other': [
{
type: 'event',
event: ecs.input.SCREEN_TOUCH_START,
target: world.events.globalId
where: (event) => event.data.position.y > 0.5
},
]
}

TimeoutTrigger

TimeoutTriggers are used to cause a transition to occur after a fixed amount of time from entering a state or group.

KeyTypeRequiredDescription
type'timeout'yesA constant to indicate the type of the trigger
timeoutnumberyesThe number of milliseconds to wait before transitioning

Example:

const example = {
triggers:
'other': [
{
type: 'timeout',
timeout: 1000,
},
]
}

CustomTrigger

CustomTriggers are transitions that can be triggered at any time, causing an immediate transition. Use ecs.defineTrigger() to create a TriggerHandle that can be manually invoked.

KeyTypeRequiredDescription
type'custom'yesA constant to indicate the type of the trigger
handleTriggerHandleyesThe handle that will cause a transition when manually activated

Example:

const toOther = ecs.defineTrigger()
const example = {
triggers:
'other': [
{
type: 'custom',
trigger: toOther,
},
]
}
...
toOther.trigger()

StateId

StateIds are used for specifying transition destinations. Can be either a StateDefiner or the state name itself as a string.

const a = ecs.definestate('a').wait(1000, 'b')
const b = ecs.defineState('b').wait(1000, a)

ListenerParams

A ListenerParams object is a type that is used for storing the parameters needed to supply an event listener. They are passed into a state via the listeners property of a State or StateGroup object, or created automatically when calling listen() on a StateDefiner or StateGroupDefiner.

KeyTypeRequiredDescription
targetEid or () => EidyesThe entity that is expected to receive an event
namestringyesThe event to listen for
listener(QueuedEvent) => voidyesThe function to call when the event is dispatched

TriggerHandle

An object that is used to define an arbitrary transition between states. It must be created via ecs.defineTrigger, and is used by onTrigger or CustomTrigger.

KeyTypeDescription
trigger()() => voidCall this function to cause any active CustomTrigger transitions to occur

Example

const go = ecs.defineTrigger()
const stopped = ecs.defineState('stoppe').onTick(() => {
if (world.input.getAction('start-going')) {
go.trigger()
}
}).onTrigger(go, 'going')
const going = ecs.defineState('going')