Yahtzee (#19)

In my family, we use games to spend time together and even learn from. When the kids in the family need help spelling, we get the Scrabble board out. When you're learning to count, we break out Yahtzee. Yahtzee remains a favorite for many of us adults because it's fast and we can talk over it. We spend many evenings rolling the dice and keeping score.

This week's quiz is to make a program that plays Yahtzee.

The rules of Yahtzee are not complex. On your turn, you roll five dice. If desired, you make pick up any number of them and re-roll. You're allowed up to two re-rolls or three total rolls counting the original toss. When you're finished rolling, you score the roll in any "category" you like.

A game of Yahtzee is 13 turns. Sometimes players play alone to see how good of a score they can get. Other times, they play against others: High score wins.

Yahtzee has thirteen categories where you can place a score, one for each roll. The categories are divided into two sections. The "top" section (located on the top half of the score card) contains: Ones, Twos, Threes, Fours, Fives and Sixes. To score in those categories, you simply count the total of all dice matching the name.

The "bottom" categories are more varied. There is a Three of a Kind and Four of a Kind. To score in either of those you need the indicated three or four of any one number shown on the dice. As long as you have the needed duplicates, you score the total of all your dice.

There is a category for a Full House, worth 25 points. This requires two of one number and three of another to be shown on the dice.

There's also Small Straight and Large Straight. They require a sequence to be shown on the dice. For example, 2, 3, 4 and 5 can be a Small Straight. The Small Straight requires a run of at least four dice and is worth 30 points. Large Straight requires all five dice to be in order and is worth 40 points.

The supreme category is the Yahtzee. This requires a five of a kind roll. All dice must show the same number. When you have that, you can score 50 points here.

If you roll more than one Yahtzee in a game, after scoring the first one for 50, the subsequent Yahtzees give you a 100 bonus point each. You must still count these Yahtzees somewhere; either in the top for the appropriate number or if that is full, it can be used as a wildcard for any category in the bottom, scored normally.

Finally, there's is a catch-all Chance category. You can score any roll here simply by totaling all the dice.

You must score each and every roll in exactly one category. This means it is common to "scratch" categories late in the game, when you cannot get the right combination. A scratch is worth zero points.

When the categories are filled in, the top and bottom are totaled separately. If the top is greater than or equal to 63 points, you add in a 35 point "bonus". The two section totals are then combined to give the overall score.

That's all there is to Yahtzee, but your program can be more complicated if you like. Those looking for an bigger challenge should consider these additions:

1. Add a computer AI that takes turns with the player. The more
intelligently it plays the better.

2. My favorite variation of Yahtzee is Triple Yahtzee. It's played just
like normal Yahtzee, except that you fill in three columns of categories
at once, placing scores in any column you please. At the end of the
game, the second column's score is doubled and the third is tripled,
then all three columns are totaled to create the overall score. Add
support for Triple Yahtzee.


Quiz Summary

Well, there wasn't any discussion or submissions this week, save my own. Guess that means you'll have to suffer through my code this week.

There are really two aspects to a game of Yahtzee: Dice rolling and score keeping. With dice rolling, you need to be able to handle a roll of multiple dice and re-rolls of selected dice. You'll need to be able to display and examine this roll, of course. Then, you'll need to be able to sum all the dice or just certain dice.

Finally, when it's time to score those rolls, you need some way to match die against patterns. Three of a Kind, Four of a Kind, Full House and Yahtzee are repetition patterns. You're looking for any number to appear on the dice a set number of times. With Full House, you're actually looking for two different dice to appear a different number of times.

Small Straight and Large Straight need another form of pattern matching: Sequence patterns. Here you're searching for a run on the dice of a specified length, but the actual numbers in the run don't matter.

Here's the class I coded up to cover those needs:

ruby
# Namespace for all things Yahtzee.
module Yahtzee
# An object for managing the rolls of a Yahtzee game.
class Roll
#
# Create an instance of Roll. Methods can then be used the
# examine the results of the roll and re-roll dice.
#
def initialize( )
@dice = Array.new(5) { rand(6) + 1 }
end

# Examine the individual dice of a Roll.
def []( index )
@dice[index]
end

# Count occurrences of a set of pips.
def count( *pips )
@dice.inject(0) do |total, die|
if pips.include?(die) then total + 1 else total end
end
end

# Add all occurrences of a set of pips, or all the dice.
def sum( *pips )
if pips.size == 0
@dice.inject(0) { |total, die| total + die }
else
@dice.inject(0) do |total, die|
if pips.include?(die) then total + die else total end
end
end
end

#
# Examines Roll for a pattern of dice, returning true if found.
# Patterns can be of the form:
#
# roll.matches?(1, 2, 3, 4)
#
# Which validates a sequence, regardless of the actual pips on
# the dice.
#
# You can also use the form:
#
# roll.matches?(*%w{x x x y y})
#
# To validate repititions.
#
# The two forms can be mixed in any combination and when they
# are, both must match completely.
#
def matches?( *pattern )
digits, letters = pattern.partition { |e| e.is_a?(Integer) }
matches_digits?(digits) and matches_letters?(letters)
end

# Re-roll selected _dice_.
def reroll( *dice )
if dice.size == 0
@dice = Array.new(5) { rand(6) + 1 }
else
indices = [ ]
pool = @dice.dup
dice.each do |d|
i = pool.index(d) or
raise ArgumentError, "Dice not found."
indices << i
pool[i] = -1
end

indices.each { |i| @dice[i] = rand(6) + 1 }
end
end

# To make printing out rolls easier.
def to_s( )
"#{@dice[0..-2].join(',')} and #{@dice[-1]}"
end

private

# Verifies matching of sequence patterns.
def matches_digits?( digits )
return true if digits.size < 2

digits.sort!
test = @dice.uniq.sort
loop do
(0..(@dice.length - digits.length)).each do |index|
return true if test[index, digits.length] == digits
end

digits.collect! { |d| d + 1 }
break if digits.last > 6
end

false
end

# Verifies matching of repetition patterns.
def matches_letters?( letters )
return true if letters.size < 2

counts = Hash.new(0)
letters.each { |l| counts[l] += 1 }
counts = counts.values.sort.reverse

pips = @dice.uniq
counts.each do |c|
unless match = pips.find { |p| count(p) >= c }
return false
end
pips.delete(match)
end

true
end
end
end

The descriptions and comments above should make that class pretty transparent, I hope.

The method matches?() is my dice pattern matching system. It understands arrays of letters and/or numbers, feeding the correct sets to the private methods matches_digits?() and matches_letters?().

Letters are used to check repetition. For example, the pattern used to match a Full House is %w{x x x y y}. That requires three of any one number and two of a different number.

Numbers are used to check sequence patterns. As another example, the pattern to match a Small Straight is [1, 2, 3, 4]. That requires that there be four different numbers shown on the dice, each exactly one apart from one of the other numbers. Which numbers are shown doesn't matter.

As an interesting aside, the above class proved tricky to unit test. Well, for me anyway. I didn't end up posting my tests because I was ashamed of the hack I used. Perhaps this should be a separate quiz...

Scoring is pretty simple. We just need a Scorecard object that holds categories we can add points to and totals based on those categories. We need to be able to print that, of course, and allow the user to identify categories using some form of label. Here's what I came up with for that:

ruby
# Namespace for all things Yahtzee.
module Yahtzee
# A basic score tracking object.
class Scorecard
# Create an instance of Scorecard. Add categories and totals,
# track score and display results as needed.
def initialize( )
@categories = [ ]
end

#
# Add one or more categories to this Scorecard. Order is
# maintained.
#
def add_categories( *categories )
categories.each do |cat|
@categories << [cat, 0]
end
end

#
# Add a total, with a block to calculate it from passed a
# categories Hash.
#
def add_total( name, &calculator )
@categories << [name, calculator]
end

#
# The primary score action method. Adds _count_ points to the
# category at _index_.
#
def count( index, count )
@categories.assoc(category(index))[1] += count
end

# Lookup the score of a given category.
def []( name )
@categories.assoc(name)[1]
end

# Lookup a category name, by _index.
def category( index )
id = 0
@categories.each_with_index do |(name, count_or_calc), i|
next unless count_or_calc.is_a?(Numeric)
id += 1
return @categories[i][0] if id == index
end

raise ArgumentError, "Invalid category."
end

# Support for easy printing.
def to_s( )
id = 0
@categories.inject("") do |disp, (name, count_or_calc)|
if count_or_calc.is_a?(Numeric)
id += 1
disp + "%3d: %-20s %4d\n" % [id, name, count_or_calc]
else
disp + " %-20s %4d\n" %
[name, count_or_calc.call(to_hash)]
end
end
end

# Convert category listing to a Hash.
def to_hash( )
@categories.inject(Hash.new) do |hash, (name, count_or_calc)|
if count_or_calc.is_a?(Numeric)
hash[name] = count_or_calc
end
hash
end
end
end
end

Using that isn't too tough. Create a Scorecard and add categories and totals to it. Categories are really just a name that can be associated with a point count. Totals are passed in as a block of code that can calculate the total as needed. The block is passed a hash of category names and their current points, when called. Moving into the "main" section of my program, we can see how I use this to build Yahtzee's Scorecard:

ruby
# Console game interface.
if __FILE__ == $0
# Assemble Scorecard.
score = Yahtzee::Scorecard.new()
UPPER = %w{Ones Twos Threes Fours Fives Sixes}
UPPER_TOTAL = lambda do |cats|
cats.inject(0) do |total, (cat, count)|
if UPPER.include?(cat) then total + count else total end
end
end
score.add_categories(*UPPER)
score.add_total("Bonus") do |cats|
upper = UPPER_TOTAL.call(cats)
if upper >= 63 then 35 else 0 end
end
score.add_total("Upper Total") do |cats|
upper = UPPER_TOTAL.call(cats)
if upper >= 63 then upper + 35 else upper end
end
LOWER = [ "Three of a Kind", "Four of a Kind", "Full House",
"Small Straight", "Large Straight", "Yahtzee", "Chance" ]
bonus_yahtzees = 0
LOWER_TOTAL = lambda do |cats|
cats.inject(bonus_yahtzees) do |total, (cat, count)|
if LOWER.include?(cat) then total + count else total end
end
end
score.add_categories(*LOWER[0..-2])
score.add_total("Bonus Yahtzees") { bonus_yahtzees }
score.add_categories(LOWER[-1])
score.add_total("Lower Total", &LOWER_TOTAL)
score.add_total("Overall Total") do |cats|
upper = UPPER_TOTAL.call(cats)
if upper >= 63
upper + 35 + LOWER_TOTAL.call(cats)
else
upper + LOWER_TOTAL.call(cats)
end
end

# ...

I make a little use of the fact that Ruby's blocks are closures there, especially with the Bonus Yahtzees total. I simply have it refer to a bonus_yahtzees variable, which the game engine can increase as needed.

Let's step into that engine now. Here's the section that handles dice rolling:

ruby
# ...

# Game.
puts "\nWelcome to Yahtzee!"
scratches = (1..13).to_a
13.times do
# Rolling...
roll = Yahtzee::Roll.new
rolls = 2
while rolls > 0
puts "\nYou rolled #{roll}."
print "Action: " +
"(c)heck score, (s)core, (q)uit or #s to reroll? "
choice = STDIN.gets.chomp
case choice
when /^c/i
puts "\nScore:\n#{score}"
when /^s/i
break
when /^q/i
exit
else
begin
pips = choice.gsub(/\s+/, "").split(//).map do |n|
Integer(n)
end
roll.reroll(*pips)
rolls -= 1
rescue
puts "Error: That not a valid reroll."
end
end
end

# ...

Most of that code is for processing user interface commands. The actual dice roll handling is just calls to the correct methods of Roll at the correct times.

Finally, here's the scoring portion of the game:

ruby
# ...

# Scoring...
loop do
if roll.matches?(*%w{x x x x x}) and score["Yahtzee"] == 50
bonus_yahtzees += 100

if scratches.include?(roll[0])
score.count(roll[0], roll.sum(roll[0]))
scratches.delete(choice)
puts "Bonus Yahtzee scored in " +
"#{score.category(roll[0])}."
break
end

puts "Bonus Yahtzee! 100 points added. " +
"Score in lower section as a wild-card."
bonus_yahtzee = true
else
bonus_yahtzee = false
end

print "\nScore:\n#{score}\n" +
"Where would you like to count your #{roll} " +
"(# of category)? "
begin
choice = Integer(STDIN.gets.chomp)
raise "Already scored." unless scratches.include?(choice)
case choice
when 1..6
score.count(choice, roll.sum(choice))
when 7
if roll.matches?(*%w{x x x}) or bonus_yahtzee
score.count(choice, roll.sum())
end
when 8
if roll.matches?(*%w{x x x x}) or bonus_yahtzee
score.count(choice, roll.sum())
end
when 9
if roll.matches?(*%w{x x x y y}) or bonus_yahtzee
score.count(choice, 25)
end
when 10
if roll.matches?(1, 2, 3, 4) or bonus_yahtzee
score.count(choice, 30)
end
when 11
if roll.matches?(1, 2, 3, 4, 5) or bonus_yahtzee
score.count(choice, 40)
end
when 12
if roll.matches?(*%w{x x x x x})
score.count(choice, 50)
end
when 13
score.count(choice, roll.sum)
end
scratches.delete(choice)
break
rescue
puts "Error: Invalid category choice."
end
end
end

print "\nFinal Score:\n#{score}\nThanks for playing.\n\n"
end

The first if block in there is watching for Bonus Yahtzees, which are the hardest thing to track in a Yahtzee game. If a second Yahtzee is thrown, it increments the bonus_yahtzee variable (so the Scorecard total will change), then it tries to score the Yahtzee in the correct slot of the Upper section. If that slot is already full, it warns the code below to allow wild-card placement by setting the boolean variable bonus_yahtzee.

The rest of the scoring code is a case statement that validates dice patterns and scores them correctly. It looks like a lot of code, but it's very basic in function. I'm really just stitching Roll and Scorecard together here.

That's all there is to my version of Yahtzee. I didn't do the extra challenges, obviously. It's pretty easy to add Triple Yahtzee to this version. The AI is a bigger challenge, if you want it to play well. Those I'll leave as a challenge for the reader.

You already know what tomorrow's quiz is. You asked for it.