I recently purchased the unique and fun Land of Lisp, which teaches the Common Lisp language and programming philosophy via a mix of wit, funny illustrations and comics, and interesting code examples. Specifically, many of the examples involve building old-school text adventure games (a la Zork), which many of us played and loved growing up.
I like the book, but I have some issues with the Common Lisp language and community (but that’s for another blog post). I decided, therefore, that instead of working through the examples in Common Lisp, I’d use F#, which I’m learning in my spare time. To pass on what I learned in this exercise, I decided to walk through my code here.
(Note that while I’m confident that the code is reasonably idiomatic, I’m by no means an expert F# programmer, so keep that in mind when analyzing my approach.)
To get started, we’ll declare our module and import supplementary libraries. A module in F# is a collection of values and functions. After the module declaration, you can import any libraries you’ll be using with open statements. I’ll just be using .NET’s System namespace, which gives access to common types like Console, which is used for inputting and outputting text to a terminal.
module TextAdventure open System
Next, we’ll define types, which will act as the nouns upon which our verbs (functions) will operate.
type Location = Room | Garden | Attic type Direction = North | South | East | West | Up | Down
The types Location and Direction are known as “union” or “variant” types–you can read the declaration as “The values Room, Garden, and Attic are Locations. Any given Location will have one of those three values.”
type Thing = { Name: string; Article: string } type Edge = { Dir: Direction; Portal: string; Loc: Location }
These next two types are records, which are somewhat analogous to structs in C. Things will represent objects in the game world, and Edges (as in the mathematical term “edge” meaning a path that connects two nodes) will represent the pathways between areas in the game. The Thing declaration can be read as “A Thing consists of a Name string and an Article string.” Note how the Edge record contains fields of type Direction and Location, which we just defined above. F# is very strict in how it parses source code, and it does so from top-to-bottom, so you need to declare everything before you use it (that is, putting the definition of the Direction type after the Edge type would result in a compilation error).
type Player(initial: Location, objects: ResizeArray<Thing>) = let mutable location = initial member this.PickUp obj = objects.Add obj member this.Location with get() = location and set v = location <- v
The Player type is a bit more complicated. It’s actually a class, quite similar to classes in C# and many other OOP languages. In fact, the equivalent definition in C# would look like this:
class Player { private Location location; private readonly List<Thing> objects; public Player(Location initial, List<Thing> objects) { location = initial; this.objects = objects; } public void PickUp(Thing obj) { objects.Add(obj); } public Location Location { get { return location; } set { location = value; } } }
So that should be fairly straightforward; we will be keeping track of the location of the player and his inventory of objects. We now need a type to describe our game world, which will track which objects are where, which areas can be accessed and from where, and what areas look like. The game world will also hold our player via an instance of the aforementioned Player type.
type World() = let player = new Player(Room, new ResizeArray<Thing>()) let mutable objects = [Room, [{ Name = "whiskey"; Article = "some" }; { Name = "bucket"; Article = "a" }]; Garden, [{ Name = "chain"; Article = "a length of" }]; Attic, []] |> Map.ofList let locations = [Room, "You are in a living room. A wizard is snoring loudly on the couch."; Garden, "You are in a beautiful garden. There is a well here."; Attic, "You are in an attic. There is a giant welding torch in the corner."] |> Map.ofList let edges = [Room, [{ Dir = West; Portal = "door"; Loc = Garden }; { Dir = Up; Portal = "ladder"; Loc = Attic }]; Garden, [{ Dir = East; Portal = "door"; Loc = Room }]; Attic, [{ Dir = Down; Portal = "ladder"; Loc = Room }]] |> Map.ofList member this.Locations = locations member this.Edges = edges member this.Player = player member this.Objects with get() = objects and set v = objects <- v
World is another class. Upon its construction, we set its fields (player, objects, locations, and edges) to their initial values. Note that the latter three fields will be represented using maps, which are collections of key-value pairs (often referred to as dictionaries, hash tables, or associative arrays in other languages). You’ll notice that our locations consist of a wizard’s living room, attic, and garden, and that inside those areas you’ll find kooky things like whiskey, a bucket, and a length of chain.
If you’ve never looked at F# (or ML) code before, it might feel like you’re being bombarded with syntax here. Here’s a crash course:
- Items separated by commas are tuples, which are rather like lists of set length.
- The square brackets enclose elements of a linked list. Said elements are delimited inside the brackets with semicolons.
- The curly braces are used for constructing values of record types, such as the
ThingandEdgetypes we defined above. Note that the fields of a record type are all given values using thekey = valuesyntax. Semicolons delimit one field from another. - The weird looking triangle thing is called the forward pipe operator, and it “feeds” the value on its left to the function on its right.
Alright. With our types defined, we are ready to write some functions.
let asOne strs = String.Join(" ", strs |> Array.ofSeq) let describePath edge = (sprintf "%A" edge.Dir).ToLower() |> sprintf "There is a %s going %s from here." edge.Portal let describePaths edges = edges |> Seq.map describePath |> asOne
These are some utility functions for describing and printing our area pathways. The describePath function will, when given an edge, return a string such as “There is a door going west from here.” We fill in terms such as “door” and “west” from the appropriate fields of the edge record. Its brother, describePaths, uses Seq.map to run describePath on every edge it’s given. Then it uses our asOne function to join all those descriptions into one string. (For more about mapping and concatenation, you should of course buy and read Land of Lisp!)
We’ll describe objects in an area similarly:
let describeObjects objs = let describeObj obj = sprintf "You see here %s %s." obj.Article obj.Name objs |> Seq.map describeObj |> asOne
Now that we can describe what’s in an area, we’ll write a look function to get a complete description of the area where the player’s at.
let look (world: World) = let player = world.Player let loc = world.Locations.[player.Location] let paths = world.Edges.[player.Location] |> describePaths let objs = world.Objects.[player.Location] |> describeObjects [loc; paths; objs] |> asOne
In turn, we describe the current area (by indexing into the world‘s Locations map), run describePaths on the area’s edges, and describeObjects on the area’s objects. Then we join it all into one string.
The next important function our game needs to handle is moving around. Our walk function will take care of making sure there’s an edge in the specified direction, and if so, changing the player’s location to the next area.
let walk dir (world: World) = let player = world.Player let attempt = world.Edges.[player.Location] |> List.filter (fun e -> e.Dir = dir) match attempt with | [] -> "You can't go that way." | edge :: _ -> world.Player.Location <- edge.Loc sprintf "You go %A..." dir
We start by filtering the list of an area’s edges by the passed-in direction. The filter function takes a function and a list, and applies the function to each element of the list. It spits out a new list containing only those elements for which that function (also called a predicate) returns true. Using a predicate to filter unwanted values from lists is a staple of functional programming.
Next we see the first instance of pattern matching, a very common F# technique. It’s superficially related to the switch statement in other languages, but it’s much more powerful. It takes an input and a set of rules to test against that input. Here, we check to see if the result of our filter was the empty list []. If so, it means the specified edge wasn’t in the area’s edge list, and is thus an invalid direction. If there was a result, we use that result as the player’s new location. You can read more about pattern matching here.
Now that we can look and move around, one more thing remains: Picking up objects.
let pickUp thing (world: World) = let player = world.Player let objs = world.Objects.[player.Location] let attempt = objs |> List.partition (fun o -> o.Name = thing) match attempt with | [], _ -> "You cannot get that." | thing :: [], things -> world.Player.PickUp thing world.Objects <- world.Objects.Remove player.Location world.Objects <- world.Objects.Add(player.Location, things) sprintf "You are now carrying %s %s." thing.Article thing.Name | _ -> "I don't know what you mean."
We employ a similar technique here as we did when checking for a valid direction. We want to make sure the object the user is trying to obtain actually exists in the area. For this, we use the partition function, which is just like filter except it returns both the matches and the misses. If the match list is empty, they didn’t specify an actual extant item. if there was, we add it to the player’s inventory (via its PickUp method). We also need to update the items in the area to reflect that item’s absence. Here I elected to do two updates, but you could just as easily use a mutable data structure. Note that I haven’t been sticking to a purely functional style in this program, but neither did the example in Land of Lisp. F# is friendly to both imperative and functional styles.
That’s the meat of our game; the rest is just handling I/O. I’ll put the rest of the code below, which hopefully is pretty self-explanatory. Note especially the use of match expressions.
let getLastWord (phrase: string) = let words = phrase.Split ' ' words.[words.Length - 1] let parsePickUp cmd world = let obj = getLastWord cmd pickUp obj world |> printfn "%s" let parseWalk (dir: string) world = let direction = match dir with | d when d.StartsWith "e" -> Some East | d when d.StartsWith "n" -> Some North | d when d.StartsWith "s" -> Some South | d when d.StartsWith "w" -> Some West | d when d.StartsWith "u" -> Some Up | d when d.StartsWith "d" -> Some Down | _ -> None match direction with | Some d -> walk d world |> printfn "%s" | None -> printfn "You can't go that way." let parseMovement cmd world = let dir = getLastWord cmd parseWalk dir world let rec handleInput world = printf "> " let input = Console.ReadLine().Trim().ToLower() match input with | "quit" | "exit" | "leave" -> printfn "Goodbye!" world, true | "look" -> world, false | "n" | "s" | "e" | "w" | "u" | "d" | "up" | "down" | "north" | "south" | "east" | "west" -> parseWalk input world world, false | cmd when cmd.StartsWith "go " || cmd.StartsWith "walk" || cmd.StartsWith "climb" -> parseMovement cmd world world, false | cmd when cmd.StartsWith "take" || cmd.StartsWith "get" || cmd.StartsWith "pick" -> parsePickUp cmd world world, false | other -> printfn "You can't %s here." other handleInput world let rec gameLoop world = look world |> printfn "%s" match handleInput world with | _, true -> () | w, _ -> gameLoop w new World() |> gameLoop
As is sometimes the case in F# code, due to how it must be written, it helps to “start with the bottom.” We define a gameLoop function that will prompt the user for input and refresh the description as needed. Note we defined it to be recursive (it can call itself), using let rec. The handleInput function is likewise recursive, and returns two values that gameLoop looks at: the updated value of the game world (containing, for example, the player’s new location after moving) and whether or not the game has finished. As you can see, if the player types any of “quit,” “exit,” or “leave,” we return true for the latter value. When gameLoop sees that, it simply returns () (pronounced “unit”), equivalent to returning in a void function in many languages.
The other actions the player can take are parsed out, and wired back to the functions we defined for them previously. We try to handle a couple different ways of saying the same thing, but of course the list isn’t exhaustive, so the last clause in our match acts as a catch-all.
That’s about all there is to it! Admittedly the gameplay is on the basic side, but it’s easy to see how it could be expanded. (And indeed the later game examples in Land of Lisp are more meaty.)