Rock Paper Scissors with core.async

11

Just for fun I implemented Rock Paper Scissors with core.async. Each player is modeled as a go process that generates moves on a channel. A judge is modeled as a go process that takes moves from each player via their channel and reports the match results on its own channel.

To start, let’s pull in core.async:

(require 'clojure.core.async :refer :all)

and define some helpful definitions:

(def MOVES [:rock :paper :scissors])
(def BEATS {:rock :scissors, :paper :rock, :scissors :paper})

Let’s make a player that randomly throws moves on an output channel:

(defn rand-player
  "Create a named player and return a channel to report moves."
  [name]
  (let [out (chan)]
    (go (while true (>! out [name (rand-nth MOVES)])))
    out))

Here, chan creates an unbuffered (0-length channel). We create a go process which you can think of as a lightweight thread that will loop forever creating random moves (represented as a vector of [name throw]) and placing them on the out channel.

However, because the channel is unbuffered, the put (>!) will not succeed until someone is ready to read it. Inside a go process, these puts will NOT block a thread; the go process is simply parked waiting.

To create our judge, we’ll need a helper method to decide the winner:

(defn winner
  "Based on two moves, return the name of the winner."
  [[name1 move1] [name2 move2]]
  (cond
   (= move1 move2) "no one"
   (= move2 (BEATS move1)) name1
   :else name2))

And now we’re ready to create our judging process:

(defn judge
  "Given two channels on which players report moves, create and return an
   output channel to report the results of each match as [move1 move2 winner]."
  [p1 p2]
  (let [out (chan)]
    (go
     (while true
       (let [m1 (<! p1)
             m2 (<! p2)]
         (>! out [m1 m2 (winner m1 m2)]))))
    out))

Similar to the players, the judge is a go process that sits in a loop forever. Each time through the loop it takes a move from each player, computes the winner and reports the match results on the out channel (as [move1 move2 winner]).

We need a bit of wiring code to start up the players and the judge and get the match result channel:

(defn init
  "Create 2 players (by default Alice and Bob) and return an output channel of match results."
  ([] (init "Alice" "Bob"))
  ([n1 n2] (judge (rand-player n1) (rand-player n2))))

And then we can play the game by simply taking a match result from the output channel and reporting.

(defn report
  "Report results of a match to the console."
  [[name1 move1] [name2 move2] winner]
  (println)
  (println name1 "throws" move1)
  (println name2 "throws" move2)
  (println winner "wins!"))

(defn play
  "Play by taking a match reporting channel and reporting the results of the latest match."
  [out-chan]
  (apply report (<!! out-chan)))

Here we use the actual blocking form of take (<!!) since we are outside a go block in the main thread.

We can then play like this:

user> (def game (init))
#'user/game
user> (play game)

Alice throws :scissors
Bob throws :paper
Alice wins!
user> (play game)

Alice throws :scissors
Bob throws :rock
Bob wins!

We might also want to play a whole bunch of games:

(defn play-many
  "Play n matches from out-chan and report a summary of the results."
  [out-chan n]
  (loop [remaining n
         results {}]
    (if (zero? remaining)
      results
      (let [[m1 m2 winner] (<!! out-chan)]
        (recur (dec remaining)
               (merge-with + results {winner 1}))))))

Which you’ll find is pretty fast:

user> (time (play-many game 10000))
"Elapsed time: 145.405 msecs"
{"no one" 3319, "Bob" 3323, "Alice" 3358}

Hope that was fun!

See: Rich Hickey podcast on core.async

See: Full gist

Comments

11 Responses to “Rock Paper Scissors with core.async”
  1. nice post!

    but, just so you know, the link to core.async is relative to your website, you might want to fix that real quick :)

  2. Nate says:

    Very slick!

    I haven’t put in my time with core.async yet, is there a way to construct a channel from/wrap a channel around a lazy sequence? The (while true …) forms in the `rand-player` & `judge` functions make me cringe a bit.

  3. Paul says:

    Nice post Alex, thanks. Helped connect the dots between core.async and Go for me. You’ve got me wondering at what “park” means if it’s not blocking, so I have to look at those sources.

    Nate, here’s my attempt at that wrapper:

    (defn wrapping-player
    [name moves]
    (let [out (chan)]
    (go
    (loop [[mv & remainder] moves]
    (when mv (>! out [name mv]) (recur remainder))
    (close! out)))
    out))

    Of course, judge doesn’t deal with closed channels, but that would be easy to fix. But a judge can now be initialized with:

    (judge
    (wrapping-player “player1″ (repeatedly #(rand-nth MOVES)))
    (wrapping-player “player2″ (repeatedly #(rand-nth MOVES))))

  4. Alex says:

    Not in core, but Brandon Bloom has done a whole rx style library with that. https://github.com/brandonbloom/asyncx. I like it but didn’t want to muddy with a dependency in this post.

  5. Alex says:

    @Paul – The trick with “parking” is that the go macro actually analyzes the code looking for channel ops (put and take) and if the operation cannot be completed, the go process is paused until work becomes available to satisfy that operation. That’s how it avoids consuming a thread.

    Doing actual blocking I/O operations inside those go blocks is a bad idea because you will block the thread pool running your go blocks. We have some plans for ways to detect at least some such blocking ops and disallow them (by knowing whether we’re in a go thread) but that’s not in there yet.

    Several people have already written go-loop combos and RX style libs and that’s great stuff. Rather than commit to any particular api around stuff like that, we like to see people experimenting with those ideas and some of them will likely end up in core or additional libs.

  6. Paul says:

    Thanks for the description. I’ve also heard Rich’s description on the @thinkrelevance podcast now, and it makes a lot more sense. Incidentally, the podcast significantly reduced my confidence in understanding the code when I get time to read it. :-)

  7. Matt says:

    Thanks for this post! It is a great intro into core.async and I recommend it. As an exercise for the reader, I changed play-many to use a seq:

    (defn play-seq
    “Return a seq of match results”
    [out-chan]
    (drop 1 (iterate (fn [f] (<!! out-chan)) nil)))

    (defn play-many [out-chan n]
    "Return a map of results"
    (frequencies (map #(nth % 2) (take n (play-seq out-chan)))))

  8. Djui says:

    Why do you use `(require ‘clojure.core.async :refer :all)` instead of `(use ‘clojure.core.async)`?

  9. Alex Miller says:

    @Djui: generally these days, “use” is considered somewhat deprecated. I always use “require” rather than “use”. You’re correct that it would be perfectly acceptable as an alternative here though.

  10. Alex says:

    Would it be correct to say that core.async is a new tool in Clojure’s time management/process coordination toolbox? If so, it’s unfortunate that the rand-player doesn’t introduce some random delays. It would better illustrate how the core.async helps to model time.

  11. Alex says:

    @Alex – if you’re referring to state/identity constructs like refs, agents, etc then I would say no. core.async is not about managing state or modeling time. It is about process coordination and time is certainly involved (esp when processes must rendezvous for handoff on unbuffered channels). However, I’d say channels (queues in general) are mostly about decoupling components and abstracting away time, not modeling it.