State Machines

Implementing game state machines in Chasm using atoms and enums.

State machines are one of the most common patterns in game programming. Chasm's atoms and enums make them clean and efficient.

Atom-Based State Machine

For simple cases, an atom attribute is all you need:

@state :: script = :menu

def on_tick(dt :: float) do
  if @state == :menu do
    update_menu(dt)
  end
  if @state == :playing do
    update_game(dt)
  end
  if @state == :paused do
    update_pause(dt)
  end
  if @state == :game_over do
    update_game_over(dt)
  end
end

defp update_menu(dt :: float) do
  if key_pressed(:enter) do
    @state = :playing
  end
end

defp update_game(dt :: float) do
  move_player(dt)
  move_enemies(dt)
  if @lives <= 0 do
    @state = :game_over
  end
  if key_pressed(:escape) do
    @state = :paused
  end
end

defp update_pause(dt :: float) do
  if key_pressed(:escape) do
    @state = :playing
  end
end

Enum-Based State Machine

When the state carries data, use an enum:

enum GameState {
  Menu,
  Playing,
  Cutscene,
  GameOver
}

@gs :: script = GameState.Menu

def on_tick(dt :: float) do
  case @gs do
    when GameState.Menu     -> update_menu(dt)
    when GameState.Playing  -> update_game(dt)
    when GameState.GameOver -> update_game_over(dt)
    _                       -> 0
  end
end

case here is used as an expression, each arm calls a function and returns its result (discarded with 0 in the wildcard arm). This reads cleanly and makes it obvious which function handles which state.

Enter/Exit Actions

Sometimes you need to run code exactly once when entering or leaving a state. Track the previous state:

@state      :: script = :menu
@prev_state :: script = :none

defp transition(new_state :: atom) do
  if @state != new_state do
    on_exit(@state)
    @prev_state = @state
    @state = new_state
    on_enter(new_state)
  end
end

defp on_enter(state :: atom) do
  if state == :playing do
    @score = 0
    @lives = 3
    spawn_initial_enemies()
  end
  if state == :game_over do
    if @score > @high_score do
      @high_score = persist_copy(@score)
    end
  end
end

defp on_exit(state :: atom) do
  if state == :playing do
    @enemies.clear()
  end
end

Per-Entity State

Each entity can have its own state field:

defstruct Enemy do
  x     :: float = 0.0
  y     :: float = 0.0
  hp    :: int   = 3
  state :: atom  = :patrol
  timer :: float = 0.0
end

@enemies :: script = array_fixed(32, Enemy{})

defp update_enemy(e :: Enemy, dt :: float) :: Enemy do
  if e.state == :patrol do
    return patrol_step(e, dt)
  end
  if e.state == :chase do
    return chase_step(e, dt)
  end
  if e.state == :dead do
    return e
  end
  return e
end

def on_tick(dt :: float) do
  i = 0
  while i < @enemies.len do
    e = @enemies.get(i)
    @enemies.set(i, update_enemy(e, dt))
    i = i + 1
  end
end

Timed Transitions

Use a timer field to automatically leave a state after a duration:

defstruct Player do
  x         :: float = 0.0
  y         :: float = 0.0
  state     :: atom  = :normal
  inv_timer :: float = 0.0   # invincibility frames timer
end

@player :: script = Player{}

defp take_hit(p :: Player) :: Player do
  if p.state == :normal do
    return p with { state: :invincible, inv_timer: 1.5 }
  end
  return p
end

def on_tick(dt :: float) do
  p = @player
  if p.state == :invincible do
    p = p with { inv_timer: p.inv_timer - dt }
    if p.inv_timer <= 0.0 do
      p = p with { state: :normal, inv_timer: 0.0 }
    end
  end
  @player = p
end