Week 02 of 2023

Development log of Word Snake

Implement different game variants (goals)

On by Tad Lispy

Extend the Game module to support different variants of game. The variants are enumerated in Goal type. Currently there are just two:

  1. Collect the sentence (the original game)
  2. Read instructions (Simple screen with instructions)

But the plan is to implement many more. The primary motivation is to have a nice interactive tutorial that introduces elements of game one by one (movement, collecting and growing, guessing password, fire and poop etc.).

There is a bit of tutorial, but it's just a proof of concept for now.

I also introduced some basic utilities in Tad namespace. Mostly it's for the Tad.Html.wrap function, but probably some other too. I want to publish them as a separate package and use them here, but had no time so far. So for now they are vendored.

index a0ed691..30a7184 100644
--- a/elm.json
+++ b/elm.json
@@ -13,9 +13,12 @@
=            "elm/json": "1.1.3",
=            "elm/parser": "1.1.0",
=            "elm/random": "1.0.0",
+            "elm/regex": "1.0.0",
=            "elm/svg": "1.0.1",
=            "elm/time": "1.0.0",
+            "elm-community/list-extra": "8.7.0",
=            "elm-community/maybe-extra": "5.2.0",
+            "elm-community/result-extra": "2.4.0",
=            "krisajenkins/remotedata": "6.0.1",
=            "mpizenberg/elm-pointer-events": "4.0.2",
=            "ohanhi/keyboard": "2.0.1"
index 6e079e5..6a14085 100644
--- a/src/Area.elm
+++ b/src/Area.elm
@@ -3,6 +3,7 @@ module Area exposing
=    , Position
=    , centerX
=    , centerY
+    , default
=    , edge
=    , isOutside
=    , maxX
@@ -29,6 +30,11 @@ type alias Position =
=    )
=
=
+default : Area
+default =
+    Area 16 16 ( 0, 0 )
+
+
=edge : Area -> List Position
=edge area =
=    let
index c699282..7a0056f 100644
--- a/src/Game.elm
+++ b/src/Game.elm
@@ -1,23 +1,24 @@
=port module Game exposing
=    ( Model
-    , Msg
+    , Msg(..)
=    , Outcome(..)
-    , areaView
=    , continue
=    , init
=    , outcome
-    , passwordView
=    , snakeEscaped
=    , subscriptions
=    , update
=    , view
+    , viewArea
+    , viewSentence
=    )
=
=import Area exposing (Area, Position)
=import Dict exposing (Dict)
-import Goal exposing (Goal)
+import Goal exposing (Goal(..))
=import Html exposing (Html)
=import Html.Attributes
+import Html.Events
=import Html.Events.Extra.Touch as Touch
=import Json.Decode as Decode
=import Keyboard
@@ -26,6 +27,7 @@ import Random
=import Svg exposing (Svg)
=import Svg.Attributes
=import Svg.Keyed
+import Tad.Html as Html
=import Time
=import Transformations
=
@@ -44,6 +46,7 @@ type alias Model =
=    , word : String
=    , randomness : Random.Seed
=    , swipe : Maybe Swipe
+    , countdown : Int
=    }
=
=
@@ -102,6 +105,7 @@ init flags =
=    , word = ""
=    , randomness = flags.randomness
=    , swipe = Nothing
+    , countdown = Goal.coundown flags.goal
=    }
=
=
@@ -123,6 +127,7 @@ continue goal ({ area, snake, letters } as game) =
=            }
=        , word = ""
=        , letters = clearPath snake.head snake.direction 5 letters
+        , countdown = Goal.coundown goal
=    }
=
=
@@ -176,45 +181,174 @@ scale =
=
=view : Model -> Html Msg
=view model =
-    Html.div
-        [ Html.Attributes.style "width" "100%"
-        , Html.Attributes.style "height" "100%"
-        , Html.Attributes.style "display" "flex"
-        , Html.Attributes.style "flex-direction" "column"
-        ]
-        [ passwordView model
-        , areaView model
+    let
+        content : List (Html Msg)
+        content =
+            case model.goal of
+                ReadInstructions instructions ->
+                    [ viewInstructions instructions ]
+
+                CompleteSentence sentence collected ->
+                    [ viewSentence sentence collected
+                    , viewArea model
+                    , viewCurtain model.countdown model
+                    ]
+    in
+    content
+        |> Html.div
+            [ Html.Attributes.style "width" "100%"
+            , Html.Attributes.style "height" "100%"
+            , Html.Attributes.style "display" "flex"
+            , Html.Attributes.style "justify-content" "center"
+            , Html.Attributes.style "align-items" "center"
+            , Html.Attributes.style "flex-direction" "column"
+            ]
+
+
+viewCurtain : Int -> Model -> Html Msg
+viewCurtain countdown game =
+    let
+        overlay elements =
+            elements
+                |> Html.div
+                    [ Html.Attributes.style "position" "absolute"
+                    , Html.Attributes.style "top" "0"
+                    , Html.Attributes.style "bottom" "0"
+                    , Html.Attributes.style "left" "0"
+                    , Html.Attributes.style "right" "0"
+                    , Html.Attributes.style "display" "flex"
+                    , Html.Attributes.style "justify-content" "center"
+                    , Html.Attributes.style "background" "hsla(0,0%,0%,80%)"
+                    , Html.Attributes.style "animation-name" "overlay"
+                    , Html.Attributes.style "animation-duration" "1s"
+                    , Html.Attributes.style "animation-delay" "1s"
+                    , Html.Attributes.style "animation-iteration-count" "1"
+                    , Html.Attributes.style "animation-fill-mode" "both"
+                    ]
+    in
+    case outcome game of
+        InProgress ->
+            if countdown == 0 then
+                Html.div [] []
+
+            else
+                countdown
+                    |> String.fromInt
+                    |> Html.text
+                    |> List.singleton
+                    |> Html.div
+                        [ Html.Attributes.style "font-size" "10rem"
+                        , Html.Attributes.style "font-weight" "bold"
+                        , Html.Attributes.style "color" "white"
+                        , Html.Attributes.style "align-self" "center"
+                        , Html.Attributes.style "animation-name" "countdown"
+                        , Html.Attributes.style "animation-duration" "1s"
+                        , Html.Attributes.style "animation-delay" "0.1s"
+                        , Html.Attributes.style "animation-iteration-count" "infinite"
+                        ]
+                    |> List.singleton
+                    |> overlay
+
+        Lost ->
+            [ Html.h2
+                [ Html.Attributes.style "font-size" "2em"
+                ]
+                [ Html.text "☠️" ]
+            , Html.p [] [ Html.text "Oh, no! Your snake is dead." ]
+            , playAgainButton
+            ]
+                |> Html.div
+                    [ Html.Attributes.style "font-size" "2rem"
+                    , Html.Attributes.style "font-weight" "bold"
+                    , Html.Attributes.style "color" "white"
+                    , Html.Attributes.style "text-align" "center"
+                    , Html.Attributes.style "align-self" "center"
+                    ]
+                |> List.singleton
+                |> overlay
+
+        Won ->
+            [ Html.h2
+                [ Html.Attributes.style "font-size" "2em"
+                ]
+                [ Html.text "🙏" ]
+            , Html.p [] [ Html.text "Bravo! The snake is safe." ]
+            , playAgainButton
+            ]
+                |> Html.div
+                    [ Html.Attributes.style "font-size" "2rem"
+                    , Html.Attributes.style "font-weight" "bold"
+                    , Html.Attributes.style "color" "white"
+                    , Html.Attributes.style "text-align" "center"
+                    , Html.Attributes.style "align-self" "center"
+                    ]
+                |> List.singleton
+                |> overlay
+
+
+playAgainButton : Html Msg
+playAgainButton =
+    Html.button
+        [ Html.Events.onClick ContinueButtonClicked
+        , Html.Attributes.autofocus True
+        , Html.Attributes.style "height" "8rem"
+        , Html.Attributes.style "width" "8rem"
+        , Html.Attributes.style "font-size" "1.2rem"
+        , Html.Attributes.style "font-weight" "bold"
+        , Html.Attributes.autofocus True
=        ]
+        [ Html.text "Play again" ]
+
+
+viewInstructions : List (Html Never) -> Html Msg
+viewInstructions instructions =
+    [ instructions |> Html.div [] |> Html.map NoOp
+    , "Continue"
+        |> Html.text
+        |> Html.wrap Html.button
+            [ Html.Events.onClick ContinueButtonClicked
+            , Html.Attributes.style "align-self" "end"
+            , Html.Attributes.style "padding" "1rem 3rem"
+            ]
+    ]
+        |> Html.div
+            [ Html.Attributes.style "padding" "1rem"
+                  , Html.Attributes.style "width" "100%"
+            , Html.Attributes.style "max-width" "48rem"
+            , Html.Attributes.style "display" "flex"
+            , Html.Attributes.style "flex-direction" "column"
+                , Html.Attributes.style "gap" "2rem"
+            ]
=
=
-passwordView : Model -> Html msg
-passwordView model =
+viewSentence : Goal.Sentence -> String -> Html msg
+viewSentence sentence collected =
=    Html.h1
=        [ Html.Attributes.style "text-align" "center"
=        , Html.Attributes.style "font-size" "1rem"
=        ]
-        [ Html.text model.goal.intro
+        [ Html.text sentence.intro
=        , Html.span
=            [ Html.Attributes.style "background" <|
-                if passwordCollected model then
+                if passwordCollected collected sentence then
=                    "lightgreen"
=
=                else
=                    "lightyellow"
=            , Html.Attributes.style "padding" "0.2em"
=            ]
-            [ if String.isEmpty model.word then
+            [ if String.isEmpty collected then
=                Html.text "..."
=
=              else
-                Html.text model.word
+                Html.text collected
=            ]
-        , Html.text model.goal.outro
+        , Html.text sentence.outro
=        ]
=
=
-areaView : Model -> Html Msg
-areaView model =
+viewArea : Model -> Html Msg
+viewArea model =
=    [ lettersView model.letters
=    , snakeView model.snake
=    ]
@@ -410,17 +544,29 @@ letterView ( ( x, y ), letter ) =
=
=
=type Msg
-    = Step Time.Posix
+    = NoOp Never
+    | CountdownClockTick Time.Posix
+    | Step Time.Posix
=    | KeyPressed (Maybe Keyboard.Key)
=    | TouchStarted Touch.Event
=    | TouchMoved Touch.Event
=    | TouchEnded Touch.Event
=    | TouchCanceled Touch.Event
+    | ContinueButtonClicked
=
=
=update : Msg -> Model -> ( Model, Cmd Msg )
=update msg model =
=    case msg of
+        NoOp _ ->
+            -- Handle the Never from Instructions
+            ( model, Cmd.none )
+
+        CountdownClockTick _ ->
+            ( { model | countdown = model.countdown - 1 }
+            , Cmd.none
+            )
+
=        Step _ ->
=            let
=                direction : Direction
@@ -433,14 +579,18 @@ update msg model =
=                    model.letters
=                        |> randomDecay 0.01
=                        |> Random.andThen
-                            (randomSpawn 0.5
+                            (randomSpawn
=                                model.area
-                                needed
-                                model.goal.charset
+                                model.goal
=                                model.snake
=                            )
=                        |> (\generator -> Random.step generator model.randomness)
=
+                goal : Goal
+                goal =
+                    model.goal
+                        |> updateGoal model.snake model.letters
+
=                snake : Snake
=                snake =
=                    model.snake
@@ -448,37 +598,23 @@ update msg model =
=                        |> updateSnake letters
=                        |> moveSnake
=
-                needed : List Char
-                needed =
-                    model.goal.password
-                        |> String.toUpper
-                        |> String.toList
-                        |> List.drop (String.length model.word)
-
-                wanted : Char
-                wanted =
-                    needed
-                        |> List.head
-                        |> Maybe.withDefault ' '
-
-                word : String
-                word =
-                    model.word
-                        |> updateWord wanted model.snake letters
-                        |> String.toUpper
-
=                burn : Bool
=                burn =
-                    model |> passwordCollected |> not
+                    case model.goal of
+                        CompleteSentence _ _ ->
+                            Goal.complete goal |> not
+
+                        _ ->
+                            False
=            in
=            ( { model
-                | letters =
+                | goal = goal
+                , letters =
=                    letters
-                        |> updateLetters wanted model.snake
+                        |> updateLetters model.goal model.snake
=                        |> burnTheEdge model.area burn
=                , randomness = randomness
=                , snake = snake
-                , word = word
=                , swipe = model.swipe |> Maybe.map (\swipe -> { swipe | from = swipe.to })
=              }
=            , Cmd.none
@@ -666,13 +802,36 @@ update msg model =
=            , Cmd.none
=            )
=
+        ContinueButtonClicked ->
+            -- This Msg is handled in the parent program, see comment in Main.elm
+            ( model
+            , Cmd.none
+            )
+
+
+updateGoal : Snake -> Letters -> Goal -> Goal
+updateGoal snake letters goal =
+    case goal of
+        CompleteSentence sentence collected ->
+            case Dict.get snake.head letters of
+                Nothing ->
+                    goal
+
+                Just letter ->
+                    collected
+                        |> Goal.collectLetter letter sentence
+                        |> CompleteSentence sentence
+
+        ReadInstructions _ ->
+            goal
+
=
=
=-- SUBSCRIPTIONS
=
=
=subscriptions : Model -> Sub Msg
-subscriptions _ =
+subscriptions model =
=    let
=        decodeKeydown : Decode.Value -> Msg
=        decodeKeydown event =
@@ -681,8 +840,20 @@ subscriptions _ =
=                |> Result.toMaybe
=                |> Maybe.andThen Keyboard.anyKeyUpper
=                |> KeyPressed
+
+        clock =
+            case model.goal of
+                CompleteSentence _ _ ->
+                    if model.countdown == 0 then
+                        Time.every 250 Step
+
+                    else
+                        Time.every 1000 CountdownClockTick
+
+                ReadInstructions _ ->
+                    Sub.none
=    in
-    [ Time.every 250 Step
+    [ clock
=    , keydown decodeKeydown
=    ]
=        |> Sub.batch
@@ -730,25 +901,8 @@ updateSnake letters snake =
=            }
=
=
-updateWord : Char -> Snake -> Letters -> String -> String
-updateWord wanted snake letters word =
-    case Dict.get snake.head letters of
-        Nothing ->
-            word
-
-        Just letter ->
-            if letter == wanted then
-                word
-                    |> String.reverse
-                    |> String.cons letter
-                    |> String.reverse
-
-            else
-                word
-
-
-updateLetters : Char -> Snake -> Letters -> Letters
-updateLetters wanted snake letters =
+updateLetters : Goal -> Snake -> Letters -> Letters
+updateLetters goal snake letters =
=    case Dict.get snake.head letters of
=        Nothing ->
=            letters
@@ -757,13 +911,25 @@ updateLetters wanted snake letters =
=            letters
=
=        Just letter ->
-            if letter == wanted then
-                Dict.remove snake.head letters
+            case goal of
+                CompleteSentence sentence collected ->
+                    let
+                        wanted : Maybe Char
+                        wanted =
+                            collected
+                                |> Goal.missingLetters sentence
+                                |> List.head
+                    in
+                    if wanted == Just letter then
+                        Dict.remove snake.head letters
=
-            else
-                letters
-                    |> Dict.remove snake.head
-                    |> Dict.insert snake.tail '💩'
+                    else
+                        letters
+                            |> Dict.remove snake.head
+                            |> Dict.insert snake.tail '💩'
+
+                ReadInstructions _ ->
+                    letters
=
=
=
@@ -778,19 +944,26 @@ type Outcome
=
=outcome : Model -> Outcome
=outcome model =
-    if snakeEscaped model then
-        Won
+    case model.goal of
+        CompleteSentence collected sentence ->
+            if snakeEscaped model then
+                -- TODO: Check if sentence is collected?
+                Won
=
-    else if model.snake.alive then
-        InProgress
+            else if model.snake.alive then
+                InProgress
=
-    else
-        Lost
+            else
+                Lost
=
+        ReadInstructions _ ->
+            -- This is always a win
+            Won
=
-passwordCollected : Model -> Bool
-passwordCollected model =
-    String.toUpper model.word == String.toUpper model.goal.password
+
+passwordCollected : String -> Goal.Sentence -> Bool
+passwordCollected collected sentence =
+    String.toUpper collected == String.toUpper sentence.password
=
=
=snakeEscaped : Model -> Bool
@@ -970,35 +1143,44 @@ randomBoolean probability =
=
=
=randomSpawn :
-    Float
-    -> Area
-    -> List Char
-    -> Goal.Charset
+    Area
+    -> Goal
=    -> Snake
=    -> Letters
=    -> Random.Generator Letters
-randomSpawn probability area needed charset snake letters =
-    let
-        perform : Bool -> Position -> Char -> Letters
-        perform spawn position letter =
-            if
-                spawn
-                    && position
-                    /= snake.head
-                    && not (List.member position snake.body)
-            then
-                Dict.insert position letter letters
+randomSpawn area goal snake letters =
+    case goal of
+        CompleteSentence sentence collected ->
+            let
+                probability =
+                    0.5
=
-            else
-                letters
-    in
-    Random.map3 perform
-        (randomBoolean probability)
-        (Area.randomPosition area)
-        (randomLetter needed charset)
+                needed =
+                    Goal.missingLetters sentence collected
+
+                perform : Bool -> Position -> Char -> Letters
+                perform spawn position letter =
+                    if
+                        spawn
+                            && position
+                            /= snake.head
+                            && not (List.member position snake.body)
+                    then
+                        Dict.insert position letter letters
+
+                    else
+                        letters
+            in
+            Random.map3 perform
+                (randomBoolean probability)
+                (Area.randomPosition area)
+                (randomLetter needed sentence.charset)
+
+        ReadInstructions _ ->
+            Random.constant Dict.empty
=
=
-randomDecay : Float -> Dict comparable v -> Random.Generator (Dict comparable v)
+randomDecay : Float -> Letters -> Random.Generator Letters
=randomDecay probability dict =
=    Random.list (Dict.size dict) (randomBoolean probability)
=        |> Random.map (\decayList -> decay decayList dict)
index dff83c4..f39a73c 100644
--- a/src/Goal.elm
+++ b/src/Goal.elm
@@ -1,10 +1,59 @@
-module Goal exposing (Charset, Goal, serialize)
+module Goal exposing
+    ( Charset
+    , Goal(..)
+    , Sentence
+    , collectLetter
+    , complete
+    , coundown
+    , missingLetters
+    , nextLetter
+    , serialize
+    )
=
+import Html exposing (Html)
=
--- type Goal =
---     CompleteSentence String Sentence
---
-type alias Goal =
+
+type Goal
+    = CompleteSentence Sentence String
+    | ReadInstructions (List (Html Never))
+
+
+complete : Goal -> Bool
+complete goal =
+    case goal of
+        CompleteSentence sentence collected ->
+            String.toUpper collected == String.toUpper sentence.password
+
+        ReadInstructions _ ->
+            True
+
+
+serialize : Goal -> String
+serialize goal =
+    case goal of
+        CompleteSentence sentence _ ->
+            [ "< "
+            , sentence.charset
+                |> List.map String.fromChar
+                |> String.join " "
+            , " > "
+            , sentence.intro
+            , "{ "
+            , sentence.password
+            , " }"
+            , sentence.outro
+            ]
+                |> String.join ""
+
+        ReadInstructions _ ->
+            ""
+
+
+
+-- COMPLETE THE SENTENCE
+
+
+type alias Sentence =
=    { charset : Charset
=    , intro : String
=    , password : String
@@ -16,17 +65,43 @@ type alias Charset =
=    List Char
=
=
-serialize : Goal -> String
-serialize goal =
-    [ "< "
-    , goal.charset
-        |> List.map String.fromChar
-        |> String.join " "
-    , " > "
-    , goal.intro
-    , "{ "
-    , goal.password
-    , " }"
-    , goal.outro
-    ]
-        |> String.join ""
+missingLetters : Sentence -> String -> List Char
+missingLetters sentence collected =
+    sentence.password
+        |> String.toUpper
+        |> String.toList
+        |> List.drop (String.length collected)
+
+
+collectLetter : Char -> Sentence -> String -> String
+collectLetter letter sentence collected =
+    if isNextLetter letter sentence collected then
+        collected
+            |> String.reverse
+            |> String.cons letter
+            |> String.reverse
+
+    else
+        collected
+
+
+isNextLetter : Char -> Sentence -> String -> Bool
+isNextLetter letter sentence collected =
+    nextLetter sentence collected == Just letter
+
+
+nextLetter : Sentence -> String -> Maybe Char
+nextLetter sentence collected =
+    collected
+        |> missingLetters sentence
+        |> List.head
+
+
+coundown : Goal -> Int
+coundown goal =
+    case goal of
+        CompleteSentence _ _ ->
+            5
+
+        ReadInstructions _ ->
+            0
index 0e2ec07..c9ea1a6 100644
--- a/src/Main.elm
+++ b/src/Main.elm
@@ -1,20 +1,28 @@
-module Main exposing (Model, Msg, init, main, subscriptions, update, view)
+module Main exposing
+    ( Model
+    , Msg
+    , init
+    , main
+    , subscriptions
+    , update
+    , view
+    )
=
-import Area exposing (Area)
+import Area
=import Browser
=import Dict exposing (Dict)
=import Game exposing (update)
+import Goal exposing (Goal)
=import Html exposing (Html)
=import Html.Attributes
=import Html.Events
=import Http
-import Goal exposing (Goal)
=import Parser
=import Parser.Custom as Parser
=import Ports
=import Random
=import RemoteData exposing (WebData)
-import Time
+import Tad.Html as Html
=import Tutorial
=
=
@@ -32,14 +40,13 @@ type alias Model =
=    { stage : Stage
=    , levels : WebData (List Goal)
=    , outcomes : Outcomes
-    , countdown : Int
=    }
=
=
=type Stage
=    = Menu
=    | Playing Game.Model
-    | Learning Tutorial.Model
+    | Learning (List Goal) Game.Model
=
=
=type alias Outcomes =
@@ -71,7 +78,6 @@ init flags =
=                        else
=                            Game.Lost
=                    )
-      , countdown = 0
=      }
=    , fetchLevels GotLevelsResponse flags.levels_url
=    )
@@ -110,46 +116,59 @@ view model =
=            viewMenu
=
=        Playing game ->
-            [ viewHeader game
-            , viewGame game
-            , viewFooter model.outcomes
-            , viewCurtain model.countdown game
-            ]
-                |> Html.div
-                    [ Html.Attributes.style "height" "100%"
-                    , Html.Attributes.style "width" "100%"
-                    , Html.Attributes.style "display" "flex"
-                    , Html.Attributes.style "flex-direction" "column"
-                    ]
+            viewPlaying game model.outcomes
=
-        Learning tutorial ->
-            tutorial
-                |> Tutorial.view
-                |> Html.map GotTutorialMsg
+        Learning goals game ->
+            viewLearning goals game
=
=
-viewHeader : Game.Model -> Html Msg
-viewHeader game =
-    game
-        |> Game.passwordView
-        |> Html.map GotGameMsg
-        |> List.singleton
+viewLearning : List Goal -> Game.Model -> Html Msg
+viewLearning remaining game =
+    [ game |> Game.view |> Html.map GotGameMsg
+    , viewLearningFooter remaining
+    ]
=        |> Html.div
-            [ Html.Attributes.style "background" "hsl(0, 0%, 86%)"
-            , Html.Attributes.style "box-shadow" "0px 0px 6px 2px hsla(0, 0%, 0%, 20%)"
-            , Html.Attributes.style "z-index" "1"
+            [ Html.Attributes.style "height" "100%"
+            , Html.Attributes.style "width" "100%"
+            , Html.Attributes.style "display" "flex"
+            , Html.Attributes.style "flex-direction" "column"
=            ]
=
=
-viewGame : Game.Model -> Html Msg
-viewGame game =
-    game
-        |> Game.areaView
-        |> Html.map GotGameMsg
+viewLearningFooter : List Goal -> Html Msg
+viewLearningFooter remaining =
+    Html.div
+        [ Html.Attributes.style "display" "flex"
+        , Html.Attributes.style "justify-content" "space-around"
+        , Html.Attributes.style "width" "100%"
+        , Html.Attributes.style "padding" "1em 0"
+        , Html.Attributes.style "background" "hsl(0, 0%, 86%)"
+        , Html.Attributes.style "box-shadow" "0px 0px 6px 2px hsla(0, 0%, 0%, 20%)"
+        , Html.Attributes.style "z-index" "1"
+        ]
+        [ [ remaining |> List.length |> String.fromInt
+          , " lessons remaining."
+          ]
+            |> List.map Html.text
+            |> Html.p []
+        ]
=
=
-viewFooter : Outcomes -> Html msg
-viewFooter outcomes =
+viewPlaying : Game.Model -> Outcomes -> Html Msg
+viewPlaying game outcomes =
+    [ game |> Game.view |> Html.map GotGameMsg
+    , viewOutcomesFooter outcomes
+    ]
+        |> Html.div
+            [ Html.Attributes.style "height" "100%"
+            , Html.Attributes.style "width" "100%"
+            , Html.Attributes.style "display" "flex"
+            , Html.Attributes.style "flex-direction" "column"
+            ]
+
+
+viewOutcomesFooter : Outcomes -> Html msg
+viewOutcomesFooter outcomes =
=    let
=        { killed, saved } =
=            score outcomes
@@ -178,101 +197,6 @@ viewFooter outcomes =
=        ]
=
=
-viewCurtain : Int -> Game.Model -> Html Msg
-viewCurtain countdown game =
-    let
-        overlay elements =
-            elements
-                |> Html.div
-                    [ Html.Attributes.style "position" "absolute"
-                    , Html.Attributes.style "top" "0"
-                    , Html.Attributes.style "bottom" "0"
-                    , Html.Attributes.style "left" "0"
-                    , Html.Attributes.style "right" "0"
-                    , Html.Attributes.style "display" "flex"
-                    , Html.Attributes.style "justify-content" "center"
-                    , Html.Attributes.style "background" "hsla(0,0%,0%,80%)"
-                    , Html.Attributes.style "animation-name" "overlay"
-                    , Html.Attributes.style "animation-duration" "1s"
-                    , Html.Attributes.style "animation-delay" "1s"
-                    , Html.Attributes.style "animation-iteration-count" "1"
-                    , Html.Attributes.style "animation-fill-mode" "both"
-                    ]
-    in
-    case Game.outcome game of
-        Game.InProgress ->
-            if countdown == 0 then
-                Html.div [] []
-
-            else
-                countdown
-                    |> String.fromInt
-                    |> Html.text
-                    |> List.singleton
-                    |> Html.div
-                        [ Html.Attributes.style "font-size" "10rem"
-                        , Html.Attributes.style "font-weight" "bold"
-                        , Html.Attributes.style "color" "white"
-                        , Html.Attributes.style "align-self" "center"
-                        , Html.Attributes.style "animation-name" "countdown"
-                        , Html.Attributes.style "animation-duration" "1s"
-                        , Html.Attributes.style "animation-delay" "0.1s"
-                        , Html.Attributes.style "animation-iteration-count" "infinite"
-                        ]
-                    |> List.singleton
-                    |> overlay
-
-        Game.Lost ->
-            [ Html.h2
-                [ Html.Attributes.style "font-size" "2em"
-                ]
-                [ Html.text "☠️" ]
-            , Html.p [] [ Html.text "Oh, no! Your snake is dead." ]
-            , playAgainButton
-            ]
-                |> Html.div
-                    [ Html.Attributes.style "font-size" "2rem"
-                    , Html.Attributes.style "font-weight" "bold"
-                    , Html.Attributes.style "color" "white"
-                    , Html.Attributes.style "text-align" "center"
-                    , Html.Attributes.style "align-self" "center"
-                    ]
-                |> List.singleton
-                |> overlay
-
-        Game.Won ->
-            [ Html.h2
-                [ Html.Attributes.style "font-size" "2em"
-                ]
-                [ Html.text "🙏" ]
-            , Html.p [] [ Html.text "Bravo! The snake is safe." ]
-            , playAgainButton
-            ]
-                |> Html.div
-                    [ Html.Attributes.style "font-size" "2rem"
-                    , Html.Attributes.style "font-weight" "bold"
-                    , Html.Attributes.style "color" "white"
-                    , Html.Attributes.style "text-align" "center"
-                    , Html.Attributes.style "align-self" "center"
-                    ]
-                |> List.singleton
-                |> overlay
-
-
-playAgainButton : Html Msg
-playAgainButton =
-    Html.button
-        [ Html.Events.onClick PlayAgainButtonClicked
-        , Html.Attributes.autofocus True
-        , Html.Attributes.style "height" "8rem"
-        , Html.Attributes.style "width" "8rem"
-        , Html.Attributes.style "font-size" "1.2rem"
-        , Html.Attributes.style "font-weight" "bold"
-        , Html.Attributes.autofocus True
-        ]
-        [ Html.text "Play again" ]
-
-
=viewMenu : Html Msg
=viewMenu =
=    Html.div
@@ -290,7 +214,7 @@ viewMenu =
=            , Html.Attributes.style "width" "20rem"
=            , Html.Attributes.style "margin" "auto"
=            ]
-              [ Html.h1 [] [ Html.text "Word Snake" ]
+            [ Html.h1 [] [ Html.text "Word Snake" ]
=            , Html.img
=                [ Html.Attributes.src "/icon.svg"
=                , Html.Attributes.style "width" "8rem"
@@ -354,12 +278,9 @@ viewMenu =
=type Msg
=    = GotLevelsResponse (Result Http.Error (List Goal))
=    | StartButtonClicked
-    | CountdownClockTick Time.Posix
+    | TutorialButtonClicked
=    | GotGameMsg Game.Msg
=    | GotRandomGameFlags (List Goal) Random.Seed
-    | PlayAgainButtonClicked
-    | TutorialButtonClicked
-    | GotTutorialMsg Tutorial.Msg
=
=
=update : Msg -> Model -> ( Model, Cmd Msg )
@@ -385,12 +306,26 @@ update msg model =
=                ]
=            )
=
-        CountdownClockTick _ ->
-            ( { model | countdown = model.countdown - 1 }
-            , Cmd.none
+        TutorialButtonClicked ->
+            ( { model
+                | stage =
+                    Game.init
+                        { area = Area.default
+                        , goal = Tutorial.intro
+
+                        -- TODO: Do we need randomness in tutorial?
+                        , randomness = Random.initialSeed 0
+                        }
+                        |> Learning Tutorial.goals
+              }
+            , Cmd.batch
+                [ Ports.fullscreen ()
+                ]
=            )
=
-        PlayAgainButtonClicked ->
+        GotGameMsg Game.ContinueButtonClicked ->
+            -- TODO: Find a more elegant way to signal the intention of
+            -- progressing the game, that doesn't leak internal Msg variants
=            case model.stage of
=                Menu ->
=                    -- TODO: Can't happen. Report an error?
@@ -434,11 +369,53 @@ update msg model =
=                                |> Cmd.batch
=                            )
=
-                Learning _ ->
-                    -- TODO: Can't happen. Report an error?
-                    ( model
-                    , Cmd.none
-                    )
+                Learning goals game ->
+                    case Game.outcome game |> Debug.log "Outcome" of
+                        Game.Won ->
+                            case goals of
+                                [] ->
+                                    -- No more tutorials, time to play the game!
+                                    case RemoteData.toMaybe model.levels of
+                                        Nothing ->
+                                            -- TODO: This should not happen. Report an error?
+                                            ( { model | stage = Menu }
+                                            , Cmd.none
+                                            )
+
+                                        Just levels ->
+                                            ( model
+                                            , [ generateRandomGameFlags GotRandomGameFlags levels
+                                              , Ports.goalTracking ( "APLYXOZD", 1 )
+                                              , Ports.fullscreen ()
+                                              ]
+                                                |> Cmd.batch
+                                            )
+
+                                nextGoal :: futureGoals ->
+                                    ( { model
+                                        | stage =
+                                            game
+                                                |> Game.continue nextGoal
+                                                |> Learning futureGoals
+                                      }
+                                    , Cmd.none
+                                    )
+
+                        Game.Lost ->
+                            ( { model
+                                | stage =
+                                    game
+                                        |> Game.continue game.goal
+                                        |> Learning goals
+                              }
+                            , Cmd.none
+                            )
+
+                        Game.InProgress ->
+                            -- TODO: This should not happen. Report an error?
+                            ( model
+                            , Cmd.none
+                            )
=
=        GotGameMsg gameMsg ->
=            case model.stage of
@@ -468,13 +445,23 @@ update msg model =
=                        |> Cmd.batch
=                    )
=
-                Learning tutorial ->
-                    -- TODO: Can't happen. Report an error?
-                    ( model, Cmd.none )
+                Learning goals game ->
+                    let
+                        ( updatedGame, gameCmd ) =
+                            Game.update gameMsg game
+                    in
+                    ( { model
+                        | stage = Learning goals updatedGame
+                      }
+                    , [ Cmd.map GotGameMsg gameCmd
+                      ]
+                        |> Cmd.batch
+                    )
=
=        GotRandomGameFlags levels randomness ->
=            case levels of
=                [] ->
+                    -- TODO: This should never happen. Report an error.
=                    ( model
=                    , Cmd.none
=                    )
@@ -484,11 +471,10 @@ update msg model =
=                        Menu ->
=                            ( { model
=                                | levels = RemoteData.Success levels
-                                , countdown = 5
=                                , stage =
=                                    { goal = level
=                                    , randomness = randomness
-                                    , area = Area 16 16 ( 0, 0 )
+                                    , area = Area.default
=                                    }
=                                        |> Game.init
=                                        |> Playing
@@ -499,7 +485,6 @@ update msg model =
=                        Playing game ->
=                            ( { model
=                                | levels = RemoteData.Success levels
-                                , countdown = 5
=                                , stage =
=                                    game
=                                        |> Game.continue level
@@ -508,38 +493,16 @@ update msg model =
=                            , Cmd.none
=                            )
=
-                        Learning tutorial ->
-                            -- TODO: Can't happen. Report an error?
-                            ( model, Cmd.none )
-
-        TutorialButtonClicked ->
-            ( { model
-                | stage =
-                    Tutorial.init |> Learning
-              }
-            , Cmd.none
-            )
-
-        GotTutorialMsg tutorialMsg ->
-            case model.stage of
-                Menu ->
-                    -- TODO: Can't happen. Report an error?
-                    ( model, Cmd.none )
-
-                Playing game ->
-                    -- TODO: Can't happen. Report an error?
-                    ( model, Cmd.none )
-
-                Learning tutorial ->
-                    let
-                        ( updatedTutorial, tutorialCmd ) =
-                            Tutorial.update tutorialMsg tutorial
-                    in
-                    ( { model
-                        | stage = Learning updatedTutorial
-                      }
-                    , Cmd.map GotTutorialMsg tutorialCmd
-                    )
+                        Learning _ game ->
+                            ( { model
+                                | levels = RemoteData.Success levels
+                                , stage =
+                                    game
+                                        |> Game.continue level
+                                        |> Playing
+                              }
+                            , Cmd.none
+                            )
=
=
=
@@ -553,18 +516,14 @@ subscriptions model =
=            Sub.none
=
=        Playing game ->
-            if model.countdown == 0 then
-                game
-                    |> Game.subscriptions
-                    |> Sub.map GotGameMsg
-
-            else
-                Time.every 1000 CountdownClockTick
-
-        Learning tutorial ->
-            tutorial
-                |> Tutorial.subscriptions
-                |> Sub.map GotTutorialMsg
+            game
+                |> Game.subscriptions
+                |> Sub.map GotGameMsg
+
+        Learning _ game ->
+            game
+                |> Game.subscriptions
+                |> Sub.map GotGameMsg
=
=
=
index d175579..f9b56f2 100644
--- a/src/Parser/Custom.elm
+++ b/src/Parser/Custom.elm
@@ -1,7 +1,18 @@
-module Parser.Custom exposing (charset, intro, letter, levels, line, lines, outro, password)
+module Parser.Custom exposing
+    ( charset
+    , intro
+    , letter
+    , levels
+    , line
+    , lines
+    , outro
+    , password
+    )
=
=import Goal exposing (Charset, Goal)
=import Parser exposing ((|.), (|=), Parser)
+import Goal exposing (Goal)
+import Goal exposing (Goal(..))
=
=
=levels : Parser (List Goal)
@@ -67,7 +78,7 @@ letter =
=
=line : Charset -> Parser Goal
=line set =
-    Parser.succeed (Goal set)
+    Parser.succeed (Goal.Sentence set)
=        |= intro
=        |. Parser.symbol "{"
=        |. Parser.spaces
@@ -75,6 +86,8 @@ line set =
=        |. Parser.spaces
=        |. Parser.symbol "}"
=        |= outro
+        |> Parser.map (CompleteSentence)
+        |> Parser.map (\constructor -> constructor "")
=
=
=intro : Parser String
new file mode 100644
index 0000000..0dcfb75
--- /dev/null
+++ b/src/Tad/Html.elm
@@ -0,0 +1,50 @@
+{- The Tad.Html Elm module
+
+   Copyright (C) 2021 Tad Lispy
+
+   This library is free software: you can redistribute it and/or modify it under
+   the terms of the GNU Lesser General Public License as published by the Free
+   Software Foundation, either version 3 of the License, or (at your option) any
+   later version.
+
+   This library is distributed in the hope that it will be useful, but WITHOUT
+   ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+   FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
+   details.
+
+   You should have received a copy of the GNU General Public License along with
+   this library.  If not, see <https://www.gnu.org/licenses/>.
+-}
+
+
+module Tad.Html exposing (textInput, wrap)
+
+import Html exposing (Html)
+import Html.Attributes
+import Html.Events
+
+
+wrap :
+    (List (Html.Attribute msg) -> List (Html msg) -> Html msg)
+    -> List (Html.Attribute msg)
+    -> Html msg
+    -> Html msg
+wrap wrapper attributes element =
+    element
+        |> List.singleton
+        |> wrapper attributes
+
+
+textInput : String -> (String -> msg) -> String -> Html msg
+textInput label tag value =
+    Html.label []
+        [ label
+            |> Html.text
+        , Html.input
+            [ Html.Attributes.type_ "text"
+            , Html.Attributes.placeholder label
+            , Html.Attributes.value value
+            , Html.Events.onInput tag
+            ]
+            []
+        ]
new file mode 100644
index 0000000..a430821
--- /dev/null
+++ b/src/Tad/Http.elm
@@ -0,0 +1,49 @@
+{- The Tad.Http Elm module
+
+   Copyright (C) 2021 Tad Lispy
+
+   This library is free software: you can redistribute it and/or modify it under
+   the terms of the GNU Lesser General Public License as published by the Free
+   Software Foundation, either version 3 of the License, or (at your option) any
+   later version.
+
+   This library is distributed in the hope that it will be useful, but WITHOUT
+   ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+   FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
+   details.
+
+   You should have received a copy of the GNU General Public License along with
+   this library.  If not, see <https://www.gnu.org/licenses/>.
+-}
+
+
+module Tad.Http exposing (..)
+
+import Http
+
+
+explainError : Http.Error -> String
+explainError error =
+    case error of
+        Http.BadUrl url ->
+            [ "This URL is invalid: "
+            , "<"
+            , url
+            , ">"
+            ]
+                |> String.join ""
+
+        Http.Timeout ->
+            "Request timed out"
+
+        Http.NetworkError ->
+            "There was a network error"
+
+        Http.BadStatus status ->
+            [ "The response status is not good: "
+            , status |> String.fromInt
+            ]
+                |> String.join ""
+
+        Http.BadBody explanation ->
+            explanation
new file mode 100644
index 0000000..31ebed0
--- /dev/null
+++ b/src/Tad/List.elm
@@ -0,0 +1,44 @@
+{- The Tad.List Elm module
+
+   Copyright (C) 2021 Tad Lispy
+
+   This library is free software: you can redistribute it and/or modify it under
+   the terms of the GNU Lesser General Public License as published by the Free
+   Software Foundation, either version 3 of the License, or (at your option) any
+   later version.
+
+   This library is distributed in the hope that it will be useful, but WITHOUT
+   ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+   FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
+   details.
+
+   You should have received a copy of the GNU General Public License along with
+   this library.  If not, see <https://www.gnu.org/licenses/>.
+-}
+
+
+module Tad.List exposing (stableSort, stableSortBy)
+
+import List.Extra as List
+
+
+stableSort : List comparable -> List comparable
+stableSort items =
+    List.stableSortWith
+        (\itemA itemB ->
+            compare
+                itemA
+                itemB
+        )
+        items
+
+
+stableSortBy : (a -> comparable) -> List a -> List a
+stableSortBy toComparable items =
+    List.stableSortWith
+        (\itemA itemB ->
+            compare
+                (toComparable itemA)
+                (toComparable itemB)
+        )
+        items
new file mode 100644
index 0000000..5db4f29
--- /dev/null
+++ b/src/Tad/Parser.elm
@@ -0,0 +1,99 @@
+{- The Tad.Parser Elm module
+
+   Copyright (C) 2021 Tad Lispy
+
+   This library is free software: you can redistribute it and/or modify it under
+   the terms of the GNU Lesser General Public License as published by the Free
+   Software Foundation, either version 3 of the License, or (at your option) any
+   later version.
+
+   This library is distributed in the hope that it will be useful, but WITHOUT
+   ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+   FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
+   details.
+
+   You should have received a copy of the GNU General Public License along with
+   this library.  If not, see <https://www.gnu.org/licenses/>.
+-}
+
+
+module Tad.Parser exposing (deadEndsToString, fromResult)
+
+{-| Implements missing deadEndsToString function
+
+Courtesy of Ben Burdette (bburdette) who provided the implementation here:
+
+    <https://github.com/elm/parser/pull/16/files>
+
+Once this PR is merged and released, this file can be dropped.
+
+-}
+
+import Parser exposing (DeadEnd, Parser, Problem(..))
+
+
+deadEndsToString : List DeadEnd -> String
+deadEndsToString deadEnds =
+    String.concat
+        (List.intersperse "\n" (List.map deadEndToString deadEnds))
+
+
+deadEndToString : DeadEnd -> String
+deadEndToString deadend =
+    problemToString deadend.problem ++ " at row " ++ String.fromInt deadend.row ++ ", col " ++ String.fromInt deadend.col
+
+
+problemToString : Problem -> String
+problemToString p =
+    case p of
+        Expecting s ->
+            "expecting '" ++ s ++ "'"
+
+        ExpectingInt ->
+            "expecting int"
+
+        ExpectingHex ->
+            "expecting hex"
+
+        ExpectingOctal ->
+            "expecting octal"
+
+        ExpectingBinary ->
+            "expecting binary"
+
+        ExpectingFloat ->
+            "expecting float"
+
+        ExpectingNumber ->
+            "expecting number"
+
+        ExpectingVariable ->
+            "expecting variable"
+
+        ExpectingSymbol symbol ->
+            "expecting symbol '" ++ symbol ++ "'"
+
+        ExpectingKeyword keyword ->
+            "expecting keyword '" ++ keyword ++ "'"
+
+        ExpectingEnd ->
+            "expecting end"
+
+        UnexpectedChar ->
+            "unexpected char"
+
+        Problem description ->
+            description
+
+        BadRepeat ->
+            "bad repeat"
+
+
+fromResult : Result String a -> Parser a
+fromResult result =
+    case result of
+        Err problem ->
+            Parser.problem problem
+
+        Ok value ->
+            Parser.succeed value
new file mode 100644
index 0000000..66f7e8d
--- /dev/null
+++ b/src/Tad/Result.elm
@@ -0,0 +1,10 @@
+module Tad.Result exposing (..)
+
+import Result.Extra as Result
+
+
+dropErrors : List (Result e a) -> List a
+dropErrors results =
+    results
+        |> Result.partition
+        |> Tuple.first
new file mode 100644
index 0000000..2e183f3
--- /dev/null
+++ b/src/Tad/String.elm
@@ -0,0 +1,202 @@
+{- The Tad.String Elm module
+
+   Copyright (C) 2021 Tad Lispy
+
+   This library is free software: you can redistribute it and/or modify it under
+   the terms of the GNU Lesser General Public License as published by the Free
+   Software Foundation, either version 3 of the License, or (at your option) any
+   later version.
+
+   This library is distributed in the hope that it will be useful, but WITHOUT
+   ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+   FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
+   details.
+
+   You should have received a copy of the GNU General Public License along with
+   this library.  If not, see <https://www.gnu.org/licenses/>.
+-}
+
+
+module Tad.String exposing (latinize)
+
+import Regex exposing (Regex)
+
+
+{-| Temporary (?) replacement for String.Extra.removeAccents
+
+Until <https://github.com/elm-community/string-extra/pull/48> is merged.
+
+-}
+latinize : String -> String
+latinize string =
+    if String.isEmpty string then
+        string
+
+    else
+        let
+            do_regex_to_remove_acents ( regex, replace_character ) =
+                Regex.replace regex (\_ -> replace_character)
+        in
+        List.foldl do_regex_to_remove_acents string accentRegex
+
+
+{-| Create list with regex and char to replace.
+-}
+accentRegex : List ( Regex.Regex, String )
+accentRegex =
+    let
+        matches =
+            [ ( "[à-æ]", "a" )
+            , ( "[À-Æ]", "A" )
+            , ( "ç", "c" )
+            , ( "Ç", "C" )
+            , ( "[è-ë]", "e" )
+            , ( "[È-Ë]", "E" )
+            , ( "[ì-ï]", "i" )
+            , ( "[Ì-Ï]", "I" )
+            , ( "ñ", "n" )
+            , ( "Ñ", "N" )
+            , ( "[ò-ö]", "o" )
+            , ( "[Ò-Ö]", "O" )
+            , ( "[ù-ü]", "u" )
+            , ( "[Ù-Ü]", "U" )
+            , ( "ý", "y" )
+            , ( "ÿ", "y" )
+            , ( "Ý", "Y" )
+
+            -- Latin Extended-A
+            , ( "Ā", "A" )
+            , ( "ā", "a" )
+            , ( "Ă", "A" )
+            , ( "ă", "a" )
+            , ( "Ą", "A" )
+            , ( "ą", "a" )
+            , ( "Ć", "C" )
+            , ( "ć", "c" )
+            , ( "Ĉ", "C" )
+            , ( "ĉ", "c" )
+            , ( "Ċ", "C" )
+            , ( "ċ", "c" )
+            , ( "Č", "C" )
+            , ( "č", "c" )
+            , ( "Ď", "D" )
+            , ( "ď", "d" )
+            , ( "Đ", "D" )
+            , ( "đ", "d" )
+            , ( "Ē", "e" )
+            , ( "ē", "e" )
+            , ( "Ĕ", "E" )
+            , ( "ĕ", "e" )
+            , ( "Ė", "E" )
+            , ( "ė", "e" )
+            , ( "Ę", "E" )
+            , ( "ę", "e" )
+            , ( "Ě", "E" )
+            , ( "ě", "e" )
+            , ( "Ĝ", "G" )
+            , ( "ĝ", "g" )
+            , ( "Ğ", "G" )
+            , ( "ğ", "g" )
+            , ( "Ġ", "G" )
+            , ( "ġ", "g" )
+            , ( "Ģ", "G" )
+            , ( "ģ", "g" )
+            , ( "Ĥ", "H" )
+            , ( "ĥ", "h" )
+            , ( "Ħ", "H" )
+            , ( "ħ", "h" )
+            , ( "Ĩ", "I" )
+            , ( "ĩ", "i" )
+            , ( "Ī", "I" )
+            , ( "ī", "i" )
+            , ( "Ĭ", "I" )
+            , ( "ĭ", "i" )
+            , ( "Į", "I" )
+            , ( "į", "i" )
+            , ( "İ", "I" )
+            , ( "ı", "i" )
+            , ( "IJ", "IJ" )
+            , ( "ij", "ij" )
+            , ( "Ĵ", "J" )
+            , ( "ĵ", "j" )
+            , ( "Ķ", "K" )
+            , ( "ķ", "k" )
+            , ( "ĸ", "K" )
+            , ( "Ĺ", "L" )
+            , ( "ĺ", "l" )
+            , ( "Ļ", "L" )
+            , ( "ļ", "l" )
+            , ( "Ľ", "L" )
+            , ( "ľ", "l" )
+            , ( "Ŀ", "L" )
+            , ( "ŀ", "l" )
+            , ( "Ł", "L" )
+            , ( "ł", "l" )
+            , ( "Ń", "N" )
+            , ( "ń", "n" )
+            , ( "Ņ", "N" )
+            , ( "ņ", "n" )
+            , ( "Ň", "N" )
+            , ( "ň", "n" )
+            , ( "ʼn", "n" )
+            , ( "Ŋ", "N" )
+            , ( "ŋ", "n" )
+            , ( "Ō", "O" )
+            , ( "ō", "o" )
+            , ( "Ŏ", "O" )
+            , ( "ŏ", "o" )
+            , ( "Ő", "O" )
+            , ( "ő", "o" )
+            , ( "Œ", "OE" )
+            , ( "œ", "oe" )
+            , ( "Ŕ", "R" )
+            , ( "ŕ", "r" )
+            , ( "Ŗ", "R" )
+            , ( "ŗ", "r" )
+            , ( "Ř", "R" )
+            , ( "ř", "r" )
+            , ( "Ś", "S" )
+            , ( "ś", "s" )
+            , ( "Ŝ", "S" )
+            , ( "ŝ", "s" )
+            , ( "Ş", "S" )
+            , ( "ş", "s" )
+            , ( "Š", "S" )
+            , ( "š", "s" )
+            , ( "Ţ", "T" )
+            , ( "ţ", "t" )
+            , ( "Ť", "T" )
+            , ( "ť", "t" )
+            , ( "Ŧ", "T" )
+            , ( "ŧ", "t" )
+            , ( "Ũ", "U" )
+            , ( "ũ", "u" )
+            , ( "Ū", "U" )
+            , ( "ū", "u" )
+            , ( "Ŭ", "U" )
+            , ( "ŭ", "u" )
+            , ( "Ů", "U" )
+            , ( "ů", "u" )
+            , ( "Ű", "U" )
+            , ( "ű", "u" )
+            , ( "Ų", "U" )
+            , ( "ų", "u" )
+            , ( "Ŵ", "W" )
+            , ( "ŵ", "w" )
+            , ( "Ŷ", "Y" )
+            , ( "ŷ", "y" )
+            , ( "Ÿ", "Y" )
+            , ( "Ź", "Z" )
+            , ( "ź", "z" )
+            , ( "Ż", "Z" )
+            , ( "ż", "z" )
+            , ( "Ž", "Z" )
+            , ( "ž", "z" )
+            , ( "ſ", "s" )
+            ]
+
+        regexFromString : String -> Regex
+        regexFromString =
+            Regex.fromString >> Maybe.withDefault Regex.never
+    in
+    List.map (Tuple.mapFirst regexFromString) matches
index 4d104e0..642e0b1 100644
--- a/src/Tutorial.elm
+++ b/src/Tutorial.elm
@@ -1,57 +1,39 @@
-module Tutorial exposing
-    ( Model
-    , Msg(..)
-    , init
-    , subscriptions
-    , update
-    , view
-    )
+module Tutorial exposing (goals, intro)
=
+import Goal exposing (Goal)
=import Html
=import Html.Attributes
+import Tad.Html as Html
+
+
+intro : Goal
+intro =
+    Goal.ReadInstructions
+        [ "Welcome to Word Snake!" |> Html.text |> Html.wrap Html.h1 []
+        , "In this tutorial we will show you basic elements of the game." |> Html.text |> Html.wrap Html.p []
+        , "Please note, the tutorial is still work in progress."
+            |> Html.text
+            |> Html.wrap Html.em []
+            |> Html.wrap Html.p []
+        ]
=
=
-type alias Model =
-    {}
-
-
-
--- INIT
-
-
-init : Model
-init =
-    {}
-
-
-
--- UPDATE
-
-
-type Msg
-    = NoOp
-
-
-update : Msg -> Model -> ( Model, Cmd Msg )
-update msg model =
-    case msg of
-        NoOp ->
-            ( model, Cmd.none )
-
-
-subscriptions : Model -> Sub Msg
-subscriptions model =
-    Sub.none
-
-
-
--- VIEW
-
-
-view model =
-    Html.div []
-        [ Html.h1 [] [ Html.text "Save the snake!" ]
-        , Html.p [] [ Html.text "Guess the password. Use W A S D (or swipe 👆) to move around and collect the letters to fill the missing word. Once the password is collected you will be able  to escape the fire trap." ]
-        , Html.p [ Html.Attributes.style "font-size" "2rem" ] [ Html.text "🐍" ]
-        , Html.p [] [ Html.text "The most important: avoid fire and poop." ]
+goals : List Goal
+goals =
+    [ Goal.ReadInstructions
+        [ "Moving around" |> Html.text |> Html.wrap Html.h1 []
+        , "Use W A S D or swipe 👆 on a touch-screen to move around and collect the letters to fill the missing word. Once the password is collected you will be able to escape the fire trap." |> Html.text |> Html.wrap Html.p []
+        ]
+    , Goal.CompleteSentence
+        { charset = [ 'A', 'B', 'C' ]
+        , intro = "Collect letters A B and C in order: "
+        , password = "ABC"
+        , outro = ""
+        }
+        ""
+    , Goal.ReadInstructions
+        [ "Save the Snake!" |> Html.text |> Html.wrap Html.h1 []
+        , "You have finished the tutorial. Well done! Now play." |> Html.text |> Html.wrap Html.p []
+        , "The most important: avoid fire and poop." |> Html.text |> Html.wrap Html.p []
=        ]
+    ]