Building a Deck of Cards in Ruby
Playing Cards with Ruby - Part 1
In the time of Covid we are all connecting more remotely, and for me and my friends that meant starting a Discord server as a virtual hangout. While Discord is awesome as is we can make it cooler by implementing a bot to interact with. As a side project in this vein I decided to make this bot play cards games with my friends and keep track of their scores. This was a really cool project and I wanted to share the implementation and what I've learned along the way, from the deck of cards, to implementing a game, to integrating with the bot itself. In this post and the few to follow I'll document how I created the card abstraction, the game I decided to go with, and how I integrated it with the bot.
A Deck of Cards
To start with we need a few simple classes which represent a deck of cards. What are some things we would need to be able to do with a deck of cards, and with the cards themselves? The goals of this implementation are as follows:
- Create a new deck.
- Shuffle or sort the deck.
- Draw cards from the deck.
- Compare between cards.
Implementing the deck is very simple; its just a wrapper around an array which stores our cards:
# deck.rb
class Deck
attr_reader :cards
def initialize
@cards = []
# create the cards in our deck
end
def draw
@cards.pop
end
def shuffle!
@cards.shuffle!
end
def sort!
@cards.sort!
end
end
Here we define a deck class. Its initialize method just creates an empty array to store our cards in the draw
, shuffle!
and sort!
methods call methods on the cards array. Now we need to implement a card class and fill our array of cards with instances of the class.
Implementing cards
Now we need to implement a card class to fill our deck. So what is a card? Well a card has two main properties: its rank, and its suit. In this sense a card is a good example of a Value Object. A quick Google tells us that a Value Object is a simple object whose equality is based on some values and not identity. Without straying too far into a discussion of value objects this means that an instance of a Card is equal to another instance when their rank and suit values are the same.
We can show how this works by using symbols (which are value objects themselves) to represent rank and suit values, and the Struct class to stand in for our card class:
Card = Struct.new(:rank, :suit)
jack_of_diamonds = Card.new(:jack, :diamond)
another_jack_of_diamonds = Card.new(:jack, :diamond)
jack_of_diamonds == another_jack_of_diamonds # => true
jack_of_spades = Card.new(:jack, :spade)
jack_of_spades == jack_of_diamonds # => false
This works out of the box since structs in Ruby are equal when they are of the same struct subclass and equal member values. Now lets implement our own card class:
# card.rb
class Card
attr_reader :rank, :suit
def initialize rank, suit
@rank, @suit = rank, suit
end
def == other
rank == other.rank && suit == other.suit
end
end
# now we can compare instances of our card class
Card.new(:jack, :spade) == Card.new(:jack, :spade) # => true
Card.new(:ace, :spade) == Card.new(:jack, :spade) # => false
But what if we want to do more than say whether two ard are equal? For example, to be able to say whether one card is less than another card? What does it mean for a card to be less than another card? Well in most card decks the ranks and suits are ranked in order from lowest to highest. If we store the symbols we are using to represent rank and suit values in an array we can then tell how they compare:
# card.rb
class Card
SUITS = [:spade, :heart, :diamond, :club] # this is the usual way
RANKS = [:ace, :king, :queen, :jack, :ten, :nine, :eight, :seven, :six, :five, :four, :three, :two]
attr_reader :rank, :suit
def initialize rank, suit
@rank, @suit = rank, suit
end
def == other
rank == other.rank && suit == other.suit
end
def > other
#lower position in the array means higher value
RANKS[rank] < RANKS[other.rank] && SUITS[suit] < SUITS[other.suit]
end
def < other
RANKS[rank] > RANKS[other.rank] && SUITS[suit] > SUITS[other.suit]
end
end
# now we can compare instances of our card class
Card.new(:jack, :spade) == Card.new(:jack, :spade) # => true
Card.new(:ace, :spade) == Card.new(:jack, :spade) # => false
Card.new(:ace, :spade) > Card.new(:jack, :spade) # => true
Card.new(:ace, :spade) < Card.new(:jack, :spade) # => false
Rank and Suit classes
Implementing these methods we can see that ranks and suits are actually value objects in their own right. In order to simplify the implementation we can create rank and suit classes which handle comparisons between themselves
#rank.rb
class Rank
RANKS = [:ace, :king, :queen, :jack, :ten, :nine, :eight, :seven, :six, :five, :four, :three, :two]
attr_reader :rank
def initialize rank
@rank = rank
end
# ... operators ommitted
end
#suit.rb
class Suit
SUIT = [:spade, :heart, :diamond, :club]
attr_reader :suit
def initialize suit
@suit = suit
end
# ... operators ommitted
end
Suit.new(:spade) == Suit.new(:spade) # => true
Suit.new(:spade) == Suit.new(:diamond) # => false
Rank.new(:ace) == Rank.new(:ace) # => true
Rank.new(:ace) == Rank.new(:king) # => false
Now we can re implement our card class as a composition of these smaller value objects.
# card.rb
class Card
attr_reader :rank, :suit
def initialize rank, suit
@rank, @suit = Rank.new(rank), Suit.new(suit)
end
def == other
rank == other.rank && suit == other.suit
end
# ...
end
include Comparable
Now to tie this all together: if we want to do lots of comparisons between our cards and perform operations on an array of them (like shuffle in our deck implementation), we can include the Comparable module. The Comparable module is a mixin included by classes whose objects may be sorted. It requires that we implement the <=> operator in order to get a bunch of comparisons. To do this we need to return -1, 0, or +1 depending on whether the card is less than, equal to, or greater than the other card. So lets get at it:
#rank.rb
class Rank
# ...
def <=> other
return 0 if rank == other.rank
RANKS.index(rank) < RANKS.index(other.rank) ? 1 : -1
end
end
#suit.rb
class Suit
# ...
def <=> other
return 0 if suit == other.suit
SUITS.index(suit) < SUITS.index(other.suit) ? 1 : -1
end
end
#card.rb
class Card
# ...
def <=> other
return 0 if rank == other.rank && suit == other.suit
return 1 if rank >= other.rank && suit >= other.suit
return -1
end
end
Now we can finally return to our deck implementation and fill our card array with Cards!
# deck.rb
class Deck
attr_reader :cards
def initialize
@cards = Rank::RANKS.flat_map do |rank|
Suit::SUITS.map do |suit|
Card.new(rank, suit)
end
end
end
# ...
end
deck = Deck.new # => # <Deck:0x000000000104f280 @cards=[ Some cards here... ]>'
deck.suffle! # => # <Deck:0x000000000104f280 @cards=[ Some sorted cards here... ]>'
deck.draw # => # <Card:0x0000000000fa4a38 @rank=#<Rank:0x0000000000fa4998 @rank=:ace>, @suit=#<Suit:0x0000000000fa4948 @suit=:spade>>'
Hope you found this a little bit interesting! Stick around for the next couple posts to see how I went about building a card game and integrating it into a discord bot :)