Looking for more to read? Check out the Archives!

Sinatra Blackjack

Step up and place your bets! I’ve created a simple Blackjack game here: https://salty-crag-8411.herokuapp.com/. I hope you’ll give it a try! I built it with Sinatra and deployed it to Heroku.

Like Rails, Sinatra is a Domain-Specific Language, or DSL, based on Ruby. Sinatra uses a simple syntax for specifying routes with Ruby blocks, which are outlined on Sinatra’s Getting Started page.

In my Sinatra Blackjack game, these routes help control the flow of the Blackjack game, as the player competes with the dealer for the highest Blackjack score (21). Just like Rails, it uses the MVC pattern to render or redirect the appropriate page. Sinatra is a pure joy to work with; simple but powerful! Here’s how I used it to build a simple Blackjack game.

Game Start

Sinatra serves the ‘/’ (root) route, and evaluates the conditional in the 'do' block:

get '/' do
  if session[:player_name]
    redirect '/game'
  else
    redirect '/new_player'
  end
end

If the session[:player_name] contains a value (because the user has entered a name into the input form), the game is redirected to the /game view. If it’s empty, the user must enter a name, so it redirects to the /new_player route.

get '/new_player' do
  erb :new_player
end

post '/new_player' do
  if params[:player_name].empty? || params[:player_name].to_i != 0
    @error = "Please enter your name."
    halt erb(:new_player)
  else
    session[:player_name] = params[:player_name]
    session[:player_bankroll] = INITIAL_PLAYER_BANLROLL
    redirect '/bet'
  end
end

The app does not use a database. Instead, the following types of session[:id] (cookies) are used to store persistent data:

  • [:player_bankroll]: The player’s total amount of money available to bet
  • [:player_name]: The player’s name
  • [:bet_amount]: The value of the player’s bet
  • [:dealer_cards]: The dealer’s hand
  • [:player_cards]: The player’s hand
  • [:deck]: The deck
  • [:turn]: Whose turn it is to hit/stand

When a user vists https://salty-crag-8411.herokuapp.com/, he or she is prompted for a name. This name is stored in session[:player_name].

Next, the player is prompted for a bet.

The player is taken to “/bet” and the ‘bet.html.erb’ view is rendered:

get '/bet' do
  erb :bet
end

post '/bet' do
  if session[:player_bankroll].to_i == 0
    redirect '/no_money'
  elsif params[:bet_amount].empty? || params[:bet_amount].to_i == 0 
    @error = "You must place a bet."
    halt erb(:bet)
  elsif (params[:bet_amount].to_i < 5) || (params[:bet_amount].to_i > session[:player_bankroll].to_i)
    @error = "Your bet must be between $5 and $#{session[:player_bankroll]}."
    halt erb(:bet)
  else
    session[:bet_amount] = params[:bet_amount]
    redirect '/game'
  end

After the player enters a bet amount, a series of checks are performed:

  1. If the player has no money, the game is redirected to ‘/no_money’ and the game is over.
  2. If the player tries to enter anything other than a number, the dealer asks for the player to try again.
  3. If the bet is less than the minimum ($5), or more than the player’s total bankroll, the player is asked to bet again.
  4. If the bet is valid, the bet amount is saved as session[:bet_amount], and the player is directed to the ‘/game’ route.

In the '/game' route, the cards are shuffled and dealt.

  • If the player has 21, it’s a Blackjack.
  • If the player’s cards total more than 21, it’s a bust.
  • Oherwise the player is offered the chance to hit or stand.
get '/game' do
  session[:turn] = session[:player_name]
  
  # create a deck and put it in session

  suits = ["H", "D", "C", "S"]
  values = ["2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"]
  session[:deck] = suits.product(values).shuffle!
  
  # deal cards
  session[:dealer_cards] = []
  session[:player_cards] = []
  session[:dealer_cards] << session[:deck].pop
  session[:player_cards] << session[:deck].pop
  session[:dealer_cards] << session[:deck].pop
  session[:player_cards] << session[:deck].pop


  player_total = calculate_total(session[:player_cards])
  if player_total == BLACKJACK_AMOUNT
    winner!("#{session[:player_name]} hit Blackjack!")
  elsif player_total > BLACKJACK_AMOUNT
    loser!("#{session[:player_name]} busted at #{player_total}.")
  end
  
  erb :game
end

The Player’s Turn

If the player chooses to hit, he/she is redirected to /game/player/hit.

post '/game/player/hit' do
  session[:player_cards] << session[:deck].pop

  player_total = calculate_total(session[:player_cards])
  
  if player_total == BLACKJACK_AMOUNT
    winner!("#{session[:player_name]} hit Blackjack!")
  elsif player_total > BLACKJACK_AMOUNT
    loser!("#{session[:player_name]} busted at #{player_total}.")
  end

  erb :game, layout: false
end

If the player chooses to stand, he/she is redirected to /game/player/stand.

post '/game/player/stand' do
  @success = "#{session[:player_name]} stands."
  @show_hit_or_stand_buttons = false
  redirect '/game/dealer'
end

The Dealer’s Turn

Now, it’s the dealer’s turn. The dealer must hit until he/she has at least 17. We can use a constant, DEALER_MINIMUM_HIT_AMOUNT, to represent this requirement.

get '/game/dealer' do
  session[:turn] = "dealer"
  @show_hit_or_stand_buttons = false

  dealer_total = calculate_total(session[:dealer_cards])

  if dealer_total == BLACKJACK_AMOUNT
    loser!("Dealer hit Blackjack.")
  elsif dealer_total > BLACKJACK_AMOUNT
    winner!("Dealer busted at #{dealer_total} .")
  elsif dealer_total >= DEALER_MINIMUM_HIT_AMOUNT
    redirect '/game/compare'
  else
    @show_dealer_hit_button = true
  end

  erb :game, layout: false
end

Until the dealer reaches at least 17, he/she must hit:

post '/game/dealer/hit' do
  session[:dealer_cards] << session[:deck].pop
  redirect '/game/dealer'
end

Choosing a Winner

Finally, if neither the player nor the dealer have hit Blackjack or busted, we need a way to compare the values of their hands, and declare a winner:

get '/game/compare' do
  @show_hit_or_stand_buttons = false

  player_total = calculate_total(session[:player_cards])
  dealer_total = calculate_total(session[:dealer_cards])

  if player_total < dealer_total
    loser!("#{session[:player_name]} stands at #{player_total}, and the dealer stands at #{dealer_total}.")
  elsif player_total > dealer_total
    winner!("#{session[:player_name]} stands at #{player_total}, and the dealer stands at #{dealer_total}.")
  else
    tie!("Both #{session[:player_name]} and the dealer stand at #{player_total}.")
  end

  erb :game, layout: false
end

Ending the Game

At the end of each match, the player is routed back to the beginning, where he/she is free to place a new bet or quit. There are two scenarios where he game must end:

  1. The player has money, but leaves the table
  2. The player is out of money
get '/no_money' do
  erb :no_money
end

get '/game_over' do
  erb :game_over
end

Both of these scenarios can be handled very easily with the appropriate views:

views/no_money.erb view raw
<h3>Thanks for playing!</h3>
<p>Sorry, <%= session[:player_name] %>. You have <span class="game_over_bankroll_amount">$<%= session[:player_bankroll] %></span>, and the minimum bet is $5...If you want to try your luck again, please see the Cashier for more chips first.</p>
<br />

<a class="btn btn-info btn-large" href="/new_player"><i class="icon-user"></i> Start Over</a>
views/game_over.erb view raw
<h3>Thanks for playing!</h3>
<h4>You're walking away with <span class="game_over_bankroll_amount"> $<%= session[:player_bankroll] %></span>. Looks like you're ready to hit Vegas!</h4>
<p>(Unless you've changed your mind, in which case you might want to keep playing)...?</p>
<br />

<a class="btn btn-info btn-large" href="/bet"><i class="icon-user"></i> Keep Playing</a>

Ajax!

One cool bonus, which you should notice as you play the game, is that the entire page does not reload with each action. The application includes jQuery code to prevent all but essential reloads for the following game actions:

  • when the player hits
  • when the player stands
  • when the dealer hits
public/application.js view raw
$(document).ready(function() {
    player_hits();
    player_stands();
    dealer_hits();
});

function player_hits() {
    $(document).on("click", "form#hit_form input", function(){
	$.ajax({
	    type: "POST",
	    url: "/game/player/hit"
	}).done(function(msg) {
	    $('#game').replaceWith(msg);
	});

	return false;
    });
};

function player_stands() {
    $(document).on("click", "form#stand_form input", function(){
	$.ajax({
	    type: "POST",
	    url: "/game/player/stand"
	}).done(function(msg) {
	    $('#game').replaceWith(msg);
	});

	return false;
    });
};

function dealer_hits() {
    $(document).on("click", "form#dealer_hit input", function(){
	$.ajax({
	    type: "POST",
	    url: "/game/dealer/hit"
	}).done(function(msg) {
	    $('#game').replaceWith(msg);
	});

	return false;
    });
};

This provides for a smoother user experience, and it saves some bandwidth. I hope you’ve enjoyed this tour of my first Sinatra web app. I had a lot fun building it.


If you enjoyed this post, you might want to subscribe, so you don't miss the next one!