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)
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
andBoard::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 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, 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)