DEV Community

Naftali Kulik
Naftali Kulik

Posted on

Ruby Bingo

From the moment I started learning Ruby, I was able to see why so many programmers love it. I found the syntax intuitive and easy to understand, and its flexibility allows for many different ways to accomplish similar things, allowing programmers to write their code in the way that's most comfortable for them. The syntax is often so simple that even someone with no programming experience whatsoever can often look at a block of code and have a pretty good idea of what's going on. Here's a quick example, taken from the project that I'll be discussing in a moment: It doesn't take a genius to figure out what Board.generate_board until Board.all.count == 20 is supposed to be doing, even if you don't have any knowledge of the code in the generate_board method. In fact, Ruby's creator Yukihiro “Matz” Matsumoto said explicitly that "The goal of Ruby is to make programmers happy", and that goal shows in every aspect of the language.

Learning Ruby has also helped me develop a pretty good habit for programming in general. Ruby has so many built-in methods that memorizing them all is impractical and frankly a waste of time. Because of this, any time I am trying to accomplish a specific task the first thing I do is to Google if there already is an existing method for what I am trying to do (which there often is). Doing so allows me to avoid having to write unnecessarily complicated code, makes my code smoother and more readable, as well as expands my knowledge of the Ruby language in general. This habit has spilled over into my work with other programming languages, and I often find myself doing the same thing in JavaScript now. Although JS doesn't have quite as many built-in methods as Ruby, too often in the past I've stumbled on a method that would've saved me a lot of work in a previous project.

My most recent project was a Bingo game, with a focus on backend development. (The repo for the backend is here, and the frontend is here.) The frontend is a pretty basic React application, and the backend uses a SQLite database with the Active Record ORM, and Sinatra to handle the routes.

Here's a brief overview of what's relevant to this post. For a more detailed explanation of how the database and routes are set up see the README. I'm going to be building out instance methods for the PlayedBoards class, which is a model for a join table that establishes a many-to-many relationship between a players table and a boards table. Each Player instance represents a user, each Board represents a bingo board with a unique layout, and each PlayedBoard contains all of the information relative to a specific player playing a specific board. The PlayedBoard needs instance methods to handle the actual gameplay, as well as to simulate a complete game (for the seed file). I will be going through how we can build these methods, while (hopefully) sticking to good coding practices, such as the single-responsibility principle.

The information that we will be working with is as follows. We have the layout of the board, obtained from the board via Active Record's belongs_to: macro. This is stored as a string consisting of 25 random integers between 0 and 99, separated by spaces. It is in string form in order for it to be stored in the database, separated by spaces so it can easily be converted into an array using .split(' '). This is a useful trick I picked up online for storing arrays in a SQLite database. We also have a string from the unused_numbers column of all numbers from 0 to 99. This will be used to pick numbers for the bingo game and modified as the game progresses so that no number is picked twice. Additionally, there are columns with integers representing the turn_count, turns_to_line, turns_to_x, and turns_to_full. These are to keep track of how many turns were needed to hit a particular milestone (line, x, full) to calculate the user's score. There is also a filled_spaces string with the position on the board of any matching number, by index. Now let's write some code that will make it all work.

Let's see what we need to do to make this work, in pseudocode:

pick a number
remove that number from unused_spaces
increment turn_count
check board layout for match
if no match, end turn (for simulation, start next turn)
if match, add the index of the matching number to filled_spaces
check for a line (only if there isn't already)
if there is, update turns_to_line to current turn_count
check for x (only if there isn't already)
if there is, update turns_to_x to current turn_count
check if full
if it is, update turns_to_full to current turn_count (and end game in simulation)
end turn (in simulation start next turn if not full)
Enter fullscreen mode Exit fullscreen mode

The first step, picking a number, is handled by the frontend during gameplay. In the simulation it's as simple as passing unused_nums.split(' ').sample into the play_turn method, which is going to handle running all of the methods we are about to define. In fact, once all the methods handled by the play_turn method were defined, all that needed to be done for the simulation was this:

def sim_play
    play_turn(unused_nums.split(' ').sample) until is_full?
end
Enter fullscreen mode Exit fullscreen mode

Let's start making that play_turn method work. First, let's remove our newly picked number from unused_nums so it doesn't get picked again.

def remove_from_unused num
    unused_nums_arr = self.unused_nums.split(' ')
    unused_nums_arr.delete num
    update(unused_nums: unused_nums_arr.join(' '))
end
Enter fullscreen mode Exit fullscreen mode

The new number is passed as an argument to the remove_from_unused method, which splits unused_nums into an array, passes the picked number to Ruby's built-in Array#delete method, and uses Active Record's update method to send the new unused_nums information back to the database. This doesn't interact with any other methods used to play a turn, so it is in fact the first line of our play_turn method:

def play_turn num
    remove_from_unused(num)
end
Enter fullscreen mode Exit fullscreen mode

Next, we have to increment the turn_count:

def count_turn
    count = self.turn_count ? self.turn_count : 0
    update(turn_count: count + 1)
end
Enter fullscreen mode Exit fullscreen mode

The ternary is necessary because in this case, the default value for turn_count is nil, so merely passing self.turn_count + 1 to the update method wouldn't work for the first turn. This too can be added as is to the play_turn method.

def play_turn num
    remove_from_unused(num)
    count_turn
end
Enter fullscreen mode Exit fullscreen mode

Now we have to find a match. Let's see what we can do here:

def get_match num
    layout_arr = self.board.layout.split(' ')
    layout_arr.find_index num
end
Enter fullscreen mode Exit fullscreen mode

This method accesses the layout from the board via the belongs_to: :board macro and splits it into an array. It then uses Ruby's built-in find_index method to find and return either the matched index of the layout array or nil if no match is found. The return value of this method will be used to handle a potential match. The next line of our play_turn method is ready:

 def play_turn num
     remove_from_unused(num)
     count_turn
     match = get_match(num)
     handle_match(match) if match
 end
Enter fullscreen mode Exit fullscreen mode

That last line is a method we haven't defined yet which will take the index of the matched number and take care of updating what needs to be updated. If no match was found, the value of match is nil and the turn ends with no further action. In the event of a match, the first thing we need to do is update the filled_spaces:

def update_filled_spaces index
    filled_spaces_arr = self.filled_spaces ? self.filled_spaces.split(' ') : []
    filled_spaces_arr.push(index)
    update(filled_spaces: filled_spaces_arr.join(' '))
end
Enter fullscreen mode Exit fullscreen mode

First, we split filled_spaces into an array (or create an empty array if there haven't yet been any matches and the value of filled_spaces is nil). We then add the index passed as an argument to the array and update the database. The first line of handle_match is ready to be written:

def handle_match index
    update_filled_spaces(index)
end
Enter fullscreen mode Exit fullscreen mode

Now we will have to build out several methods to handle the rest. First, let's give ourselves a way of checking if the board has been filled:

def is_full?
    filled_spaces_arr = self.filled_spaces ? self.filled_spaces.split(' ') : []
    filled_spaces_arr.count == 25
end
Enter fullscreen mode Exit fullscreen mode

Simple enough! Now let's check if we have a line or an X. Our Board class helpfully provides two class constants, Board::LINE_WIN_COMBINATIONS and Board::X_WIN. Board::LINE_WIN_COMBINATIONS is an array containing other arrays, each containing the indexes of the layout needed to complete a specific line. Board::X_WIN contains the indexes (indices? Dunno, according to Google either way works) needed to fill out an X on the board. Let's start with finding if there is an X:

def has_new_x?
    filled_spaces_arr = self.filled_spaces ? self.filled_spaces.split(' ') : []
    !self.turns_to_x && (Board::X_WIN - filled_spaces_arr).empty?
end
Enter fullscreen mode Exit fullscreen mode

This will return true if two conditions are met. If the value for turns_to_x is truthy, that means the X has already been filled on a previous turn, and has_new_x will return false because the X is not new. The right side of the AND expression takes advantage of an interesting feature of Ruby, which is that arrays can be subtracted from each other, and if all of the elements in the array being subtracted from exist in the array being subtracted, the return value will be an empty array. For example: [1, 2] - [4, 1, 3, 2] == []. In this case, we are subtracting filled_spaces_array from Board::X_WIN and checking if the result is empty. The return value of .empty? will help determine the return value of has_x?. We'll do something similar for our next method:

def has_new_line?
    filled_spaces_arr = self.filled_spaces ? self.filled_spaces.split(' ') : []
    !self.turns_to_line && Board::LINE_WIN_COMBINATIONS.find { |combo| (combo - filled_spaces_arr).empty? }
end
Enter fullscreen mode Exit fullscreen mode

Here we are iterating through Board::LINE_WIN_COMBINATIONS and checking if a similar expression as the one we used for has_x? returns true for any of the possible combinations.

Note: The elements of Board::LINE_WIN_COMBINATIONS and Board::X_WIN are represented as strings to avoid the complexities of dealing with two different data types

Now let's define a method that will handle updating the database in the event of a match. We need to do two things here. We'll need to update this particular instance of PlayedBoard, as well as check if the current user's score is better than the high score currently saved to the related instance of Board. Let's start with defining a method for the high scores:

def update_high_score high_score
    if !self.board[high_score] || turn_count < self.board[high_score]
        self.board[high_score] = turn_count
        self.board.save
    end
end
Enter fullscreen mode Exit fullscreen mode

This takes a key for the Board class (:full_high_score, :x_high_score, or :line_high_score) as an argument and first checks if there is already a high score for this board (if not, congrats, you have the high score!). If there is, it checks if the current turn_count is lower than the current high score (remember, lower is better in bingo) and if it is, updates the board's high score accordingly. Let's build another method to handle updating the user's scores too:

def update_scores(turns_to_score, high_score)
    self[turns_to_score] = turn_count
    self.save
    update_high_score(high_score)
end
Enter fullscreen mode Exit fullscreen mode

Now we can fill out the rest of handle_match:

def handle_match index
    update_filled_spaces(index)
    update_scores(:turns_to_full, :full_high_score) if is_full?
    update_scores(:turns_to_x, :x_high_score) if has_new_x?
    update_scores(:turns_to_line, :line_high_score) if has_new_line?
end
Enter fullscreen mode Exit fullscreen mode

We are using conditionals to update the appropriate scores if the necessary conditions have been met. Let's see it all together now:

def remove_from_unused num
    unused_nums_arr = self.unused_nums.split(' ')
    unused_nums_arr.delete num
    update(unused_nums: unused_nums_arr.join(' '))
end

def count_turn
    count = self.turn_count ? self.turn_count : 0
    update(turn_count: count + 1)
end

def get_match num
    layout_arr = self.board.layout.split(' ')
    layout_arr.find_index num
end

def update_filled_spaces index
    filled_spaces_arr = self.filled_spaces ? self.filled_spaces.split(' ') : []
    filled_spaces_arr.push(index)
    update(filled_spaces: filled_spaces_arr.join(' '))
end

def is_full?
    filled_spaces_arr = self.filled_spaces ? self.filled_spaces.split(' ') : []
    filled_spaces_arr.count == 25
end

def has_new_x?
    filled_spaces_arr = self.filled_spaces ? self.filled_spaces.split(' ') : []
    !self.turns_to_x && (Board::X_WIN - filled_spaces_arr).empty?
end

def has_new_line?
    filled_spaces_arr = self.filled_spaces ? self.filled_spaces.split(' ') : []
    !self.turns_to_line && Board::LINE_WIN_COMBINATIONS.find { |combo| (combo - filled_spaces_arr).empty? }
end

def update_high_score high_score
    if !self.board[high_score] || turn_count < self.board[high_score]
        self.board[high_score] = turn_count
        self.board.save
    end
end

def update_scores(turns_to_score, high_score)
    self[turns_to_score] = turn_count
    self.save
    update_high_score(high_score)
end

def handle_match index
    update_filled_spaces(index)
    update_scores(:turns_to_full, :full_high_score) if is_full?
    update_scores(:turns_to_x, :x_high_score) if has_new_x?
    update_scores(:turns_to_line, :line_high_score) if has_new_line?
end

def play_turn num
     remove_from_unused(num)
     count_turn
     match = get_match(num)
     handle_match(match) if match
 end

def sim_play
    play_turn(unused_nums.split(' ').sample) until is_full?
end
Enter fullscreen mode Exit fullscreen mode

Beautiful! We've built a fully functioning bingo game! Let's look at the seed file to see how simple the code for seeding our database can now be (with some help from the Faker gem):

puts "Seeding..."

# generate 20 unique boards
Board.generate_board until Board.all.count == 20

#generate fake users and passwords
5.times do
    name = Faker::Name.name
    username = Faker::Beer.brand.gsub(/\s+/, "")
    password = Password.create(password: Faker::Types.rb_string)
    Player.create(name: name, username: username, password_id: password[:id])
end



#simulate each user playing each board (note: this will take a while. The puts are to help track progress)
Player.all.each do |player|
    puts "player #{player[:id]}"
    Board.all.each do |board|
        puts "board #{board[:id]}"
        unused_nums = (0..99).to_a.join(' ')
        new_game = PlayedBoard.create(player_id: player[:id], board_id: board[:id], unused_nums: unused_nums)
        new_game.sim_play
        puts new_game[:turn_count]
    end
end

puts "Done seeding!"
Enter fullscreen mode Exit fullscreen mode

This takes a while, but when it's done you'll have twenty unique boards, five users, and one hundred played_boards! Please feel free to check out the repo, and tell me what you think!

Questions? Feedback? Drop it in the comments, connect with me on LinkedIn, or email me at naftalikulikse@gmail.com.

Top comments (0)