Skip to content

Instantly share code, notes, and snippets.

@AngelMunoz
Created June 2, 2026 16:04
Show Gist options
  • Select an option

  • Save AngelMunoz/a86994596d694f03ba374e329b2706f2 to your computer and use it in GitHub Desktop.

Select an option

Save AngelMunoz/a86994596d694f03ba374e329b2706f2 to your computer and use it in GitHub Desktop.

User

I did not see your answer

Assistant

The user is saying they didn't see my answer. I got stuck in a loop of thinking and never actually delivered my response. Let me provide a concise, clear answer now.

Apologies for the delay. Let me give you the answer directly.

Turn-based games are a natural fit for Elmish — arguably better than real-time games. The MVU pattern is fundamentally a state machine, which is exactly what turn-based games are.

The Core Pattern: Phase-Gated Updates

Model your game phases as a DU in the model, then pattern-match on (phase, message) in update:

type Phase =
  | PlayerTurn
  | EnemyTurn
  | Animating
  | GameOver

type Msg =
  | SelectHex of int * int
  | MoveUnit of UnitId * int * int
  | Attack of UnitId * UnitId
  | EndTurn
  | AIResolved of EnemyAction
  | Tick of GameTime
  | AnimationComplete

let update msg model =
  match model.Phase, msg with
  | PlayerTurn, SelectHex(col, row) -> ...
  | PlayerTurn, MoveUnit(id, col, row) -> ...
  | PlayerTurn, EndTurn -> { model with Phase = EnemyTurn }, computeAI model
  | EnemyTurn, AIResolved action -> applyEnemyAction action model
  | Animating, Tick dt -> progressAnimation dt model
  | Animating, AnimationComplete -> advancePhase model
  | _ -> model, Cmd.none  // invalid action for current phase → ignored

The compiler enforces that every valid (phase, msg) pair is handled. Invalid combos silently no-op.

Key Framework Features That Help

Feature Turn-based use
Model as state Phase, units, grid, history — all in one immutable record
Cmd.ofAsync Offload heavy AI computation without blocking the frame
Cmd.deferNextFrame Chain multi-step resolution (attack → damage → death → next unit)
withTick Drive smooth animations between turns (unit sliding, attacks, etc.)
DispatchMode.FrameBounded Prevent re-entrant dispatch during complex turn resolution

AI as a Command

The enemy turn dispatches a command that computes the AI action and sends the result back:

let computeAI (model: Model) : Cmd<Msg> =
  Cmd.ofAsync
    (async { return AI.findBestMove model.Grid model.Units })
    AIResolved
    (fun _ -> AIResolved EnemyPass)  // fallback on error

Animations Between Turns

Use an Animating phase with Tick-driven progress. The model holds animation state:

type AnimState = {
  StartPos: Vector2
  EndPos: Vector2
  Elapsed: float32
  Duration: float32
}

// In update:
| Animating, Tick dt ->
  let anim = { model.Anim with Elapsed = model.Anim.Elapsed + float32 dt.TotalSeconds }
  if anim.Elapsed >= anim.Duration then
    { model with Phase = nextPhase model; Anim = None }, Cmd.none
  else
    { model with Anim = Some anim }, Cmd.none

Undo/Redo

Since every state transition is a pure function of (msg, model) -> model', you can maintain a history stack:

type Model = {
  // ...
  History: Model list
  Future: Model list  // for redo
}

// Push current model to history before applying a change
| PlayerTurn, MoveUnit(id, col, row) ->
  let newModel = applyMove id col row model
  { newModel with History = model :: model.History; Future = [] }, Cmd.none

| _, Undo ->
  match model.History with
  | prev :: rest -> { prev with History = rest; Future = model :: model.Future }
  | [] -> model, Cmd.none

Summary

No special framework support is needed. The Elmish architecture handles turn-based games through its core mechanics: a state machine model, pure update function, and commands for side effects. The phase-gated pattern match gives you compile-time safety that the right actions are only available at the right time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment