statemachine
StateMachine
A generic, type-safe state machine library for Gleam with easing function support and weighted transitions.
Features
- Fully Generic: Works with any state type (enums, strings, custom types)
- Easing Support: Compatible with easings_gleam for smooth transitions
- Weighted Transitions: Prioritize transitions when multiple conditions are true
- Automatic Blending: Smooth interpolation between states
- Context-Aware Conditions: Pass game/app context to transition condition functions
- Type-Safe: Leverage Gleam’s type system for correctness
- Duration-Based: Uses gleam_time’s Duration type for precise timing
Quick Example
import statemachine
import gleam/option.{None}
import gleam/time/duration
// Define your state type
type PlayerState {
Idle
Walking
Running
}
// Define your context
type GameContext {
GameContext(velocity: Float)
}
// Create state machine
let machine =
statemachine.new(initial_state: Idle)
|> statemachine.with_state(state: Idle)
|> statemachine.with_state(state: Walking)
|> statemachine.with_state(state: Running)
|> statemachine.with_transition(
from: Idle,
to: Walking,
condition: statemachine.Custom(fn(ctx) { ctx.velocity >. 0.1 }),
blend_duration: duration.milliseconds(200),
easing: None,
weight: 0,
)
// Update in game loop
fn update(model: Model, delta_time: duration.Duration) {
let ctx = GameContext(velocity: model.velocity)
let #(new_machine, transitioned) = statemachine.update(machine, ctx, delta_time)
case statemachine.state_data(new_machine) {
statemachine.Single(state) -> // Use single state
statemachine.BlendingData(from, to, factor) -> // Interpolate
}
}
Transition Conditions
Always
Transition immediately when conditions are evaluated:
statemachine.with_transition(
from: Idle,
to: Jump,
condition: statemachine.Always,
blend_duration: duration.milliseconds(100),
easing: None,
weight: 0,
)
AfterDuration
Transition after spending time in the current state:
statemachine.with_transition(
from: Idle,
to: Sleeping,
condition: statemachine.AfterDuration(duration.seconds(5)),
blend_duration: duration.seconds(1),
easing: Some(easings.ease_in_out_sine),
weight: 0,
)
Custom
Transition based on custom context:
statemachine.Custom(fn(ctx) { ctx.velocity >. 5.0 })
Transition Weights
When multiple transitions from the same state have their conditions met, the transition with the highest weight is chosen:
statemachine.new(initial_state: Idle)
|> statemachine.with_transition(
from: Idle,
to: Walking,
condition: statemachine.Always,
blend_duration: duration.milliseconds(200),
easing: None,
weight: 5,
)
|> statemachine.with_transition(
from: Idle,
to: Running,
condition: statemachine.Always,
blend_duration: duration.milliseconds(200),
easing: None,
weight: 10, // This one will be chosen!
)
Types
Condition for transitioning between states.
The generic parameter ctx allows you to pass custom context to condition functions.
Examples
// Always transition
statemachine.Always
// After 5 seconds in current state
statemachine.AfterDuration(duration.seconds(5))
// Based on velocity
statemachine.Custom(fn(ctx: GameContext) { ctx.velocity >. 5.0 })
pub type Condition(ctx) {
Always
AfterDuration(duration.Duration)
Custom(fn(ctx) -> Bool)
}
Constructors
-
AlwaysAlways transition (immediate)
-
AfterDuration(duration.Duration)Transition after elapsed time in current state
-
Custom(fn(ctx) -> Bool)Custom condition function that receives context
The current state of a running state machine.
Either playing a single state or blending between two states.
pub type MachineState(state) {
Playing(state: state, elapsed: duration.Duration)
Blending(
from: state,
to: state,
blend_progress: duration.Duration,
blend_duration: duration.Duration,
easing: option.Option(fn(Float) -> Float),
)
}
Constructors
-
Playing(state: state, elapsed: duration.Duration)Playing a single state
-
Blending( from: state, to: state, blend_progress: duration.Duration, blend_duration: duration.Duration, easing: option.Option(fn(Float) -> Float), )Blending between two states with easing
Get the current state or blended state data.
Returns either a single state when not blending, or blended state data with an eased factor (0.0 to 1.0) during transitions.
Returns
Single(state): Currently in a single stateBlendingData(from, to, factor): Blending between states with eased factor
Example
case statemachine.state_data(machine) {
statemachine.Single(state) -> {
// Use the current state directly
io.println("Current state: " <> string.inspect(state))
}
statemachine.BlendingData(from: from, to: to, factor: factor) -> {
// Interpolate between from and to using the eased factor
let interpolated = lerp(from_value, to_value, factor)
}
}
pub type StateData(data) {
Single(data)
BlendingData(from: data, to: data, factor: Float)
}
Constructors
-
Single(data)Single state data
-
BlendingData(from: data, to: data, factor: Float)Blending between two states with blend factor (0.0 to 1.0)
A state machine.
Generic over:
state: The type used for state identifiers (e.g., enum, string, custom types)ctx: The type passed to condition functions (e.g., game context)
Example
type State {
Idle
Walking
}
type Context {
Context(velocity: Float)
}
let machine: StateMachine(State, Context) = statemachine.new(initial_state: Idle)
pub opaque type StateMachine(state, ctx)
Transition between two states with optional easing and priority weight.
Fields
from: Source stateto: Target statecondition: When to trigger the transitionblend_duration: Time to blend between stateseasing: Optional easing function for non-linear blendingweight: Priority when multiple transitions are valid (higher = higher priority)
pub type Transition(state, ctx) {
Transition(
from: state,
to: state,
condition: Condition(ctx),
blend_duration: duration.Duration,
easing: option.Option(fn(Float) -> Float),
weight: Int,
)
}
Constructors
-
Transition( from: state, to: state, condition: Condition(ctx), blend_duration: duration.Duration, easing: option.Option(fn(Float) -> Float), weight: Int, )
Values
pub fn blend_progress(
machine: StateMachine(state, ctx),
) -> Result(Float, Nil)
Get blend progress as a normalized value (0.0 to 1.0).
Returns None if not currently blending, Some(progress) during transitions.
The progress returned is the linear progress, not the eased value.
Example
case statemachine.blend_progress(machine) {
Ok(progress) -> {
io.println("Blend: " <> float.to_string(progress *. 100.0) <> "%")
}
Error(Nil) -> Nil
}
pub fn is_blending(machine: StateMachine(state, ctx)) -> Bool
Check if currently blending between states.
Returns True during transitions, False when playing a single state.
Example
case statemachine.is_blending(machine) {
True -> io.println("Transitioning...")
False -> io.println("Stable state")
}
pub fn new(
initial_state initial_state: state,
) -> StateMachine(state, ctx)
Create a new state machine with an initial state.
The initial state is automatically registered in the state machine.
Default blend duration: 1 second (can be changed with with_default_blend).
Default easing: None (linear) (can be changed with with_default_easing).
Example
type State {
Idle
Walking
}
let machine = statemachine.new(initial_state: Idle)
pub fn state_count(machine: StateMachine(state, ctx)) -> Int
Get the number of states in the state machine.
pub fn state_data(
machine: StateMachine(state, ctx),
) -> StateData(state)
Get the current state data from the state machine.
See StateData type documentation for usage examples.
pub fn states(
machine: StateMachine(state, ctx),
) -> set.Set(state)
Get all registered state IDs.
Returns a Set of all states registered in the state machine.
Example
import gleam/set
let ids = statemachine.state_ids(machine)
set.to_list(ids)
|> list.each(fn(state) {
io.println("State: " <> string.inspect(state))
})
pub fn transition_count(machine: StateMachine(state, ctx)) -> Int
Get the number of transitions in the state machine.
pub fn transition_to(
machine: StateMachine(state, ctx),
target: state,
blend_duration blend_duration: option.Option(duration.Duration),
easing easing: option.Option(fn(Float) -> Float),
) -> StateMachine(state, ctx)
Manually force a transition to a specific state.
Bypasses all transition conditions and forces an immediate state change. Useful for external events like damage, death, or cutscenes.
Parameters
machine: The state machinetarget: The state to transition toblend_duration: Optional blend time. If None, uses default blend durationeasing: Optional easing function. If None, uses default easing
Example
import easings
import gleam/option.{None, Some}
import gleam/time/duration
// Force transition with custom blend and easing
let machine =
statemachine.transition_to(
machine,
HitReaction,
blend_duration: Some(duration.milliseconds(100)),
easing: Some(easings.ease_out_back),
)
// Force transition with defaults
let machine =
statemachine.transition_to(
machine,
Dead,
blend_duration: None,
easing: None,
)
pub fn update(
machine: StateMachine(state, ctx),
context: ctx,
delta_time: duration.Duration,
) -> #(StateMachine(state, ctx), Bool)
Update the state machine (call every frame).
Evaluates transition conditions and advances blend progress. Returns the updated machine and a boolean indicating if a transition occurred this frame.
Parameters
machine: The state machine to updatecontext: Your custom context passed to Custom condition functionsdelta_time: Time elapsed since last update
Returns
A tuple of (updated_machine, transitioned) where transitioned is True
if a state change occurred this frame.
Example
import gleam/time/duration
fn game_loop(model: Model, delta: duration.Duration) {
let ctx = GameContext(velocity: model.velocity)
let #(new_machine, transitioned) =
statemachine.update(model.machine, ctx, delta)
case transitioned {
True -> io.println("State changed!")
False -> Nil
}
Model(..model, machine: new_machine)
}
pub fn with_default_blend(
machine: StateMachine(state, ctx),
duration default_blend: duration.Duration,
) -> StateMachine(state, ctx)
Set the default blend duration for manual transitions.
This duration is used when calling transition_to without specifying
a blend duration. Does not affect transitions added with with_transition.
Example
import gleam/time/duration
let machine =
statemachine.new(initial_state: Idle)
|> statemachine.with_default_blend(duration: duration.milliseconds(500))
pub fn with_default_easing(
machine: StateMachine(state, ctx),
easing default_easing: option.Option(fn(Float) -> Float),
) -> StateMachine(state, ctx)
Set the default easing function for manual transitions.
This easing is used when calling transition_to without specifying
an easing function. Does not affect transitions added with with_transition.
Example
import easings
import gleam/option.{Some}
let machine =
statemachine.new(initial_state: Idle)
|> statemachine.with_default_easing(easing: Some(easings.ease_in_out_quad))
pub fn with_state(
machine: StateMachine(state, ctx),
state state: state,
) -> StateMachine(state, ctx)
Register a state in the state machine.
States must be registered before they can be used in transitions. Registering the same state multiple times is safe (uses a Set internally).
Example
let machine =
statemachine.new(initial_state: Idle)
|> statemachine.with_state(state: Walking)
|> statemachine.with_state(state: Running)
pub fn with_transition(
machine: StateMachine(state, ctx),
from from: state,
to to: state,
condition condition: Condition(ctx),
blend_duration blend_duration: duration.Duration,
easing easing: option.Option(fn(Float) -> Float),
weight weight: Int,
) -> StateMachine(state, ctx)
Add a transition between two states.
Transitions define when and how to switch between states.
Parameters
from: Source state (must be registered)to: Target state (must be registered)condition: When to trigger (Always, AfterDuration, or Custom)blend_duration: Time to blend between stateseasing: Optional easing function (compatible with easings_gleam)weight: Priority when multiple transitions are valid (higher = higher priority)
Example
import easings
import gleam/option.{None, Some}
import gleam/time/duration
statemachine.with_transition(
machine,
from: Idle,
to: Walking,
condition: statemachine.Custom(fn(ctx) { ctx.velocity >. 0.1 }),
blend_duration: duration.milliseconds(200),
easing: Some(easings.ease_out_quad),
weight: 5,
)