Please Buy now at Leanpub
Written and illustrated by GetContented.
Published on 2024-05-24.
Games! We saw the Fridge game. It took place in one single “room” which made it very limited. Then, the Train game didn’t really give you any freedom, but at least it had more rooms.
Next we’ll see a game that lets us imagine that we’re a squirrel and we live in a tree. Our new game will only have two areas, but it will provide more capability than before. And, in the process, we’ll get some more practice with all the things we’ve seen so far.
If we wanted to make a game that is a tiny bit more like a real text adventure game, it’d have to let the player move around between its game areas.
To keep it ultra-simple, let’s say we (our squirrel) could be able to go between only the inside and outside of the tree, perhaps. The program we’ll be discovering in this section is a lot more complicated than the Fridge game, but it’s about one of the most simple, basic text adventures possible.
Let’s look at the types first:
data GameObject = Player
| Acorn
deriving (Eq, Show)
data Room =
Room Description [GameObject]
deriving (Show)
type Description = String
type Inventory = [GameObject]
type GameMap = [Room]
type GameState = (GameMap, Inventory)
We have a GameObject
type whose values can be either Player
or Acorn
. Fairly straightforward, this is just a sum type like we’ve seen before. What about deriving (Eq, Show)
, though? Well, this is a way to make a type become an instance of these typeclasses without having to write the code for it manually ourselves. Being an instance of Eq
means we can use (==)
and other comparison functions on values of this type, and being an instance of Show
means it can be converted to strings with the show
function.
Now, the Room
type is a product type and it has a type constructor called Room
as well as a value constructor of the same name, which is used to make values of the Room
type. It has fields of Description
, and a list of type GameObject
which is used to hold the contents of the Room
. So, because of the way the GameObject
type is defined, a Room
can have one or more Player
or Acorn
values in it. Our game will only ever have one of each in the whole game.
Next are a bunch of type synonyms which should be pretty easy to understand by now, possibly with the exception of GameState
which is a 2-Tuple (otherwise known as a pair) of GameMap
, and Inventory
, which is a list of GameObject
. GameMap
is a list of Room
.
The GameState
type will be what we use to store all the changing pieces of our game as the player plays it. The GameMap
will be all the rooms in the game, and the Inventory
is what the player is holding moment by moment.
Let’s see a definiton for the initial state of the game and the main
function, that will start the game.
initialState :: GameState
initialState =
( [ Room "You are inside a tree." [Player]
, Room "You are outside of a tree." [Acorn]]
, [] )
main :: IO ()
main = do
putStrLn "Welcome to Skwak the Squirrel."
putStrLn "You are a squirrel."
gameLoop initialState
Now we see the main
function, which as we know is the IO
action that will be executed by Haskell when we compile and run our program.
All it does it announce the game, then runs a function called gameLoop
using the intialState
which is the state that the game should start with. The gameLoop
contains the bulk of the program, and we’ll see it in a moment.
First, Let’s look at initialState
. This is a GameState
, which as we know from the types is a 2-Tuple that has a list of Room
then a list of GameObject
. We start our game with two rooms... we use the Room
constructor to build the rooms. We have the outside and the inside of the tree. The Player
is inside, and the Acorn
is outside.
Let’s look at the gameLoop
function now:
gameLoop :: GameState -> IO ()
gameLoop (rooms, currentInv) = do
let currentRoom =
case findRoomWithPlayer rooms of
Just r -> r
Nothing -> error $ "Somehow the player "
++ "ended up outside the map!"
possibleCmds =
validCommands currentRoom currentInv
if playerWon (rooms, currentInv)
then gameOverRestart
else do
describeWorld currentRoom currentInv possibleCmds
takeActionThenLoop
currentRoom currentInv possibleCmds rooms
This function takes a GameState
and returns an IO
action. It’s the main
functionality of the game. Each move the player makes goes through the game loop once, which is why it’s called a loop, because it’s like a circle.
We see that we can have a let
expression in our do
block which essentially sets up temporary variables in the do
block from that point onwards.
We’re finding and grabbing the current room from the list of rooms, or throwing up our hands if it can’t find the Player
.
Then we work out which commands are valid for the current room and the current inventory. Some player commands are only possible with some combination of Acorn
being in the room with the player, or in the inventory.
Once that is done, we check if the player has won with an if
expression, and if so, we tell the player they’ve won and offer to play again. If they didn’t win, then we describe the world to them at the room they’re in, and let them take an action by grabbing their command, then go back again to the start (using the takeActionThenLoop
function).
Now we’ll go through the remaining functions. Note, though, that we can use any expressions (such as if
) with no trouble in a do
block for IO
, even other nested do
blocks, so long as those expressions result in an IO
value of some kind. We’ll see in a moment that all of those functions we just discussed yield IO
values of some kind.
findRoomWithPlayer :: [Room] -> Maybe Room
findRoomWithPlayer rooms =
L.find (\(Room _ obs) ->
any (== Player) obs)
rooms
We can tell by the name that findRoomWithPlayer
should return the Room
whose objects include the Player
. We’re returning a Maybe Room
because it’s technically possible that the Player
might not be in any Room
. If that’s the case, there’s a problem, because the game won’t work. That’s why there’s an application of the error
function in gameLoop
if findRoomWithPlayer
can’t find a Player
.
We’re using a lambda as our Room-testing function here. The way find works is it looks through the list passed into it, applies the predicate section function (== Player)
to each item, and if it finds one that returns True
, it returns that one, wrapped in the Just
data constructor from the Maybe
type. If it doesn’t, it returns the Nothing
value (also from the Maybe
type.
In depth, our lambda takes a Room
, pulls it apart using pattern-matching to get at the objects (which is a list of GameObject
), names that obs
, and then passes that to any
, which will check to see if any of them return True
for the function (== Player)
which checks to see if something is equal to the Player
value.
Next we’ll see the function that crafts the valid commands for a particular room and inventory combination:
validCommands :: Room -> Inventory -> [String]
validCommands (Room _ gameObjs) invItems =
["go"] ++ takeCommandList
++ dropCommandList ++ ["quit"]
where
takeCommandList =
if somethingToTake gameObjs
then ["take"]
else []
dropCommandList =
if length invItems > 0
then ["put"]
else []
This function takes a Room
and an Inventory
and returns a list of String
values that are all the commands that a player can type in depending on the current data: whether there is anything to take (in the room), or drop (from inventory).
It’s using the list concatenation operator (++)
to join the String
lists together into one list to return, and two definitions with if
expressions to ensure we only put “take” or “put” on the command list if it’s appropriate.
somethingToTake :: [GameObject] -> Bool
somethingToTake objs =
any (/= Player) objs
Of course, we can eta-reduce this function as we know by now (that is, get rid of the repeated trailing arguments):
somethingToTake :: [GameObject] -> Bool
somethingToTake = any (/= Player)
Here we take a list of GameObject
which is used by validCommands
as the list of objects in the room that can be taken. We’re using it to work out if there is anything that is not a Player
in the list. The any
function takes a predicate function, evaluates it on each item of a list, and returns True
if any of the evaluations return True
, otherwise False
. The function (/=)
means not equal to, and comes from the Eq
typeclass. The end effect answers the question “Are there any non-player objects in this room?”.
As we’ve seen before, the use of parentheses around (/= Player)
makes it into what’s called a section: it creates a function of one argument, having applied one of the 2-arguments required by the operator. Here we’re creating a function that takes a GameObject
and returns a Bool
that indicates whether the GameObject
was Player
.
playerWon :: GameState -> Bool
playerWon (rooms, currentInv) =
any hasAcornAndInside rooms
where hasAcornAndInside (Room desc objs) =
desc == "You are inside a tree."
&& any (==Acorn) objs
This function answers the question “Has the player won yet?” The player has won if the acorn is in the tree (and not in the player’s inventory). Pay careful attention to the indentation (where the lines start). This matters!
We test for this by pulling the GameState
tuple apart into a variable each for rooms and current inventory. We then go through all the rooms, and using the any
function again, check if there’s one which has the description “You are inside a tree.” that also has the Acorn in its objects list.
Note the use of the &&
operator, which takes two Bool} expressions and returns \{True
if they’re both True
. This is called the logical and operator. It’s one way we can connect up logic expressions into larger chunks of meaning. There is also an (||)
operator that is logical or, which will return True
if either of its inputs evaluate to True
, and a not
function that takes only one Bool
expression and returns the logical inverse of it (that is, if it’s True
, it returns False
and vice-versa).
Our playerWon
function is a not the best way we could write it, because we’re relying on the description of the room to check if it’s the inside room. This gives us a lot of room for errors to creep in by accident. We did this to keep the types a bit simpler as you’re learning. It’s bad because what if we changed the description in the actual Room
data, but then forgot to change this check String
. It would mean it’d be impossible to win the game.
A better way would be to pull this description into its own definition that is defined in only one place. The best way, though, would be to have an identifier in the Room
data type representing the type of Room
it is, and to check against this to see if this Room
was the winning goal Room
.
gameOverRestart :: IO ()
gameOverRestart = do
putStrLn $ "You won!"
++ "You have successfully stored the acorn"
++ " for winter. Well done!"
putStrLn "Do you want to play again? y = yes"
playAgain <- getLine
if playAgain == "y"
then gameLoop initialState
else putStrLn "Thanks for playing!"
Our function to check if the game is now over uses a do
block to print some messages congratulating the player, then asks if they want to play again, collects a line of input from them, and restarts the game from the beginning by using initialState
if their input equals "y"
. Very straight-forward!
getCommand :: IO String
getCommand = do
putStrLn "What do you want to do?"
getLine
getCommand
is a simple little action that prints a query to the player, gets that response and returns it. The type is IO String
, which is the same type as getLine
. Notice that as getLine
is the last line of the function, we don’t need to use anything to pull the value out here, because it has the return type already. We’ll see how it’s used now in takeActionThenLoop
:
takeActionThenLoop :: Room ->
Inventory ->
[String] ->
[Room] ->
IO ()
takeActionThenLoop currentRoom
currentInv
possibleCmds
rooms =
do
command <- getCommand
if any (==command) possibleCmds
then case command of
"go" ->
do
putStrLn "You go..."
gameLoop $ movePlayer (rooms, currentInv)
"take" ->
do
putStrLn "You take the acorn..."
gameLoop $
moveAcornToInventory (rooms, currentInv)
"put" ->
do
putStrLn "You put the acorn down..."
gameLoop $
moveAcornFromInventory (rooms, currentInv)
"quit" ->
putStrLn $ "You decide to give up."
++ " Thanks for playing."
_ ->
do
putStrLn "That is not a command."
gameLoop (rooms, currentInv)
else do
putStrLn $ "Command not possible here,"
++ " or that is not a command."
gameLoop (rooms, currentInv)
Even though this is one of the largest functions in the program, it’s reasonably simple. It’s structured by firstly taking a command from the user, then checking if the command is one of the valid ones for the player right now. If not it complains that it can’t understand and just goes back to the gameLoop
with the current data. However, if it does actually match against a valid command, it runs through the case expression trying to find a match.
Each of the commands has a corresponding function which results in a modified GameState
, which is then passed back into the gameLoop
function for a further turn. We’ll see each of these functions momentarily.
Notice that we use the ($)
function all over the place, such as to apply gameLoop
to the result of the evaluation of the command functions, such as movePlayer
.
Also notice that there is a pattern match for “_
”, which for completeness will match on any other values for the command
. In our case there aren’t actually any other commands, but not having this is a potential source of problems in the future as things change, and Haskell will complain if we leave it off. Take a moment and think what would happen if you added a new command to the validCommands
function, but didn’t add it to the takeActionThenLoop
function. If we keep this underscore catch-all, then play the game, the game will tell us that our new command is not recognised as a command. If we leave it off, though, our program will crash. That’s why it’s better to have total functions.
Having said this, a better solution would be to represent our commands with an algebraic data type. For now and to keep things slightly more simple, we’ll stick to the String
type.
These next three functions are to adjust the game state when the player does something.
movePlayer :: GameState -> GameState
movePlayer (rooms, inv) =
(newRooms, inv)
where
newRooms =
map adjustRooms rooms
adjustRooms (Room d objs) =
if any (==Player) objs
then (Room d (filter (/=Player) objs))
else (Room d (Player : objs))
moveAcornToInventory :: GameState -> GameState
moveAcornToInventory (rooms, inv) =
(newRooms, newInv)
where
newInv =
Acorn : inv
newRooms =
map adjustRooms rooms
adjustRooms (Room d objs) =
Room d (filter (/=Acorn) objs)
moveAcornFromInventory :: GameState -> GameState
moveAcornFromInventory (rooms, inv) =
(newRooms, newInv)
where
newInv =
filter (/=Acorn) inv
newRooms =
map adjustRooms rooms
adjustRooms (Room d objs) =
if any (==Player) objs
then Room d (Acorn : objs)
else Room d objs
Our game only has two rooms, so the movePlayer
function just goes over “all” two rooms using the map
function, and applies a function that takes the Player
out if it’s there using filter
, or puts it in if it’s not there, using the list-prepend function (:)
. It does this by pattern-matching the Room
into its pieces, then reconstructing it with a new GameObject
list.
The moveAcornToInventory
function returns a new 2-tuple (a GameState
) that is comprised of an adjusted set of rooms by mapping a deconstructing-reconstructing function that uses filter
to remove all acorns from the rooms’ GameObject
list (there should only be one anyway), and an adjusted inventory by prepending an Acorn
to it using (:)
. This “moves” the Acorn
into inventory.
The moveAcornFromInventory
does the reverse of this. The mapping function that puts the Acorn
into the room where the player is is quite interesting because it has an if
expression to detect if a particular room’s game objects contains the Player
, and if so, it prepends an Acorn
, otherwise it just re-builds the passed in room as it was before.
describeWorld :: Room ->
Inventory ->
[String] ->
IO ()
describeWorld currentRoom
currentInv
possibleCmds =
do
putStrLn $ describeRoom currentRoom
putStrLn $ describeInventory currentInv
putStrLn $ describeCommands possibleCmds
This is pretty straightforward, it just uses other functions to describe to the player which room they’re in, what they’re carrying and also which commands they can use here.
Notice, again, that we’re using another do
block to make a single IO
action out of a bunch of putStrLn
function applications.
Also we see the function ($)
again at work, applying the function on the left of it to the result of evaluating everything on the right of it. The function application operator is often used as above, to avoid having to type brackets around expressions, as you probably know by now. It has the lowest possible precedence, which means it’s evaluated last, after all the other parts of an expression.
The type signature of this is ($) :: (a -> b) -> a -> b
which means it takes a function from a
to b
(on the left of it) and an a
(on the right of it), and applies the function to the “a” which gives a b
. Obviously this is an extremely general function because ($)
can apply to any function, and any value as long as they fit together.
Again, it’s important to realise that even though the type signature of ($)
says it takes a function from a
types to b
types, it doesn’t have to be different types, but they can be if you like. So, (+3)
fits the description even though its type is Num a => a -> a
and this is because (a -> b)
goes from one “any type” to another “any type”, so not necessarily the same type. (a -> b)
is actually the type that fits any type of function at all!
There is no difference in result between writing (+3) $ 7
and writing (+3) 7
, but when you start adding more arguments it starts to matter, like in (+3) $ 7 * 5
which gives 38
, a different answer than (+3) 7 * 5
, which is 50. From this we can see that ($)
changes the order that things are evaluated in, just like putting parentheses around some things (so, (+3) $ 7 * 5
is equivalent to (+3) (7 * 5)
).
Your homework (yes, mid-lesson homework) is to experiment with the ($)
function, and explicit bracketing, and explore the differences and similarities if you can.
Next, the three functions that describeWorld
uses:
describeRoom :: Room -> String
describeRoom (Room desc objs) =
desc ++ if any (==Acorn) objs
then " There is an acorn here"
else ""
describeInventory :: Inventory -> String
describeInventory [] =
"You are holding nothing"
describeInventory inv =
"You are holding: " ++
(concat $ map show inv)
describeCommands :: [String] -> String
describeCommands commands =
"Commands: "
++ (L.intercalate ", " commands)
The describeRoom
function takes a single Room
and pattern-matches it into the desc
and objs
variables, which are the description of the Room
, and the objects that the Room
contains respectively. Notice that we’re using (++)
to append the result of an if
expression to the Room
’s description here. The if
expression uses a section. Remember that a section is effectively a partially applied operator. In this case, (==Acorn) :: GameObject -> Bool
will check if something is an Acorn
, and return a Boolean
value for that (True
/False
).
Let’s think some more about the function any :: Foldable t => (a -> Bool) -> t a -> Bool
for a moment. This is a function that takes a predicate from types a
to Bool
, a Foldable t a
and then returns a Bool
. This function will tell us if any item in the list “satisfies” the predicate. That is, if any item in the list returns True
for our predicate function.
Or, partially applied to the (==Acorn)
section as in any (==Acorn)
, it will tell us if there’s an Acorn
in a supplied list! If there is an Acorn
, it’ll say so. If not, just an empty String
.
Next we’ll look at the describeInventory
function. There are two definitions on this function. The first is for when there is nothing in the passed-in Inventory
, so we’re matching on an empty list. This is simple.
The second definition is where the meat is. If the match with the empty list failed, that means there must be something in the Inventory
, which we now pattern-match to the “inv” variable.
We then use the list concatenation operator (++) to append the result of an expression to the end of the beginning of a description of what they player is holding. Of course, this works because String
is [Char]
, so therefore a list.
The expression we’re appending is (concat $ map show inv)
. We can break this apart into its pieces. Firstly, we now know about the ($)
function, and can think about it like it’s bracketing like this: (concat (map show inv))
.
Thinking about the expression map show inv
, we can see that this takes the inv
list and returns a new list that it builds by applying the show
function to each GameObject
in it, turning it into a list of strings. We can see this clearly if we check its type: map show :: Show a => [a] -> [String]
.
The function concat :: Foldable t -> t [a] -> [a]
takes a Foldable
-wrapped list of type a
, and returns a list of type a
. So in this case, because we’re passing it a [String]
, which as you know is the same as a [[Char]]
. So that means the Foldable t
that concat
needs will be the list type, so a list of list of Char
. The concat
function will concatenate (join) that [String]
to be a single String
(or turn the [[Char]]
into a [Char]
, same thing), by joining all the strings into one String
.
The end result is that describeInventory
will either tell the player they’re holding nothing, or that they’re holding a list of what they’re holding. The fact that there will only ever be one item in the list means we don’t need to comma-separate it or anything.
Next is the describeCommands
function. It takes a list of commands, which are just String
values, and calls the intercalate
function from Data.List
on that it. This is a pretty neat function. Its type is [a] -> [[a]] -> [a]
. The first argument is a separator, and the second is a list of items of the same type. It then joins them all together into one big list after putting the separator between each of the items. So, here we’re using it to separate all the commands by comma-space so that it reads nicely.
While this game is designed to be small and simple for teaching purposes, it’s not hard to see that it would be pretty easy to adjust it to have a larger map, or more items. There are some problems, though.
Your homework is to go back through the chapter and think about what the problems of this program are as it’s built. For example, what would happen to the program if you added an additional room? Why not try it out and see.
It can be very useful to think about all the possible ways that programs could be changed, and how important it is to design programs in ways that make extending them easier, and also to make them resilient to change in as many ways as possible. This game is very easy to extend in some ways, but it’s not written to be extended at all in other ways.
Ideally when we write programs, we make it very easy to adjust them. We can do this by building them up from smaller pieces that closely match the type of data and functionality we want. For example, perhaps a List
isn't a very good data type to use for our game map, and perhaps when we wrote our map movement functions, we could have written them in a way that meant they would work on any size List
, not just a two-element one.
We can see from the language Haskell itself that its designers have followed this pattern because this language is very easy to extend, and encourages a style of programming where we build the language up to the problem we’re trying to solve.
If you’ve enjoyed reading this, please consider purchasing a copy at Leanpub today
Please follow us, and check out our videos Follow @HappyLearnTutes
Also, Volume 2 is now in beta and being written! Show your support and register your interest at its Leanpub site.