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

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:

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:

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

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

  • Always

    Always 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

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

Transition between two states

pub type Transition(state, ctx) {
  Transition(
    from: state,
    to: state,
    condition: Condition(ctx),
    blend_duration: Float,
  )
}

Constructors

  • Transition(
      from: state,
      to: state,
      condition: Condition(ctx),
      blend_duration: Float,
    )

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)
}
Search Document