marc walter

Mini game 1 + 2 = 3 (now with less time)

2026-07-04

A few years ago I ported a tiny game involving sums and differences of 1, 2 and 3 from JS to Elm.

Back then I subscribed to Time.every events to implement a countdown. Now I updated it to not need the elm/time package:
After the css animation countdown is finished, the code now listens to the animationend event, which is considered baseline widely available since 2019.

Html.Events.on "animationend" (Json.Decode.succeed Timeout)

I like this approach because I no longer need to make sure that the CSS animation has the same duration as my timeout in Elm. And I only need to tweak one file to make such a behavior change.
Fewer options to make mistakes is why I like writing programs in a statically typed functional language.

About the game: You can use either mouse, fingers, or the keyboard to enter the correct values and try to get as high a score as possible.

If this iframe is not displayed, try the game directly or open it on ellie.

Other differences to last time (than time 😉)

Just replacing the periodic time subscription with the animationend event would have been boring, so I made more changes.

Custom type

Instead of using one shared record for every state in the game, I now use only a custom type to distinguish the game states, each with their own data.

type alias OldModel =
    { view : OldView
    , score : Int
    , highscore : Int
    , questions : List ( String, Int )
    , countdown : Int
    , level : Int
    , counter : Int
    }


type OldView
    = Start
    | Question String Int
    | End String Int


type NewModel
    = Start { highscore : Int, random : Random.Seed }
    | Question { score : Int, highscore : Int, random : Random.Seed, question : String, solution : Int }
    | End { score : Int, highscore : Int, message : String, random : Random.Seed }

Before, I would have useless data, e.g. when in the view of an ended game:
No need for countdown, level or the list of questions when the game has ended.

Now, each NewModel variant only contains the data that I need for rendering that state and updating its transition to the next possible states.

Avoiding unnamed data in custom type variants

While it was quite obvious what Question String Int is, I prefer to be more explicit these days: Question { question: String, solution: Int }. Putting a label before the type helps me the most if the order is not obvious, or if I have multiple Int values. But even here, I prefer to use a record.

Generating random questions

Before, I would generate a list of random questions, and pick randomly some of them for each level.

nextQuestion : Model -> ( Model, Cmd Msg )
nextQuestion model = 
    ...
    ( model
    , Random.generate (StartLevel level) <|
        Random.List.shuffle (calculateQuestions level)
    )

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    ...
    StartLevel level questions ->
        let questions = List.take (numberOfQuestionsForLevel level) questions
    ...

Now, I store a Random seed value, that allows me to generate random values without going through the Elm runtime and creating a Cmd Msg.

nextQuestion : QuestionData -> ( Model, Cmd Msg )
nextQuestion data =
    let
        { question, solution, random } =
            let
                ( value, seed ) =
                    # generate next random value and seed
                    Random.step (Random.int 1 3) data.random
            in
            buildQuestion (String.fromInt value) value level seed
    in
    ( Question { data | question = question, solution = solution, random = random }
    , Cmd.none
    )

Same interesting parts as before

Keyboard interactions

To allow players to use the keyboard to play the game, I had to subscribe to key press events and decode the brower's result values with Json.Decode

subscriptions : Model -> Sub Msg
subscriptions model =
    Browser.Events.onKeyDown (Json.Decode.map KeyPress keyDecoder)


keyDecoder : Json.Decode.Decoder String
keyDecoder =
    Json.Decode.field "key" Json.Decode.string

Countdown animations

I used CSS animations to render the countdown, but the elm runtime was too smart and the animation was only rendered once. After the initial run, elm re-used the existing buttons and thus the animation after adding to the DOM was not playing.

Because of that I changed the type of the list from a simple unordered list to a keyed unordered list. After that new buttons were added to the DOM and the animation is rendered every time.

See the functions view/1 and btn/2.

Other options could have been:

  • Render different classes onto the buttons after user interaction
  • Render the animation using css directly
  • Render the animation using a helper utility like elm-style-animation

Saving the high score

Elm does not yet have direct access to the Web Storage API and so I had to use a port and JavaScript code to persist the highest score.

port module Main exposing (..)

--...

port storeHighscore : Int -> Cmd msg

The highscore is passed from the JavaScript code to the Elm app on start using a flag, same as an intial random number.

main : Program Flags Model Msg
main =
    Browser.document
        { view = view
        , init = init
        , update = update
        , subscriptions = subscriptions
        }


type alias Flags =
    { highscore : Int
    , random : Int
    }


init : Flags -> ( Model, Cmd Msg )
init flags =
    ( Start { highscore = flags.highscore, random = Random.initialSeed flags.random }
    , Cmd.none
    )

Download the source files or view and edit them on ellie.