Adventures in Clojure: Nearly Constant Data

Adventures in Clojure: Nearly Constant Data

As I noted last week, the Clojure way is to keep immutable (unchanging) data separate from the mutable data. Thus, you keep the network of rooms distinct from the data structure that indicates where the player and other things are at the present time. The latter changes while the game runs, and the former doesn’t.

At least, that’s the theory. As I showed a couple of weeks ago, you can define the world map as a single, immutable data structure, and never change it thereafter. But there are two problems with this.

First, of course, is that the game map really does change over time. When you get a key, you can unlock a door, opening up a new area in the map. After you blow up Flood Control Dam #3, it washes away the bridge you used to be able to cross to get to the Friendly Forest. However, there are number of ways to handle this without actually changing the map data at run-time. I’ll talk about this in future posts.

The more basic problem is that you might not want to define the entire world map as a single data structure. It will need to be one, ultimately, but you might not want to define it that way. For example, you want might to break it up into a number of files, instead of putting it all in one. Contrast these two implementations:

(def world {
  :home {:name "Home Base" 
         :description "Your home..."
         :links {:n :street :s :bedroom}
         :initial-contents {:couch :soccer-ball :house-key}}
  :street {:name "The Street"
           :description "The mean street outside your front door."
           :links {:s :home :n :tavern ...}
           :initial-contents {:garbage}}
  ;; This would continue with all of the room definitions, 
  ;; which might get quite large.
  ...})

On the other hand, you might write code like this:

(define-room :home "Home Base" {:n street :s :bedroom}
  "Your home, which has seen better days."
  :initial-contents {:couch :soccer-ball :house-key})

(define-room :street "The Street" {:s :home :n :tavern ...}
   "The mean street outside your front door."
   :initial-contents {:garbage})

This second form is more concise, and makes it easier to see what’s important. Every room has a unique keyword, a brief name, a detailed description, and a set of links to other rooms. Some rooms will have other attributes, such as an “initial contents”. So let’s use positional arguments for the things that all rooms have, and named arguments for the optional things.

However, we’re no longer building an immutable data structure. Instead, each define-room function is adding a room to some kind of “world” map piece-by-piece. And doing that in Clojure takes a little extra work.

The idiomatic approach, I gather (and any experienced Clojure programmers are urged to chime in) is to create an “atom”, and let define-room update the atom. And an atom is an entity that contains some Clojure value in such a way that any updates to the value are “atomic”, that is, if two threads are using the same atom, neither thread will ever see the atom in an intermediate state.

First, you define an atom containing an empty map:

(def world (atom {}))

You can retrieve the map by using the “@” character. For example, to print the contents of the world map, you could say this:

(print @world)

Remember that a Clojure map is a mapping from keys to values. In this case, the key is a room keyword, and the value is the map containing the room’s attributes. Consequently, define-room‘s job is to build the attribute map and insert it into the atom’s map of rooms.

The code to insert an entry into the atom’s map is complicated, and it’s worth looking at it in detail. Let’s suppose we’ve already added some rooms to world, and now we want to add :home. If all we wanted was a new map value with :home added, we could simply use the assoc function, which adds a new key and value to a map, and returns the new map. (The old map is always unchanged.)

(def new-world (assoc @world :home {:name "Home Base" ...})

The variable new-world now contains all of the entries that were in @world, plus the entry for :home. So how do we update a world map in place? It looks like this:

(swap! world assoc :home {:name "Home Base" ...})

The swap! function is updating the atom called world. It is using the assoc function to do it; and it is passing the map stored in the world atom to
assoc as its first argument. In short, this swap! is doing something like this:

let @world = (assoc @world :home {:name "Home Base" ...})

though of course, that’s not valid Clojure code.

Passing functions to other functions is an extremely common paradigm in Clojure, and it takes some getting used to.


Browse Our Archives