Ruby Bingo
Tags: ruby
Published: 2022-07-07 19:02:00

From the moment I started learning Ruby, I was able to see why so many programmers [love it](https://www.ruby-lang.org/en/about/). 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](https://github.com/nkulik94/phase-3-sinatra-react-project), and the frontend is [here](https://github.com/nkulik94/sinatra-react-bingo-frontend).) The frontend is a pretty basic React application, and the backend uses a SQLite database with the [Active Record](https://guides.rubyonrails.org/active_record_basics.html) ORM, and [Sinatra](http://sinatrarb.com/) 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](https://github.com/nkulik94/sinatra-react-bingo-frontend#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)
```
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
```
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
```
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
```
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
```
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
```
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
```
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
```
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
```
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
```
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
```
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
```
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
```
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
```
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
```
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
```
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
```
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](https://github.com/faker-ruby/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!"
```
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](https://github.com/nkulik94/sinatra-react-bingo-frontend), and tell me what you think!

Questions? Feedback? Drop it in the comments, connect with me on [LinkedIn](https://www.linkedin.com/in/naftali-kulik-se/), or email me at [naftalikulikse@gmail.com](mailto:naftalikulikse@gmail.com).