Riding the Ruby Bus (Inversion of Control in Ruby)
Playing Cards with Ruby - Part 2
In part one of this series we covered building a deck of cards with Ruby. In this post we will be using the deck that we built to implement a game. We'll start with a simple game that we can play in the terminal and turn it into something that can be integrated into a Discord bot.
Riding the Ruby Bus
The end goal of all this is to make a Discord bot play cards with my friends, and so to achieve this purpose I've chosen a game that is simple and quick to play: Ride The Bus! As an added bonus its a really good drinking game since the dealer almost always wins ;)
The game is a series of three questions that the player must answer about the next card to be drawn in relation to their current hand. If they can guess correctly each time they win! If they answer incorrectly once they lose. The questions that are asked in order are: Black or red? Above or below? Inside or outside?
In the black or red phase the player guesses if the next card to be drawn will be black or red.
In the above or below phase the player guesses if the next card to be drawn will be above or below the first card.
In the inside or outside phase the player guesses if the next card will be inside or outside the first two cards.
So an example game would look like this:
Dealer: Black or Red?
Player: Red
Dealer: Draws an ace of diamonds (a red card)
Dealer: Above or Below?
Player: Below
Dealer: Draws a ten of spades (below an ace)
Dealer: Inside or Outside?
Player: Outside
Dealer: Draws a jack of hearts (inside / between a ten and an ace)
Player: I lose!
Implementing the game
So how do we go about implementing something like this? Here are some things that we need to be able to do:
- Display the current turn
- Take input from the user about their choice
- Draw cards and store previous draws in a hand
- Evaluate the drawn card and determine if the player is winning or if they have lost
Ride the terminal
Our initial attempt can be a simple loop where we take user input, draw a card, and evaluate the result. Our turns can be represented by symbols in an array which we iterate over to control the game state. In each iteration we can use a case statement to evaluate logic for a particular turn and exit if the player loses. We can have an additional phase of the game to indicate that the game is over and we should print a final message to the player before exiting.
require './deck'
# initialize a new deck and shuffle it
deck = Deck.new
deck.shuffle!
# we need an array to store the previously drawn cards
cards = []
# each phase
[:black_or_red, :above_or_below, :inside_or_outside, :game_over].each do |phase|
# exit if game is complete
if phase == :game_over
puts 'You won!'
exit
end
# say what phase it is
puts phase.to_s
# get some input from the user
guess = gets.chomp
# draw a card
card = deck.draw
# display what card it was
puts "#{card.rank} of #{card.suit}"
# push the card onto our hand for later
cards.push(card)
# evaluate based on phase
case phase
when :black_or_red
if (guess == 'black' && card.black?) || (guess == 'red' && card.red?)
puts 'You win!'
else
puts 'Drink!'
exit
end
when :above_or_below
if (guess == 'above' && card.rank > cards[0].rank) || (guess == 'below' && card.rank <= cards[0].rank)
puts 'You win!'
else
puts 'Drink!'
exit
end
when :inside_or_outside
hand = cards.sort
if guess == 'inside' && card.rank.between?(hand[0].rank, hand[1].rank)
puts 'You win!'
elsif guess == 'outside' && !card.rank.between?(hand[0].rank, hand[1].rank)
puts 'You win!'
else
puts 'Drink!'
exit
end
end
end
# evaluating this looks something like:
black_or_red
black
six of spades
you win!
above_or_below
above
ace of spades
you win!
inside_or_outside
inside
eight of clubs
you win!
This implementation works well in the context of the terminal, but what if we want to integrate this into some other program (like a Discord bot)? We need to allow the bot to coordinate how and when the game is played.
Inverting the bus
Inversion of control is a principle in programming which is concerned with who initiates messages in a system. There are lots of descriptions of this on the Internet so I wont dig too deep (this one is pretty succinct, ctrl + f 'inversion of control' if you don't care about the rest of the context here). In my mind it mostly boils down to the fact that for a program or piece of code to be useful it often needs to allow other, often unknown, parts of a system control how and when it does what it needs to do. In our first implementation the entirety of "how and when" of the game is defined in the loop construct. If we invert the control of the "how and when" for our game it will be much more flexible. For example maybe we want to have something happen in between turns that the game naturally wouldn't know about? (like handling other Discord messages or whatnot).
So how can we invert control such that when turns are played, inputing guesses, and outputting the game state is controlled by the caller of our game class? Thinking about how we would use the game instance, we want to have an interface that looks something like:
game = RideTheBus.new
game.phase # => :black_or_red
game.play('black') # => true # meaning we won
game.won? # => true
game.complete? # => false
game.cards # => [#<Deck::Card ... > ]
puts 'Such inversion' # now we can do stuff in between turns
game.phase # => :above_or_below
game.play('above') # => true
game.won? # => true
game.complete? # => false
game.cards # => [#<Deck::Card ... >, ... ]
puts 'Wow!' # and the game doesnt need to know or care what that stuff is
game.phase # => :inside_or_outside
game.play('outside') # => true
game.won? # => true
game.complete? # => true
game.cards # => [#<Deck::Card ... >, ... ]
game.phase # => :game_over
Now the caller of the game object is in control of how and when the game is played. We know how we want to use the game object so lets get implementing!
A Smoother Ride
We can start by sticking this loop into a class and implement some methods which allow an external caller to play turns etc.
require './deck.rb'
class RideTheBus
def initialize
@deck = Deck.new
@deck.shuffle!
@cards = []
end
def play
[:black_or_red, :above_or_below, :inside_or_outside, :game_over].each do |phase|
# ...
end
end
end
game = RideTheBus.new
game.play
# ... game output
Now we can start a new game on demand by initializing a new RideTheBus
instance and calling the play method. However we are still limited to taking input from / outputting to the command line, and every turn happens all at once in the loop.
Enumerable Busses
What we need is a way to control how iteration over phases happens in our game. We want to be able to do one iteration per call of the play method. We can do this by taking advantage of Enumerators and external iteration. Enumerator
is the underlying class which handles iterating over objects like an array. This works like below:
# internal iteration
[1, 2, 3].each { |i| puts i } # => 1, 2, 3
# external iteration
enum = [1, 2, 3].each
puts enum.class # => Enumerator
puts enum.next # => 1
puts enum.next # => 2
puts enum.next # => 3
Normally we would pass a block to the each method which will call the block for each element in the array like in the first example. If the block is omitted an instance of an Enumerator
is returned allowing us to control the iteration ourselves (this is called external iteration). Calling .next
on the instance returns the next element. This allows us to retain our position in the array as the play method is called multiple times.
class RideTheBus
PHASES=[:black_or_red, :above_or_below, :inside_or_outside, :game_over]
attr_reader :cards, :phase
def initialize
# ...
@phases = PHASES.each
@phase = @phases.next
end
def play guess
# ... do game logic /
# ... case statement
# at the end of the turn setup the next phase
@phase = @phases.next
end
end
This implementation is looking much more flexible, but it still has some issues that we can address. For example, how would we recreate the previous turns in order to display the game history? We need to implement a way to look back at each previous turn / phase and determine what the state of the game was at that point.
State of the bus
We can accomplish this by implementing another class: the Turn
class, which can store the state of the game at each turn. Then we can expose these turns so that the calling context can recreate the game state at will.
class RideTheBus
# ...
class Turn
def initialize phase, cards, guess
@phase, @cards, @guess = phase, cards, guess
end
def won?
# send the phase symbol
# which will call one of the
# below methods
send @phase, @guess
end
private
def inside_or_outside guess
# ...
end
def black_or_red guess
# ...
end
def above_or_below guess
# ...
end
end
# ... rest of RideTheBus class
end
Now let's modify our RideTheBus
class to use the Turn
class, allow access to previous turns, and thus reconstruct the game state on demand.
class RideTheBus
# ...
class Turn
# ...
end
attr_reader :cards, :turns, :phase
def initialize
# ...
@turns = []
end
def play guess
#...
cards << @deck.draw
@turns << Turn.new(phase, cards.clone, guess)
# cloning the cards here ensures that if they are modified the turn's representation of the game state is not modified
@phase = @phases.next
# ...
end
end
Each time the play method is called we construct a new turn object an push it onto our turns array. Now it is possible to see our previous turns:
game = RideTheBus.new
game.phase # => black_or_red?
game.play('black')
# ... suppose that we won the game as above
# now we want to print what happened at some other time
game.turns.each do |t|
puts t.phase
puts t.cards
puts t.won?
end
# => black_or_red
# => [#<Deck::Card ... >]
# => true
# => above_or_below
# => [#<Deck::Card ... >, ...]
# => true
# => ...
Completing the ride
Now lets implement methods which allow the calling context to determine if the game is being won, and if the game has been completed or not. The implementation is pretty simple. We just need to check if the phase is :game_over
and if all previous turns have been won:
class RideTheBus
#...
def complete?
phase == :game_over
end
def won?
@turns.all? &:won?
end
end
Putting it all together our implementation looks like this:
class RideTheBus
PHASES=[:black_or_red, :above_or_below, :inside_or_outside, :game_over]
class Turn
def initialize phase, cards, guess
@phase, @cards, @guess = phase, cards, guess
end
def won?
send @phase, @guess
end
private
def inside_or_outside guess
hand = [@cards[0].rank, @cards[1].rank].sort
if guess == 'inside' && @cards[2].rank.between?(*hand)
true
elsif guess == 'outside' && !@cards[2].rank.between?(*hand)
true
else
false
end
end
def black_or_red guess
if guess == 'black' && @cards[0].black?
true
elsif guess == 'red' && @cards[0].red?
true
else
false
end
end
def above_or_below guess
if guess == 'above' && @cards[1].rank >= @cards[0].rank
true
elsif guess == 'below' && @cards[1].rank < @cards[0].rank
true
else
false
end
end
end
attr_reader :cards, :turns, :phase
def initialize
@deck = Deck.new
@deck.shuffle!
@cards = []
@turns = []
@phases = PHASES.each
@phase = @phases.next
end
def play guess
return if complete?
cards << @deck.draw
@turns << Turn.new(phase, cards.clone, guess)
@phase = @phases.next if won?
@phase = :game_over unless won?
won?
end
def complete?
phase == :game_over
end
def won?
@turns.all? &:won?
end
end
Finally to demonstrate the concept of inversion of control we can actually go back and re-implement this game for the console using our finished implementation.
require './ride_the_bus'
game = RideTheBus.new
while !game.complete?
puts game.phase
game.play(gets.chomp)
game.cards.each do |card|
puts "#{card.rank} of #{card.suit}"
end if game.cards.any?
if game.won?
puts 'You won!'
else
puts 'Drink!'
end
end
Now we are ready to use our RideTheBus
game in any context we want (like a Discord or even Twitch bot)
Conclusion
Hopefully you found this cool / interesting and learned a little something about inversion of control and enumerators in ruby. Next time we'll dig into setting up a Discord bot and implementing the game in that environment so we can play cards with our friends / bot and get everyone a little drunk.