Making a Text Adventure in Haskell (Part 4)

Making a change in the world

Last time I discussed how I filtered interactions the player typed in to decide if the interactions were valid given the state of the world. In this final post about my text adventure engine, I’ll describe how I updated the state of the game world and how to use the HaskellAdventure game engine to create a full text adventure game.

Performing conditional actions

The filterInteraction function calls performConditionalAction with a valid conditional action. performConditionalAction takes a list of delimiters and column width, the current scene index, end scene list, inventory, flags, a Maybe Interaction for the current scene, and a Maybe Interaction for the default scene. It evaluates to the next state of the game.

performConditionalActions :: [Char] -> Int -> SceneIndex -> [SceneIndex] -> Inventory -> Flags -> Maybe Interaction -> Maybe Interaction -> IO (Maybe (SceneIndex, Inventory, Flags))

There are five different patterns to handle based on the interactions in the input list.

The first pattern is matched when no valid interaction was found in either the current or default scene:

performConditionalActions _ _ currentSceneIndex _ inventory flags Nothing Nothing
    = putStrLn "That does nothing.\n" >>
      hFlush stdout >>
      return (Just (currentSceneIndex, inventory, flags))

If there are no valid interactions actions but the sentence was valid, I just return to the current state.

The second and third patterns form a mutual recursion, which iterates over all of the conditional actions in the current scene. They are matched whenever the current scene has at least one valid ConditionalAction. The second pattern is the terminal case in the recursion.

performConditionalActions delimiters
                          columnWidth
                          currentSceneIndex
                          endScenes
                          inventory
                          flags
                          (Just (Interaction {sentences = _,
                                              conditionalActions = []}))
                          defaultSceneInteractions
    = performConditionalActions delimiters columnWidth currentSceneIndex endScenes inventory flags Nothing defaultSceneInteractions

performConditionalActions delimiters
                          columnWidth
                          currentSceneIndex
                          endScenes
                          inventory
                          flags
                          (Just (Interaction {sentences = thisSentences,
                                              conditionalActions = (conditionalAction@(ConditionalAction {condition = thisCondition}) : remainingConditionalActions)}))
                          defaultSceneInteractions -- Ignore default scene interactions if there are still current scene interactions
    | evaluateCondition thisCondition inventory flags = updateGameState delimiters columnWidth endScenes currentSceneIndex inventory flags conditionalAction --The condition for the action passed, update the game state
    | otherwise = performConditionalActions delimiters columnWidth currentSceneIndex endScenes inventory flags
                  (Just (Interaction {sentences = thisSentences,
                                      conditionalActions = remainingConditionalActions}))

In the third pattern, I call evaluateCondition with the current ConditionalAction‘s condition and if it evaluates to True, I call updateGameState with the conditionalAction as an input. If all of the current scene’s ConditionalActions evaluate to False, the second pattern is matched and it calls the fourth and fifth patterns, which handle all of the default scene’s ConditionalActions.

The fourth and fifth patterns of performConditionalActions form an iteration over the default scene’s ConditionalActions. The fourth pattern is the terminal case of the iteration. If a ConditionalAction evaluates to True in this iteration, I call updateGameState with the ConditionalAction as an input. If no ConditionalActions evaluate to true in this iteration, all possible conditional actions have failed and the first pattern of performConditionalActions is called.

performConditionalActions delimiters
                          columnWidth
                          currentSceneIndex
                          endScenes
                          inventory
                          flags
                          Nothing
                          (Just (Interaction {sentences = _,
                                              conditionalActions = []}))
    = performConditionalActions delimiters columnWidth currentSceneIndex endScenes inventory flags Nothing Nothing

performConditionalActions delimiters
                          columnWidth
                          currentSceneIndex
                          endScenes
                          inventory
                          flags
                          Nothing --The current scene failed to have any interactions
                          (Just (Interaction {sentences = thisSentences,
                                              conditionalActions = (conditionalAction@(ConditionalAction {condition = thisCondition}) : remainingConditionalActions)}))
    | evaluateCondition thisCondition inventory flags = updateGameState delimiters columnWidth endScenes currentSceneIndex inventory flags conditionalAction
    | otherwise = performConditionalActions delimiters columnWidth currentSceneIndex endScenes inventory flags Nothing
                  (Just (Interaction {sentences = thisSentences,
                                      conditionalActions = remainingConditionalActions}))

The function which actually updates the game state is called updateGameState:

updateGameState :: [Char] -> Int -> [SceneIndex] -> SceneIndex -> Inventory -> Flags -> ConditionalAction -> IO (Maybe (SceneIndex, Inventory, Flags))
updateGameState delimiters
                columnWidth
                endScenes
                currentSceneIndex
                inventory
                flags
                conditionalAction@(ConditionalAction {conditionalDescription = thisConditionalDescription,
                                                      stateChanges = thisStateChanges})
 = printConditionalDescription delimiters columnWidth endScenes thisConditionalDescription [] (Just (currentSceneIndex, inventory, flags)) >>=
   stateChange (Data.List.find (\x -> case x of
                                      (SceneChange _) -> True
                                      otherwise -> False) thisStateChanges)
               endScenes
               thisStateChanges

updateGameState first prints the ConditionalAction‘s ConditionalDescription, then it searches for a SceneChange value constructor in the list of stateChanges. If there is a SceneChange value constructor, it passes the scene change with a Just StateChange as the first parameter of stateChange and Nothing otherwise.

stateChange takes the Maybe StateChange for scene changes, the end scenes list, the list of all other state changes, and the current state of the game. It evaluates to the next state of the game:

stateChange :: Maybe StateChange -> [SceneIndex] -> [StateChange] -> Maybe (SceneIndex, Inventory, Flags) -> IO (Maybe (SceneIndex, Inventory, Flags))

The first and second pattern of stateChange match when the game is in an end game state (the current state is Nothing). In these cases, sceneChange just evaluates to Nothing:

stateChange Nothing _ stateChanges Nothing
    = return Nothing
stateChange _ endScenes stateChanges Nothing 
    = return Nothing

The third pattern matches when the game currently is in a valid state but no SceneChange was necessary:

stateChange Nothing _ stateChanges (Just (sceneIndex, inventory, flags))
    = return (Just (sceneIndex,
                    updateInventory inventory stateChanges,
                    updateFlags flags stateChanges))

In this case, updateInventory and updateFlags are called to update the player’s inventory and flags as necessary.

The fourth pattern matches when the game is in a valid state and a SceneChange was necessary as part of the current ConditionalAction:

stateChange (Just (SceneChange nextScene)) endScenes stateChanges (Just (sceneIndex, inventory, flags)) 
    = if nextScene `elem` endScenes
      then return Nothing
      else return (Just (nextScene,
                         updateInventory inventory stateChanges,
                         updateFlags flags stateChanges))

In this case, if the nextScene is an end scene, the function evaluates to Nothing, which is the end game state. Otherwise, the function evaluates to the next scene and calls updateInventory and updateFlags to update the player’s inventory and flags.

updateInventory matches the AddToInventory and RemoveFromInventory value constructors and inserts the object string into the inventory or filters it out of the inventory as necessary:

updateInventory :: Inventory -> [StateChange] -> Inventory
updateInventory (Inventory inventory) [] = Inventory inventory
updateInventory (Inventory inventory) ((RemoveFromInventory object) : remainingChanges) = updateInventory (Inventory (filter (\x -> x /= object) inventory)) remainingChanges
updateInventory (Inventory inventory) ((AddToInventory object) : remainingChanges)
    | object `elem` inventory = updateInventory (Inventory inventory) remainingChanges
    | otherwise = updateInventory (Inventory (object : inventory)) remainingChanges
updateInventory (Inventory inventory) (_ : remainingChanges) = updateInventory (Inventory inventory) remainingChanges

updateFlags matches the SetFlag and RemoveFlag value constructors and inserts the flag string into the flags or filters it out of the flags as necessary:

updateFlags :: Flags -> [StateChange] -> Flags
updateFlags (Flags flags) [] = Flags flags
updateFlags (Flags flags) ((RemoveFlag flag) : remainingChanges) = updateFlags (Flags (filter (\x -> x /= flag) flags)) remainingChanges
updateFlags (Flags flags) ((SetFlag flag) : remainingChanges)
    | flag `elem` flags = updateFlags (Flags flags) remainingChanges
    | otherwise = updateFlags (Flags (flag : flags)) remainingChanges
updateFlags (Flags flags) (_ : remainingChanges) = updateFlags (Flags flags) remainingChanges

Since the only changes the player can make to the world are adding and removing from the inventory and flags and changing the current scene, these are the only functions necessary to update the game world.

Building an adventure

All that’s left is to cover how to actually make a game which uses the HaskellAdventure game engine. There is an example adventure in my HaskellAdventure GitHub repository called DummyAdventure.hs. Let’s walk through it one piece at a time.

The first thing which you need to do to create a game with the HaskellAdventure engine is create a module for your adventure:

module DummyAdventure (gameIntro,
                       allVerbs,
                       allNouns,
                       allPrepositions,
                       allTokens,
                       startInventory,
                       startFlags,
                       defaultScene,
                       allScenes) where

As you can see, quite a lot of functions are required to define a text adventure game.

Next you need to import the game engine and Data.List:

import qualified Data.List

import NaturalLanguageLexer
import NaturalLanguageParser
import NarrativeGraph

gameIntro simply defines a string to print at the start of your game as an introduction:

gameIntro :: String
gameIntro = "Dummy Adventure by Laurence Emms\n"

allVerbs evaluates to a list of all valid TokenVerbs in your game:

allVerbs :: [Token]
allVerbs =
 [
 TokenVerb "get" ["get", "take", "pick up"],
 TokenVerb "put" ["put", "place", "put down"],
 TokenVerb "throw" ["throw", "pitch"],

...

 TokenVerb "leave" ["leave", "exit"],
 TokenVerb "eat" ["eat", "consume"],
 TokenVerb "drink" ["drink", "consume"]
 ]

allNouns evaluates to a list of all valid TokenNouns in your game:

allNouns :: [Token]
allNouns =
 [
 TokenNoun "north" ["north"],
 TokenNoun "south" ["south"],
 TokenNoun "west" ["west"],

...

 TokenNoun "Steve" ["Steve"],
 TokenNoun "juice" ["juice"],
 TokenNoun "cake" ["cake"]
 ]

and allPrepositions evaluates to a list of all valid TokenPrepositions in your game:

allPrepositions :: [Token]
allPrepositions =
 [
 TokenPreposition "in" ["in", "inside", "within"],
 TokenPreposition "into" ["into"],
 TokenPreposition "out" ["out", "outside"],

...

 TokenPreposition "until" ["until"],
 TokenPreposition "with" ["with"],
 TokenPreposition "together with" ["together with"]
 ]

It’s useful to define the following function to create unambiguous sentences, so you don’t have to call unambiguousSentence in every Interaction:

uSentence :: [String] -> Sentence
uSentence words = unambiguousSentence allVerbs allNouns allPrepositions words

allTokens collects all tokens into a single list:

allTokens :: [Token]
allTokens = allNouns ++ allVerbs ++ allPrepositions

startInventory contains all of the items the player starts with in their inventory:

startInventory :: Inventory
startInventory = Inventory ["fork"]

startFlags contains all of the flags which are set at the start of the game:

startFlags :: Flags
startFlags = Flags ["started game"]

Next you can define all of the scenes in your game. DummyAdventure.hs contains only one scene, called scene0:

scene0 :: Scene
scene0 =
    Scene
    {
        sceneDescription =
            ConditionalDescription [(CTrue, "You're standing in a green room. The room has a <white door>.", []),
                                    (CNot (FlagSet "opened white door"), "The <white door> is closed.", []),
                                    (FlagSet "opened white door", "The <white door> is open.", []),
                                    (CNot (InInventory "key"), "There is a <key> on the floor.", [])],
        interactions =
            [
                Interaction
                {
                    sentences = [uSentence ["get", "key"]],
                    conditionalActions =
                        [
                            ConditionalAction
                            {
                                condition = CNot (InInventory "key"), --The player does not have the key
                                conditionalDescription = ConditionalDescription [(CTrue, "You pick up the <key>.", [])],
                                stateChanges = [AddToInventory "key"]
                            },
                            ConditionalAction
                            {
                                ...
                            }
                        ]
                },
                Interaction
                {
                    ...
                },
            ]
    }

Each scene has a sceneDescription, which is a ConditionalDescription which is printed when the player is in the scene. The scene also has a list of Interactions, which the player can perform in the scene. Each Interaction has a list of sentences which trigger the Interaction and a list of ConditionalActions which contain NarrativeConditions, ConditionalDescriptions, and StateChanges. By defining these for each scene, you can create a whole text adventure.

The game will need at least one end scene. In this case, scene1 is the end scene for the game:

scene1 :: Scene
scene1 =
    Scene
    {
        sceneDescription = ConditionalDescription [],
        interactions = []
    }

In this game, the end scene does nothing but end the game.

The last scene you need to define to make a game in the HaskellAdventure engine is the default scene. All Interactions in the default scene are valid in every other scene.

defaultScene :: Scene
defaultScene =
    Scene
    {
        sceneDescription = ConditionalDescription [],
        interactions =
        [
            Interaction
            {
                sentences = [uSentence ["jump"]],
                conditionalActions =
                [
                    ConditionalAction
                    {
                        condition = CTrue, --Always do this
                        conditionalDescription = ConditionalDescription [(CTrue, "You jump up and down in place.", [])],
                        stateChanges = []
                    }
                ]
            },
            Interaction
            {
                ...
            },
            ...
   }

The very last thing you need to define to make a game with the engine is the allScenes function which evaluates to a list of all scenes in the game and a list of end scene indices:

allScenes :: ([Scene], [SceneIndex])
allScenes = ([scene0, scene1], --List of scenes
             [1]) --End scenes

It has been an interesting experience developing a text adventure engine in Haskell. I learned a lot about the language and what it’s capable of during this project. I hope that also you learned something about Haskell by reading about my experiences.

Let me know if you try to use my engine to make a text adventure game or if you’ve made a text adventure in Haskell yourself.

The code for the text adventure and engine is available with an MIT open source license at https://github.com/WhatTheFunctional/HaskellAdventure.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google+ photo

You are commenting using your Google+ account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s