tiramisu/state_machine
State Machine module - declarative animation state management.
Provides a type-safe, immutable state machine for managing complex animation transitions in 3D models. Supports condition-based transitions, automatic blending, and custom state types.
Core Concepts
- Generic State Types: Use any type for states (enums, strings, custom types)
- Context Parameter: Pass game context to custom transition conditions
- Declarative Transitions: Define state transitions with conditions and blend durations
- Automatic Blending: Smooth animation blending during state transitions
- Immutable Updates: Update returns new state machine (functional pattern)
- Model3D Integration: Seamless integration with 3D model animation playback
Quick Example
import tiramisu/state_machine
import tiramisu/object3d
import tiramisu/scene
// Define your state type
pub type CharacterState {
Idle
Walking
Running
Jumping
}
// Define your context type (for custom conditions)
pub type GameContext {
GameContext(velocity: Float, is_grounded: Bool)
}
// Create state machine
let machine =
state_machine.new(Idle)
|> state_machine.add_state(Idle, idle_anim, looping: True)
|> state_machine.add_state(Walking, walk_anim, looping: True)
|> state_machine.add_state(Running, run_anim, looping: True)
|> state_machine.add_state(Jumping, jump_anim, looping: False)
|> state_machine.add_transition(
from: Idle,
to: Walking,
condition: state_machine.Custom(fn(ctx) { ctx.velocity >. 0.1 }),
blend_duration: 0.2,
)
|> state_machine.add_transition(
from: Walking,
to: Running,
condition: state_machine.Custom(fn(ctx) { ctx.velocity >. 5.0 }),
blend_duration: 0.3,
)
|> state_machine.add_transition(
from: Running,
to: Walking,
condition: state_machine.Custom(fn(ctx) { ctx.velocity <=. 5.0 }),
blend_duration: 0.3,
)
|> state_machine.add_transition(
from: Walking,
to: Idle,
condition: state_machine.Custom(fn(ctx) { ctx.velocity <=. 0.1 }),
blend_duration: 0.2,
)
// Update state machine in your game loop
fn update(model: Model, ctx: tiramisu.Context) -> Model {
let game_ctx = GameContext(velocity: model.velocity, is_grounded: model.grounded)
let #(new_machine, _transitioned) =
state_machine.update(model.anim_machine, game_ctx, ctx.delta_time)
let animation =
state_machine.get_current_animation(new_machine)
|> state_machine.to_playback()
Model(..model, anim_machine: new_machine)
}
// Use in scene
fn view(model: Model) -> scene.Node {
let animation =
state_machine.get_current_animation(model.anim_machine)
|> state_machine.to_playback()
scene.Model(
id: "character",
model: model.character_model,
animation: animation,
transform: model.transform,
)
}
Transition Conditions
Three types of conditions control when transitions occur:
- Always: Transition immediately (useful for forced state changes)
- AfterDuration(seconds): Transition after elapsed time in current state
- Custom(fn(ctx) -> Bool): Custom condition based on game context
Custom Condition Examples
// Velocity-based transition
state_machine.Custom(fn(ctx) { ctx.player_speed >. 5.0 })
// Input-based transition
state_machine.Custom(fn(ctx) { ctx.jump_pressed && ctx.is_grounded })
// Distance-based transition
state_machine.Custom(fn(ctx) {
vec3.distance(ctx.player_pos, ctx.enemy_pos) <. 10.0
})
Animation Blending
State machines automatically blend animations during transitions:
- blend_duration: How long the blend takes (in seconds)
- blend_progress: Current blend progress (0.0 to 1.0)
- Smooth transitions: Linear interpolation between animation poses
Use is_blending() and blend_progress() to query blend state.
Manual Transitions
You can manually trigger transitions (bypassing conditions):
// Force transition to specific state with custom blend duration
let machine =
state_machine.transition_to(machine, Running, option.Some(0.5))
// Force transition with default blend duration
let machine =
state_machine.transition_to(machine, Idle, option.None)
Types
Output from the state machine
pub type AnimationOutput {
None
Single(animation.Animation)
Blend(
from: animation.Animation,
to: animation.Animation,
factor: Float,
)
}
Constructors
-
NoneNo animation
-
Single(animation.Animation)Play a single animation
-
Blend( from: animation.Animation, to: animation.Animation, factor: Float, )Blend between two animations
Condition for transitioning between states
The generic parameter ctx allows you to pass context (like GameContext) to custom conditions
pub type Condition(ctx) {
Always
AfterDuration(Float)
Custom(fn(ctx) -> Bool)
}
Constructors
-
AlwaysAlways transition (immediate)
-
AfterDuration(Float)Transition after a duration (seconds)
-
Custom(fn(ctx) -> Bool)Custom condition function that receives context
An animation state with its configuration
pub type State(state) {
State(
id: state,
animation: animation.Animation,
is_looping: Bool,
)
}
Constructors
-
State( id: state, animation: animation.Animation, is_looping: Bool, )
An animation state machine
pub opaque type StateMachine(state, ctx)
The current state of a running state machine
pub type StateMachineState(state) {
Playing(state: state, elapsed: Float)
Blending(
from: state,
to: state,
blend_progress: Float,
blend_duration: Float,
)
}
Constructors
-
Playing(state: state, elapsed: Float)Playing a single state
-
Blending( from: state, to: state, blend_progress: Float, blend_duration: Float, )Blending between two states
Values
pub fn add_state(
machine: StateMachine(state, ctx),
id: state,
animation: animation.Animation,
looping looping: Bool,
) -> StateMachine(state, ctx)
Add a state to the state machine.
Each state links a unique ID to an animation clip and playback settings.
Looping: Set True for repeating animations (idle, walk), False for one-shots (jump, attack).
Example
let machine = state_machine.new(Idle)
|> state_machine.add_state(Idle, idle_anim, looping: True)
|> state_machine.add_state(Walking, walk_anim, looping: True)
|> state_machine.add_state(Attacking, attack_anim, looping: False)
pub fn add_transition(
machine: StateMachine(state, ctx),
from from: state,
to to: state,
condition condition: Condition(ctx),
blend_duration blend_duration: Float,
) -> StateMachine(state, ctx)
Add a transition between two states.
Transitions define when and how to switch from one animation to another.
Blend duration: Time in seconds for smooth animation blending (typical: 0.1-0.5s).
Condition: When to trigger the transition (Always, AfterDuration, or Custom).
Example
state_machine.new(Idle)
|> state_machine.add_state(Idle, idle_anim, looping: True)
|> state_machine.add_state(Walking, walk_anim, looping: True)
// Transition when velocity exceeds threshold
|> state_machine.add_transition(
from: Idle,
to: Walking,
condition: state_machine.Custom(fn(ctx) { ctx.velocity >. 0.1 }),
blend_duration: 0.2,
)
// Transition back when velocity drops
|> state_machine.add_transition(
from: Walking,
to: Idle,
condition: state_machine.Custom(fn(ctx) { ctx.velocity <=. 0.1 }),
blend_duration: 0.3,
)
pub fn blend_progress(
machine: StateMachine(state, ctx),
) -> option.Option(Float)
Get blend progress as a normalized value (0.0 to 1.0).
Returns None if not currently blending, Some(progress) during transitions.
Progress: 0.0 = start of blend, 1.0 = end of blend.
Example
// Visualize blend progress
case state_machine.blend_progress(model.anim_machine) {
option.Some(progress) -> {
io.println("Blend: " <> float.to_string(progress *. 100.0) <> "%")
}
option.None -> Nil
}
pub fn current_state(machine: StateMachine(state, ctx)) -> state
Get the current state ID.
When blending, returns the target state (where we’re transitioning to).
Example
let state = state_machine.current_state(machine)
case state {
Idle -> io.println("Character is idle")
Running -> io.println("Character is running")
_ -> Nil
}
pub fn get_current_animation(
machine: StateMachine(state, ctx),
) -> AnimationOutput
Get the current animation output from the state machine.
Returns either a single animation (when playing), blended animations (when transitioning), or none (if state not found).
Use to_playback() to convert the result to Option(AnimationPlayback) for use with scene.Model3D.
Example
fn view(model: Model) -> scene.Node {
let animation =
state_machine.get_current_animation(model.anim_machine)
|> state_machine.to_playback()
scene.Model3D(
id: "character",
model: model.character_model,
animation: animation,
transform: model.transform,
physics: option.None,
)
}
pub fn get_state(
machine: StateMachine(state, ctx),
id: state,
) -> Result(State(state), Nil)
Get a state by ID.
Returns the state configuration including animation and looping setting. Useful for inspecting state machine configuration.
Example
case state_machine.get_state(machine, Running) {
Ok(state) -> {
io.println("Running animation duration: " <>
float.to_string(animation.clip_duration(state.animation)))
}
Error(_) -> io.println("State not found")
}
pub fn is_blending(machine: StateMachine(state, ctx)) -> Bool
Check if currently blending between states.
Returns True during animation transitions, False when playing a single state.
Example
// Disable certain actions during transitions
case state_machine.is_blending(model.anim_machine) {
True -> {
// Can't attack while transitioning
model
}
False -> {
// Allow attack
handle_attack(model)
}
}
pub fn new(initial_state: state) -> StateMachine(state, ctx)
Create a new state machine with a starting state.
The machine starts with no states or transitions. Use add_state() and
add_transition() to build up the state graph.
Default blend duration: 0.2 seconds (can be changed with set_default_blend()).
Example
// Define state type
type PlayerState {
Idle
Walking
Running
}
// Create machine starting in Idle state
let machine = state_machine.new(Idle)
pub fn set_default_blend(
machine: StateMachine(state, ctx),
duration: Float,
) -> 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 add_transition() (those use their own blend_duration).
Duration: Time in seconds (default: 0.2s).
Example
let machine = state_machine.new(Idle)
|> state_machine.set_default_blend(0.5) // 500ms default blend
// Later, manual transition uses default 0.5s blend
let machine = state_machine.transition_to(machine, Running, option.None)
pub fn state_count(machine: StateMachine(state, ctx)) -> Int
Get the number of states in the state machine.
Example
io.println("State machine has " <>
int.to_string(state_machine.state_count(machine)) <>
" states")
pub fn state_ids(
machine: StateMachine(state, ctx),
) -> List(state)
Get all state IDs in the state machine.
Useful for debugging or building UI to visualize state machines.
Example
// List all states
state_machine.state_ids(machine)
|> list.each(fn(state) {
io.println("State: " <> string.inspect(state))
})
pub fn to_playback(
output: AnimationOutput,
) -> option.Option(animation.AnimationPlayback)
Convert AnimationOutput to AnimationPlayback for use with scene.Model3D.
This helper converts the state machine’s output format to the format expected
by 3D model nodes. Use it after get_current_animation().
Example
fn view(model: Model) -> scene.Node {
let animation =
state_machine.get_current_animation(model.anim_machine)
|> state_machine.to_playback()
scene.Model3D(
id: "character",
model: model.character_model,
animation: animation, // Option(AnimationPlayback)
transform: model.transform,
physics: option.None,
)
}
pub fn transition_count(machine: StateMachine(state, ctx)) -> Int
Get the number of transitions in the state machine.
Example
io.println("State machine has " <>
int.to_string(state_machine.transition_count(machine)) <>
" transitions")
pub fn transition_to(
machine: StateMachine(state, ctx),
target: state,
blend_duration: option.Option(Float),
) -> StateMachine(state, ctx)
Manually trigger a transition to a specific state.
Bypasses all transition conditions and forces an immediate state change. Useful for external events like taking damage, dying, or cutscene triggers.
blend_duration: Optional blend time in seconds. If None, uses default blend duration.
Example
fn update(model: Model, msg: Msg, ctx: tiramisu.Context) -> Model {
case msg {
TakeDamage(_) -> {
// Force transition to hit state with fast blend
let machine =
state_machine.transition_to(
model.anim_machine,
HitReaction,
option.Some(0.1),
)
Model(..model, anim_machine: machine)
}
Die -> {
// Force transition using default blend
let machine =
state_machine.transition_to(model.anim_machine, Dead, option.None)
Model(..model, anim_machine: machine)
}
_ -> model
}
}
pub fn update(
machine: StateMachine(state, ctx),
context: ctx,
delta_time: Float,
) -> #(StateMachine(state, ctx), Bool)
Update the state machine (call every frame in your game loop).
Evaluates transition conditions and advances blend progress. Returns the updated machine and a boolean indicating if a transition occurred this frame.
delta_time: Time in milliseconds since last frame (e.g., 16.67ms for 60 FPS).
context: Your custom context type passed to Custom condition functions.
Example
fn update(model: Model, msg: Msg, ctx: tiramisu.Context) -> Model {
// Create context for state machine conditions
let game_ctx = GameContext(
velocity: model.velocity,
is_grounded: model.grounded,
)
// Update state machine
let #(new_machine, transitioned) =
state_machine.update(model.anim_machine, game_ctx, ctx.delta_time)
// Log transitions
case transitioned {
True -> io.println("State changed!")
False -> Nil
}
Model(..model, anim_machine: new_machine)
}