Writing a text adventure in Clojure

266 views
Skip to first unread message

Will Duquette

unread,
Mar 29, 2018, 6:45:02 PM3/29/18
to Clojure
I'm an experienced programmer, but a Clojure newbie; as a beginner project, I'm looking into how one would idiomatically write a text adventure of sorts in Clojure.  I'm less interested in producing a playable game than I am in learning how to do such a thing in a proper functional style.

Suppose in this game I have a room whose description changes based on a global flag.  For example, there's something in the Fancy Room that you won't notice until you've reached the major plot point.

The world map is (for the sake of argument) a hash-map whose keys are the room IDs and whose values are room records, where each record is a hash-map.

(def world {:fancy-room {:name "Fancy Room" :description "This is a fancy room." ...}})

I'm aware that I could use a (defstruct) or (defrecord); I'm keeping it simple for now.  Then, the flags are saved in a ref; the intent is that mutable set is segregated, so that it can more easily be written to a save file.

;; Global set of flags
(def flags (ref #{})

(defn flag-set [flag]
   (dosync (alter flags conj flag))

;; When the major plot point is reached
(flag-set :major-plot-point-reached)
 
Normally, to describe a room you just return its :description.

(defn describe [room] (:description (world get room)))

But for the :fancy-room, the returned description depends on the global flag, and it will be specific to :fancy-room.  I could add this logic directly to the (describe) function's body, but that would be ugly.  What I'd like to do is attach a lambda to the :fancy-room in some way.  The (describe) function looks for a :describer, and if it's there it calls it; and if not it just returns the :description:

(defn describe [entity]
    (if (:describer entity) 
      ((:describer entity) entity)
      (:description entity)))

Question 1: this works, but it looks ugly to me; I figure there's a better, more idiomatic way to do this kind of thing that's probably obvious to anyone with any real experience.  Multimethods, maybe?  Define a Room protocol, then let most rooms be NormalRoom records, but let :fancy-room be a FancyRoom record?

Question 2: Whatever code actually computes the description, it will need access to the :major-plot-point-reached flag.  What's the cleanest way to give the description code access to the flags ref?  It could simply access "@flags" directly:

(if (:major-plot-point-reached @flags) 
  "This is a fancy room.  Hey, that light sconce looks movable!"
  "This is a fancy room.")

But that doesn't seem properly functional.  Would it be better to pass the game state into each method?

(defn describe [entity state]
  (if (:describer entity)
     ((:describer entity) entity state)
     (:description entity)))

Any ideas?

Will Duquette

unread,
Mar 29, 2018, 7:01:00 PM3/29/18
to Clojure
Aha!  How about this, to cut the Gordian knot:

  1. The fancy room's :description isn't necessarily a simple string.  It can be a vector of specs, where each spec is a text snippet or a pair containing a predicate function and a text snippet.
  2. The (describe) function takes two arguments, the global state and the room record.
  3. It steps through the specs, including only those snippets whose predicate is true.  The predicates, of course, are predicates on the global state.
The room is then a simple object; and the describe method remains purely functional.

Timothy Baldridge

unread,
Mar 29, 2018, 7:40:34 PM3/29/18
to clo...@googlegroups.com
You often don't even need functions for this sort of thing. This is what is often called "data driven" programs. Simply define this as a hashmap with :description, :items, etc and then a single function that introspects this data and figures out how to describe the room. 

Also you might want to read up on Entity Component Systems. They are often implemented in OOP languages, but they really are not OOP at all. Here's a good talk on the subject: https://www.youtube.com/watch?v=TW1ie0pIO_E

--
You received this message because you are subscribed to the Google
Groups "Clojure" group.
To post to this group, send email to clo...@googlegroups.com
Note that posts from new members are moderated - please be patient with your first post.
To unsubscribe from this group, send email to
clojure+unsubscribe@googlegroups.com
For more options, visit this group at
http://groups.google.com/group/clojure?hl=en
---
You received this message because you are subscribed to the Google Groups "Clojure" group.
To unsubscribe from this group and stop receiving emails from it, send an email to clojure+unsubscribe@googlegroups.com.
For more options, visit https://groups.google.com/d/optout.



--
“One of the main causes of the fall of the Roman Empire was that–lacking zero–they had no way to indicate successful termination of their C programs.”
(Robert Firth)

James Reeves

unread,
Mar 29, 2018, 9:10:56 PM3/29/18
to clo...@googlegroups.com
Often it's better to store the entire game state as one large, immutable data structure.

Atoms are usually preferred over refs in most cases.

When you want polymorphism over a map, the most common solution is to use protocols and records.


--
You received this message because you are subscribed to the Google
Groups "Clojure" group.
To post to this group, send email to clo...@googlegroups.com
Note that posts from new members are moderated - please be patient with your first post.
To unsubscribe from this group, send email to
clojure+unsubscribe@googlegroups.com
For more options, visit this group at
http://groups.google.com/group/clojure?hl=en
---
You received this message because you are subscribed to the Google Groups "Clojure" group.
To unsubscribe from this group and stop receiving emails from it, send an email to clojure+unsubscribe@googlegroups.com.
For more options, visit https://groups.google.com/d/optout.



--
James Reeves

Tim Visher

unread,
Mar 30, 2018, 8:41:27 AM3/30/18
to Clojure
http://landoflisp.com/ is specifically about coding games in Lisp, in case you're into books. :)

--

Gary Johnson

unread,
Mar 30, 2018, 11:01:59 AM3/30/18
to Clojure
Hi Will,

Welcome to the wide world of functional programming, where data flows, and functions transmute without destroying their inputs.

As some others in this thread have already suggested, a general approach to viewing any problem space from a functional perspective is to imagine your problem as a pipeline of data transformations from start to finish. To the greatest extent possible, try to represent your data transformations as pure functions over immutable arguments rather than holding any global state in a var and mutating it at each time step.

In the context of a game program, consider depicting all of your game state as a single hierarchical map. Then thread this map through each function and return a new derived map as each output result.

```clojure
(def world-state {:flags #{}
                        :location :fancy-room
                        :inventory #{:sledgehammer :car-keys :lamp}
                        :rooms {:creepy-corridor {:name "Creepy Corridor" :description "Whoa! Spooky..." :items #{:halloween-mask :monkeys-paw :troll-doll}}
                                    :fancy-room {:name "Fancy Room" :description "The fanciest!" :items #{:chandelier :toy-poodle :looking-glass}}
                                    ...}})

```
Let's imagine this is the initial world state. When your application starts up, it enters into the game loop (which would be a nice, friendly recursion, of course). In each round of the game loop, you call a sequence of functions to get the description of the current room, print it out, ask the user for the next command, process that command, and then recurse back to the top of the next iteration of the game loop. Although printing to stdout and reading from stdin are obviously side effecting functions, you should be able to keep your functions for retrieving the room description and processing the user's command as pure functions of the current world-state. Just make sure that the functions that process a user's command always return a new copy of the world-state value. When you want to save the game, just write out the world-state to an EDN file. By reading it back in again later, you can restore the game to exactly the same state it was in before.

In response to your specific question about having a different room description after some event has happened, consider this approach:

```clojure
(defn do-important-plot-changing-action [world-state]
    (-> world-state
         (update :flags conj :major-plot-point-reached)
         (assoc-in [:rooms :fancy-room :description] "Gosh! This room isn't nearly as fancy anymore!")))
```

That is, the function just uses assoc, assoc-in, update, and update-in to modify the values in the new map and return it for use in future iterations of the game loop. And that's pretty much all there is to it. You just pass the changing world state down through the stack rather than mutating it in place on the heap. (Ultimately, the world-state data structure is, of course, actually stored on the heap, and you are just passing references to it through the stack, but I hope you get my meaning here.)

Good luck and happy hacking!

Karsten Schmidt

unread,
Mar 30, 2018, 2:12:04 PM3/30/18
to clo...@googlegroups.com
Hi Will,

have a look at this workshop repository, in which we developed a
simple text adventure framework:
https://github.com/learn-postspectacular/resonate-workshop-2014

Hth! K.
> --
> You received this message because you are subscribed to the Google
> Groups "Clojure" group.
> To post to this group, send email to clo...@googlegroups.com
> Note that posts from new members are moderated - please be patient with your
> first post.
> To unsubscribe from this group, send email to
> clojure+u...@googlegroups.com
> For more options, visit this group at
> http://groups.google.com/group/clojure?hl=en
> ---
> You received this message because you are subscribed to the Google Groups
> "Clojure" group.
> To unsubscribe from this group and stop receiving emails from it, send an
> email to clojure+u...@googlegroups.com.
> For more options, visit https://groups.google.com/d/optout.



--
Karsten Schmidt
http://postspectacular.com | http://thi.ng | http://toxiclibs.org
> --
> You received this message because you are subscribed to the Google
> Groups "Clojure" group.
> To post to this group, send email to clo...@googlegroups.com
> Note that posts from new members are moderated - please be patient with your
> first post.
> To unsubscribe from this group, send email to
> clojure+u...@googlegroups.com
> For more options, visit this group at
> http://groups.google.com/group/clojure?hl=en
> ---
> You received this message because you are subscribed to the Google Groups
> "Clojure" group.
> To unsubscribe from this group and stop receiving emails from it, send an
> email to clojure+u...@googlegroups.com.
> For more options, visit https://groups.google.com/d/optout.



--
Karsten Schmidt
http://thi.ng

Will Duquette

unread,
Apr 2, 2018, 1:06:00 PM4/2/18
to Clojure
Howdy!

Yeah, I get this; and for most of the rooms it's plenty good enough.  It's the ones that are complicated that concern me.  Writing text adventures has been one of my standard ways of learning new languages, going back to the mid-80s to a simple text adventure I wrote—in LISP, actually—on an 8-bit CP/M-80 system, so I've a rich appreciation for how complicated things can get, and it's that richness I'm wanting to be able to capture.

In OOP terms, I find that most rooms are just Room objects with normal semantics; and the ones that aren't are different in different ways.  I almost need a new class for each "fancy room".  That's the kind of richness I'm looking for.

For more options, visit this group at
http://groups.google.com/group/clojure?hl=en
---
You received this message because you are subscribed to the Google Groups "Clojure" group.
To unsubscribe from this group and stop receiving emails from it, send an email to clojure+u...@googlegroups.com.

For more options, visit https://groups.google.com/d/optout.

Will Duquette

unread,
Apr 2, 2018, 1:06:19 PM4/2/18
to Clojure
Thanks!

For more options, visit this group at
http://groups.google.com/group/clojure?hl=en
---
You received this message because you are subscribed to the Google Groups "Clojure" group.
To unsubscribe from this group and stop receiving emails from it, send an email to clojure+u...@googlegroups.com.

For more options, visit https://groups.google.com/d/optout.



--
James Reeves

Will Duquette

unread,
Apr 2, 2018, 1:09:08 PM4/2/18
to Clojure
Thanks! I've glanced at Land of Lisp, actually, some while back.  I might even have a copy.  

To be clear, it isn't LISP that's giving me problems.  I've been dabbling with LISP on-and-off since the '80's, and I've read a couple of Paul Graham's books.  I understand code-as-data, and lambdas, and I even grok macros.  It's specifically Clojure's functional programming model that I need to wrap my head around. 

For more options, visit this group at
http://groups.google.com/group/clojure?hl=en
---
You received this message because you are subscribed to the Google Groups "Clojure" group.
To unsubscribe from this group and stop receiving emails from it, send an email to clojure+u...@googlegroups.com.

Will Duquette

unread,
Apr 2, 2018, 1:29:54 PM4/2/18
to Clojure
Gary,

Thanks for the detailed answer.

What you're describing is pretty much exactly what I'm trying to do, including the game loop you mention.  The basic implementation is pretty simple: a player who can move from room to room, picking up objects, putting them down, and so forth, and the world-state var you describe captures it just fine.  As I noted in a response to someone else, I've been writing adventure games as an exercise in learning languages since the mid-80's.  Because I've done it so many times, I'm looking not just for how to do the basic, but how to do the complicated stuff: places that are also objects that can move (e.g., vehicles), places that change, rooms and items that have complicated logic associated with them.

You suggest:

When you want to save the game, just write out the world-state to an EDN file. By reading it back in again later, you can restore the game to exactly the same state it was in before.

And for this case, this is probably just fine.  Putting my software engineer hat on, I'd prefer to have the data that can change segregated from the data that doesn't, so as to save the minimal amount of data...and so that the same save file can work with successive versions of the code.  If I fix a typo in a room description, I don't want the old typo to still be present when I load a saved game!  But this is an exercise, and I probably don't need to worry about that.

You suggest:

```clojure
(defn do-important-plot-changing-action [world-state]
    (-> world-state
         (update :flags conj :major-plot-point-reached)
         (assoc-in [:rooms :fancy-room :description] "Gosh! This room isn't nearly as fancy anymore!")))
```

I take your point here, about how to update the world-state when something changes, and I thank you for the example.  

In terms of the game logic, though, this is precisely how I *don't* want to solve the problem of a room with complex description logic.  In fact, I'd regard it as an anti-pattern.  There are a couple of reasons:

1. Just as an aesthetic thing, I want to keep the details about a given room all in one place, with the room definition.  If I've got a bug related to a particular room, I want to know where to go looking, and not have to look all over the code for places where I mention :fancy-room.  But the do-important-plot-changing-action function might be called in some other context altogether.  It's precisely my goal to use the :major-plot-point-reached flag as a way to decouple the :fancy-room logic from the logic that determines that the plot point has been reached.

2. Suppose the description of the room might be affected by half-a-dozen different flags.  That would be an exceptional case; but adventure games are all about exceptional cases.  Using your function up above, it couldn't simply say, "Oh, the :fancy-room description is this now."  It would need to query the current state of the :fancy-room and decide what makes sense now.

What we have here is an object whose state depends on the state of the exterior world.  In my experience (in other problem domains) it's much easier to simply recompute the object's state as a function of the state of the world, rather than to compute the change to the object as a function of the change to the state of the world.  Your do-important-plot-changing-action function does the latter.

But in general, yeah, this approach makes sense.  We've agreed on the menu, now we're arguing over the recipes. :-)

Will

Will Duquette

unread,
Apr 2, 2018, 1:30:04 PM4/2/18
to Clojure
Thanks!

Will Duquette

unread,
Apr 2, 2018, 1:39:01 PM4/2/18
to Clojure
Spent the weekend pondering all of this, and here's the way I think I want to do it.

1. The world-state is stored in an atom, and updated much as Gary Johnson suggests.

2. I define a multi-method, (describe-room [room world-state]), that is responsible for computing the current description of the room: what it looks like, what items are in it, whatever the player can currently see or has just noticed.

3. The multi-method's dispatch function looks for the :description.  

3a. If it's a string, the dispatch function returns nil; and I get the default implementation: it describes the room in the simplest possible way.

3b. If the :description is undefined or nil, the dispatch function returns the room's id, e.g., :fancy-room.  The :fancy-room definition includes an implementation of the multi-method that's specific to the :fancy-room.  I wouldn't expect to use this approach all that often, but it should be flexible enough to do anything that comes up.

3c. If I find that I have a number of standard flavors for how to describe a room, I can define the dispatch function accordingly and add additional implementations.

There are other ways to skin this cat; but this one seems to give me maximum simplicity for the normal case, maximum flexibility for special cases, and it lets me keep all of the logic related to a single room in one place in the code.

Comments?


On Thursday, March 29, 2018 at 3:45:02 PM UTC-7, Will Duquette wrote:

Mikhail Gusarov

unread,
Apr 2, 2018, 2:30:53 PM4/2/18
to clo...@googlegroups.com
Hello Will.

You can simplify it further:

1. Define a multimethod always dispatching by room id.
2. Create a :default implementation. It will be called for non-fancy rooms.
3. Create an implementation for :fancy-room. It will be preferred over :default for it.

If you ever have a group of rooms with a similar fancy description, use a hierarchy

(derive :fancy-room1 :fancy-group)
(derive :fancy-room2 :fancy-group)

and create an implementation for :fancy-group.

Regards, Mikhail.

Will Duquette

unread,
Apr 2, 2018, 2:44:52 PM4/2/18
to Clojure
Excellent.  Yeah, I was thinking I was probably going to too much trouble to get the "nil" value.  And the (derive) solution is very nice.

Thanks very much!

Will Duquette

unread,
Apr 2, 2018, 5:13:30 PM4/2/18
to Clojure
A style question.

I've got a world data object, which I pass into my functions.  It looks like this:

```clojure
(def world 
  (atom {:flags #{:want-umbrella}  
         :location :home 
         :inventory #{...}  
         :map {:home {...} :patio {...}))  
```

My default describe-room method looks like this:

```clojure
(defmethod describe-room :default [world id]
  (let [r (-> world :map id)]
    ;; TODO: display room contents.
    ;; TODO: wrap text neatly
    (println (:name r))
    (println (:description r))))
```

In this function, I retrieve the room using the syntax `(-> world :map id)`.  I might similarly check whether the :want-umbrella flag is set using the syntax `(-> world :flags :want-umbrella)`.  Is that considered good style?  It leaves the structure of the "world" object completely visible, which could make it hard to change in the future.  Would I do better to define a couple of functions, like this?

```clojure
(defn get-room [world id] (-> world :map id))
(defn flag-set? [world id] (-> world :flags id))
```

Or would that be just complicating things to no good end?

Thanks!


On Thursday, March 29, 2018 at 3:45:02 PM UTC-7, Will Duquette wrote:
Reply all
Reply to author
Forward
0 new messages