Rock, Paper, Scissors, Go!

3 minute read

When core.async was new and I was kinda learning Clojure, I bookmarked Alex Miller’s blog post about implementing Rock, Paper, Scissors with channels. So now I’ve done the same in Go.

Here’s what I did and what I was thinking.

type move string

const (
	rock     move = "rock"
	paper    move = "paper"
	scissors move = "scissors"
)

var moves = [3]move{rock, paper, scissors}

I made a type for the moves, and a var for all of them, so I could make random moves:

import "math/rand"

move := rand.Intn(len(moves))

Then added a function that makes an unbuffered channel that produces random moves.

type play struct {
	move move
	name string
}

func makePlayer(name string) <-chan play {
	out := make(chan play)
	go func() {
		for { // ever
			i := rand.Intn(len(moves))
			out <- play{name: name, move: moves[i]}
		}
	}()
	return out
}

So if we have 2 of those, we can have another goroutine that is popping “plays” and deciding the winner. At first I just had them return the winner’s name, but I liked the reporting Alex’s example had so I later added the name and play together so results could be aggregated by the process downstream from the “judge”.

type result struct {
	play1, play2 play
	winner       string
}

func makeJudge(player1, player2 <-chan play) <-chan result {
	out := make(chan result)
	go func() {
		for { // ever
			p1 := <-player1
			p2 := <-player2
			out <- result{play1: p1, play2: p2, winner: winnerIs(p1, p2)}
		}
	}()
	return out
}

My judge’s goroutine uses a winnerIs function that returns a string, the winner’s name. At first I implemented it like this:

func winnerIs(play1, play2 play) string {
	if play1.move == play2.move {
		return "no one"
	}
	switch play1.move {
	case rock:
		if play2.move == scissors {
			return play1.name
		}
		return play2.name
	case paper:
		if play2.move == rock {
			return play1.name
		}
		return play2.name
	case scissors:
		if play2.move == paper {
			return play1.name
		}
		return play2.name
	}
	return "good job, you broke it!"
}

But that felt gross. It didn’t read cleanly, and it had an unnecessary return at the end that would never be reached. So I beefed up my test so I knew I had the entire game space mapped:

func TestWinnerIs(t *testing.T) {
	rockPlay := play{name: "rock", move: rock}
	paperPlay := play{name: "paper", move: paper}
	scissorsPlay := play{name: "scissors", move: scissors}

	allMatches := []result{
		{rockPlay, rockPlay, "no one"},
		{rockPlay, paperPlay, "paper"},
		{rockPlay, scissorsPlay, "rock"},
		{paperPlay, rockPlay, "paper"},
		{paperPlay, paperPlay, "no one"},
		{paperPlay, scissorsPlay, "scissors"},
		{scissorsPlay, rockPlay, "rock"},
		{scissorsPlay, paperPlay, "scissors"},
		{scissorsPlay, scissorsPlay, "no one"},
	}

	for _, match := range allMatches {
		winner := winnerIs(match.play1, match.play2)
		if winner != match.winner {
			t.Errorf("%s should have beat %s but got %s", match.play1.move, match.play2.move, winner)
		}
	}
}

Now I know I can refactor my winnerIs function and ensure it still works the same (I re-read Alex’s blog post at this time, too).

var wins = map[move]move{
	rock:     scissors,
	paper:    rock,
	scissors: paper,
}

func winnerIs(play1, play2 play) string {
	if play1.move == play2.move {
		return "no one"
	}
	if wins[play1.move] == play2.move {
		return play1.name
	}
	return play2.name
}

This simplified the code and more coherently modeled the game. Each move has a move it ties, it beats, else it loses.

So now if I tie all this together in the main function:

player1 := makePlayer("Amon")
player2 := makePlayer("Belinda")
judge := makeJudge(player1, player2)

totals := make(map[string]int)
for i := 0; i < 100000; i++ {
	r := <-judge
	fmt.Printf("%s throws %s\n", r.play1.name, r.play1.move)
	fmt.Printf("%s throws %s\n", r.play2.name, r.play2.move)
	fmt.Printf("%s wins!\n", r.winner)
	totals[r.winner]++
}
fmt.Println(totals)

100,000 games takes 550ms, with reporting each match.

$ go build
$ time ./rps-chans
...
Amon throws scissors
Belinda throws rock
Belinda wins!
Amon throws paper
Belinda throws scissors
Belinda wins!
map[Amon:33192 no one:33219 Belinda:33589]
./rps-chans  0.55s user 0.56s system 118% cpu 0.942 total

And with the printing each game removed:

$ time ./rps-chans
map[Belinda:33400 Amon:33346 no one:33254]
./rps-chans  0.19s user 0.10s system 179% cpu 0.162 total

Bump it to a million games:

time ./rps-chans
map[Amon:333318 Belinda:333814 no one:332868]
./rps-chans  2.19s user 1.30s system 196% cpu 1.776 total

That’s what we would expect, right? Using the concurrency primitives in Go makes it easy for our program to leverage multiple cores.

Find the full source on GitHub.

If you want to tinker, check out this Go playground.

Leave a Comment