* Or any SBC supported in Maker.MakeCode
Part 3 (of 4): Implementing Blackjack.There are sixteen sections in Part 3 of the project::
- 1 = Introduction
- 2 = Blackjack specification
- 3 = Clean Code
- 4 = Calculating hand totals, with aces
- 5 = Testing (1)
- 6 = Dealing cards
- 7 = Testing (2)
- 8 = Betting
- 9 = Dealing the first 2 cards
- 10 = Game phases
- 11 = Twist or stick?
- 12 = Dealing to the dealer
- 13 = Going bust
- 14 = Evaluating the winner
- 15 = Edge cases
- 16 = Next steps
----------------------------------
1: IntroductionIn Part 1 of this project we set up a virtual packs of cards and built some related functions. In Part 2 we then added the capacity to output visually recognisable cards onto an OLED display.
In the process we have created a range of 'public' functions that could be used to build a card game. In this project, Part 3, we are going to those functions to build a game of blackjack where a single person can play against a dealer.
Then, in Part 4, we will build a 'sim' element... we'll use a config file to specify the behaviour of the player and the dealer and we'll run tens, hundreds, thousands of hands without any user input. Data from each hand will be saved to the microSD card, and we'll analyse it in Excel. Is there a strategy that will beat the house? We'll find out in Part 4.
2: Blackjack specificationIt will be helpful if you know how the game of Blackjack works - if not here's a wikipedia article.
To make our game work we are going to implement the following workflow:
- Deal 1 card to the player.
- Establish how much the player is going to bet (*)
- Deal 1 more card to the player.
- Evaluate these cards (including checking for 'blackjack') then display the total.
- Allow the player to stick or twist.
- Evaluate the value of the hand after every twist and display the value.
- On every twist, check if player goes bust.
- If player goes bust the player loses - we need to process 'losing'.
- Enable up to 3 twists - limit hand size to 5 cards.
- When player sticks it is the dealer's turn:
- Deal cards until dealer goes bust or sticks
- Evaluate who won the hand. Handle the bet amount accordingly.
- Start a new hand...
You may notice that I've not mentioned splitting aces (or splitting in general - which is an option in some variants of blackjack when the first 2 cards dealt have the same face). I think it can be done, but the UX would be clumsy. Not being able to split is a shame, especially when 2 aces are dealt, but it doesn't ruin the game.
3: Clean codeBy the end of this project the code base for Blackjack will be quite cumbersome, with over 30 functions and even more variables.
MakeCode offers a few tools to help you organise your code:
- Collapsing functions. This is a new feature that is invaluable for us in this code base. Place collapsed functions in the workspace in a logical location and they are easy to find when you need them. But beware - when you shift to and from JavaScript view the functions are shown un-collapsed.
- Return values for functions. Thank you MakeCode!!! I wrote the first iteration of Blackjack before return values were implemented in MakeCode... it was super messy and had even more global variables (which are not ideal) than this version.
- Sensible naming: by naming variables and function with clear descriptions their purpose is always obvious. And when you pass arguments into a function name them carefully too. And don't be afraid of long names - resetHandParameter() is much more descriptive than reset() and its purpose would not be mistaken with that of the setHandArrays() function.
- Use of constants. We build several functions into which we pass in an ID indicating whether the function is processing for the player or the dealer. We establish a convention that playerID = 0 and dealerID = 1. By setting up constants for these values, our code is easier to read and follow - in the image below we use a constant in the first version (when we call twist()), but in the lower image we pass the value 0. In the first option it is much clearer that we are calling twist for the player, rather than the dealer.
There are 2 constants that are very helpful throughout. We will define more later, but these 2 are going to be most useful to us, as you will see:
Evaluating the total value of a hand is key. Blackjack is all about making ones hand 'total' be as close to 21 as possible, and if higher than 21 the player goes 'bust' and loses. In blackjack the value of the cards are evaluated as shown:
- Ace = 1 or 11, The potential of the Ace to take on 2 values throws us a curveball that complicates things considerably!
- Jack, Queen and King = 10. They are referred to as "picture cards".
- Other cards value = face (so, two = 2, five = 5 etc)
So its important to be able to evaluate a hand; specifically playerHand and dealerHand. These 5 element arrays are set up in On Start; we don't change the size during game play, we just add new cards into each hand. We need to know the number of cards in a hand so that we just consider the ones that are relevant.
The instinct is to define a counter called playerCards, and for the dealer, dealerCards, to tell us how many cards have been dealt to each hand. But what if we add in a third player, or a fourth? To ensure the game can scale we are going to store this counter in an array, and we'll do the same with a few other counters a bit later on. cardsInHand[] is an array with 2 elements. By convention:
- cardsInHand[0] = number cards in playerHand
- cardsInHand[1] = number cards in dealerHand
- In code we will use the constants playerID and dealerID to address this array (and the others we define below).
We use cardsInHand to allow us to evaluate a hand properly. It is also the variable we will pass in to getLateralPosition() (which we built in Part 2) to ensure the card is drawn in the correct location. We'll use this variable in a few places, and we will need to remember to increment it when a new card is dealt to a hand.
But the headline function we need is getCardValue(), which returns the value of each card based on the FaceID (which is a number from 0-12, where 0 = Ace, 1 = 2 and 12 = King - there's a lookup table in Part 2 and attached to this project).:
In getCardValue() we return 11 for an Ace. We will need to cater for the variability in the value of aces elsewhere.
To ensure we only call getCardValue() once for each card in a hand we are going to keep a running total of each hand (with with Aces = 11) which we will update when a card is added to a hand. To store this we will define another array called rawHandValues[], and by the same convention as earlier:
- rawHandValues[0] = raw value of the cards in playerHand (with aces = 11)
- rawHandValues[1] = raw value of the cards in dealerHand (with aces = 11)
When a card is dealt it will be evaluated and the cardValue() will be added to the relevant rawHandValue[]. But we need to cater for the variability of aces, which we'll accomplish by keeping track of how many aces are in each hand - we will set up another array, acesInHand[], and follow the same convention:
- acesInHand[0] = number of aces in playerHand
- acesInHand[1] = number of aces in dealerHand
We now have a ton of parameters to keep track of and reset each time a new hand is dealt. To make life easy we'll define 2 functions:
- setHandArrays() initialises all the arrays we need for a hand. This is only called once per session:
- resetHandParameters() resets the counters at the beginning of each hand:
In the next section we will update On Start and move things around a bit.
For now we've covered a lot of ground and we have everything we need to build the getHandValue() function. This function will take the rawHandTotal - if its greater than 21 it will check if there are any aces in the hand. IF so it will change the value of each ace from 11 to 1, one at a time, until either the hand total is less than 21 or all aces have been considered:
The function getHandValue() is convenient but cumbersome... when we call it it we have to pass in 2 parameters that are complex:
The parameters we pass in are non-trivial. The function works and we don't NEED to worry about it, but the line of code shown above is long and not very readable... it could be confusing and its easy to mess up (by getting something wrong in the parameters). To make our code more readable, and safe to use, we will set up a convenience function - getHandTotal()... we don't need it, but it will make life a bit easier:
We will update our testing code to see how we are getting on evaluating individual cards and hands.
Update On Start as shown:
Then tweak startNewHand():
Update the Button-A clicked event function as shown below. It will show the cards in playerHand and will print the raw hand total on the first row and the calculated total of the hand on the second.
CHALLENGE: The function shown above is very messy (although adequate to test and verify that the functions we built to evaluate a hand are working correctly). I use the utilityVariable to make the code easier to read and to ensure that we do not call the same function repeatedly. You can clean / simplify it a bit more: try using the constants we set up in Section 3 and the convenient getHandTotal(). from Section 4, which I have not used in the above code.
The reason I omitted the constants and getHandTotal().is because I did not develop them for the iteration from which the screen-grab is taken. I added them in the final iteration, but a lot of this project was written based on a previous iteration. The code shown above is 'throw-away-code' - it works as shown and we only use it in testing. So, I don't have a copy of it any more, and there is no real point in recreating it just for a screen-grab.
Test, check, confirm, then move on!
The results shown above are right: the top value is the raw total (with aces = 11) and the bottom value is the refined total, where the aces' value is recalculated if the hand total exceeds 21. The algorithm we have used to calculate hand value appears to be working properly.
6: Dealing cardsWhat we have built so far is working very well. But we are doing a lot of 'work' in the A-button function.
To use all the functions and parameters we created correctly we have to follow the workflow below:
- On start we call setHandArrays(). It is not called again.
- When a new hand is started resetHandParameters() must be called.
- When a card is dealt and allocated to a hand we need to increment the activeCard_PackPosition (from Part 1 - this effectively discards the card).
- We need to draw the card
- When a card is added to a hand the value of that card must be added to rawHandValues[].
- When a card is added to a hand we need to check if it is an ace, and if so we must increment acesInHand[] by 1.
- When this has been done we can evaluate the correct score for a hand, based on rawHandValue and acesInHand.
What we need is to be able to deal a card (to the player or the dealer) and for all of this 'housekeeping' to be taken care of for us: we will define dealCard() to do just that.
When we call dealCard() we pass in a parameter indicating whether we are dealing to player or the dealer. Everything else is taken care of - tick off the items in the bullet list above to verify that dealCard() is meets our needs.
dealCard() takes care of everything that needs to be done when a card is dealt, including drawing the card and all the housekeeping we've discussed previously. All we will need to do later is call dealCard(), confident in the knowledge that all the messy bits are taken care of :)
In effect this means that we don't need to think about all the functions and variables that are used in this function, neither do we need to worry about the conventions for using those functions. Once dealCard() is tested and verified we will ONLY need to call this function :)7: Testing (2)
dealCard() is a big deal - it is key to our program. It is therefore important to make sure that it works, so lets update our testing functions and check. We can now simplify A-button code significantly as all the work is done in dealCard() try this:
For the same reasons as in Section 4, the function above can be simplified using the constants and getHandTotal() function that we defined specifically for that purpose.
Most important above is the call to dealCard(). Each time you click the A button a new card is dealt. We print the rawValue and handValue to ensure everything is working correctly - by now we are pretty confident in these functions though.
Adjust your B-button code to look like this:
Compile and run the program:
- Click A and a card is dealt to the dealer hand. Tweak the A-button code to confirm it works when dealer to the dealer.
- Confirm that cards are dealt correctly and that the values are evaluated. Our maximum hand size is 5, so what happens when you try to deal a sixth card into a hand? Is this 'correct' behaviour?
- Click B button to start a new hand.
In Blackjack 1 card is dealt to the player. The player then places a bet based on that card. Usually you would bet high for aces and picture cards, and much lower for 2 - 6.
I am not going to try and build a UI for placing a bit - I am sure its possible, but I think it would be clumsy and would slow the game down. Instead we are going to create a config.txt file. This file will include information on what to bet for each possibility of the first card.
We will add to this config file in Part 4. For now there is 1 more parameter to include in the config file - dealerStickTotal - this is the hand total on which the dealer will stick. We could hard code it, but we need it in the config in Part 4, so we'll add it now.
Use the (attached) Excel file to help create config.txt (or you can use the version attached, which uses the values shown above):
- Open it.
- Edit the values in the yellow cells. Enter a number from 0 to 999.
- Copy the string in the blue cell
- Paste it into a text file named config.txt (case sensitive). The string pasted from Excel should be the ONLY content of the file.
- Copy this file onto you microSD card. Save it in the im01 folder.
The string is 42 chars long, composed of numerals only, and each 3 char chunk is a value. The location of the 3 char chunk in the file tells us what the value relates to:
- Chars 0-2 = amount to bet when first card is an ace
- Chars 3-5 = amount to bet when first card is a two
- Chars 6-8 = amount to bet when first card is a three etc...
- Chars 39-41 = total on which dealer sticks
We will write the code such that it 'trusts' the contents of the config file. If the string is too short or if it contains non-numerical characters then we won't be able to read it in. Placing this 'trust' on the config file is perhaps risky, but it makes life much easier in code. It is OK to impose conventions on things like config files.
First, set up an array called betAmounts[] and a variable dealerStickAmount to store the config values. Then create a function called loadConfig() and call it from On Start.
The loadConfig() function, and a custom function to test it:
the testConfig() function is throw-away. When you compile and run the program with these functions added, the contents of betAmounts[] and dealerStickAmount are shown. Confirm that the config is loading correctly then delete testConfig() and the reference to it in loadConfig().
Note how we can reference betAmounts[] using faceID... so the value at betAmounts[faceID] is the value to bet for that card. Also, the bet amount is always based on the first card in playerHand. So a quick function that returns the bet amount will help keep our code clean:
We can deal a card, and we know what the player will bet on the first card dealt. We are ready to deal a hand to the player:
9: Dealing the first 2 cardsA hand starts like this:
- 1 card is dealt.
- The bet amount is determined.
- A second card is dealt.
- The player decides whether to take another card (to "twist") or to "stick" with the hand they have.
In this section we'll get the first 2 cards dealt before moving on to twisting and sticking later.
We will build a function, dealFirst2Cards(), tailored to dealing the first 2 cards, and we will pass in a parameter indicating whether we are dealing to the player or the dealer. This function will initially be called from startNewHand() - this is for the player. We will have to call it again later for the dealer.
Update startNewHand():
One thing we want to do in dealFirst2Hands() is show the bet amount as well as the total of the hand. We only need to show bet amount after the first card is dealt, and only for the player. We want to show and update hand total as more cards are added too. So a couple of quick functions will make life easier and the code cleaner:
We will show the player hand total and bet on row 0, and this will be displayed during both player and dealers turn. The showPlayerHandHeader() function takes care of row 0, including printing the headers ("Total:" and "Bet:") and values:
The showHandTotal() function called by showPlayerHandHeader() (and elsewhere) works for both player and dealer - it uses playerDealerID to write the total to row 0 (for player) and row 1 (for dealer):
Note how the convention we set up earlier (player = 0, dealer = 1) is pervasive throughout our code and decisions - e.g. we use it to print the player details on row 0 and the dealers on row 1. This consistency allows us to use playerDealerIDs as more than just array references.
With these functions, and dealCard() our dealFirst2Hands() is quite clean and simple:
The player (or dealer) cannot go bust after just 2 cards, so we don't need to check for that here.
Run some tests and satisfy yourself that everything is working correctly
10: Game phasesWe are going to control the game from our Forever loop. We will call functions like drawFirst2Cards() and dealCard() from there. To ensure that the right code is executed at the right time we are going to define a variable that keeps track of the current phase of the game: gamePhase.
By convention, gamePhase will take on the following values:
- gamePhase = 0. Introduction. Happens only once = On Start.
- gamePhase = 1. Happens once per hand at the starting of a new hand.
- gamePhase = 2. Players turn. The constant we defined in Section 3 - allowPlayerInput = 2 - relates to this.
- gamePhase = 3. Dealers turn
- gamePhase = 4 Player went bust.
- gamePhase = 5 Dealer went bust.
At any twist of the cards the player / dealer can go bust (their hand total exceeds 21). That player loses automatically and we finalise the hand (which is a function call, rather than a phase)
- gamePhase = 6. Both parties have stuckso we have to evaluate the winner then finalise the hand.
We introduced constants in Section 3. Its not necessary, but as long as we've not exceeded the memory on the micro:bit / SBC, it will be helpful to set up constants for these. We'll name them like this: phase1_NewHand; phase4_PlayerBust etc. Lets move all the constants into 1 function, which we will call from On Start:
Update On Start as shown:
Now create a Forever loop which switches according to gamePhase:
Phase 2 is omitted... this is the phase when user input is required (see Section 8). Handling Phase 2 is delegated to the button events.
Using the functions we have already built we are already able to fill Phase 1:
When gamePhase = 2 (once the first 2 cards have been dealt) it is time for the player to make a decision between 2 actions:
Twist: take another card
- If the player twists then the hand is evaluated to see if it is "bust" (i.e. the total of the hand is greater than 21).
- The player can twist up to 3 times - their hand cannot exceed 5 cards.
- The same applies to the dealer.
- Once a new card has been twisted it is necessary to evaluate the hand total and ensure that it does not exceed 21 (i.e. the hand loses, or is "bust"). If so we set gamePhase = 4, preventing further twists.
Stick: to hold the hand as is
- When the player sticks then it is the dealers turn, gamePhase = 3.
- 2 cards are dealt to the dealer and the dealer twists until the hand total equals or exceeds the dealerStickTotal
- When the dealer sticks we move to gamePhase = 6, where both parties hand totals are evaluated to see who wins.
As you will see, we don't need a function for stick... in the B-button code below we have just set gamePhase to 3, which has the same effect as the player sticking.
We'll allocate the A-button for twist and the B-button for stick. It is time to gut the A-button function and replace the code as shown below:
If you have updated the code as shown, compile and run it. Verify that the first 2 cards are dealt and that you can then twist additional cards into your hand. Then verify that if you stick you cannot twist again.
12: Dealing to the dealer:When gamePhase = 3 it is the dealers turn. In the Forever loop above we already began to define the code for this phase of the game. We need to do the following:
- Deal the first 2 cards
- Check if the total exceeds dealerStickAmount
- If not twist, if so, stick
- Continue until dealer is bust or sticks.
- Change gamePhase on completion of dealing.
Thanks to all the hard work we've done so far, the code here is very simple - everything listed above is taken care of in dealToDealer():
We set gamePhase to 6 early on in the function and it will remain as 6 UNLESS the dealer goes bust, which is determined in the twist() function. If we set gamePhase to 6 at the end of the function it would always be 6 when the function finishes running... this way the twist() function get priority to set gamePhase.
And we can update the Forever loop too:
The pause allows us to see the last card dealt to the dealer for a second before the game moves on.
You can test this now.
13: Going bustWhen we defined twist() we set up a function called checkForBust(). All we need to do in checkForBust() is see if the current hand value exceeds 21, and if so set gamePhase = 4 (for player) and gamePhase = 5 (for dealer):
Once again our convention that player = 0 and dealer = 1 comes in useful as it helps us slip into the right gamePhase.
Set up a function called processGoneBust() to take control when gamePhase = 4 or 5:
We will define processHandOutcome() and showHandSummary() in the next sections, and these functions are also called in gamePhase = 6. Note that we pass in the ID of the winner. If you add stubs (empty functions) then you can test whether our go-bust code works by updating Forever with the following:
Test that when you go bust it is registered.
14: Evaluating the winnerWe've completed all game phases except the final one. gamePhase = 6 is triggered when both parties have stuck; we need to check the hand totals to determine the winner:
- the winner is the player whose hand total is closest to 21
- when both players have the same hand total the dealer wins.
There is 1 exception. IF the player has blackjack and the dealer has 21 then the player wins. If both player and dealer have blackjack then the dealer wins. Blackjack means the first 2 cards dealt are an Ace and a picture card. As soon as blackjack is dealt the hand is complete To cater for this we will update the dealFirst2Cards function... later though.
The getHandWinner() function is called when both player and dealer have stuck. It returns the ID of the hand winner:
Note how we have called processHandOutcome(). We also called that from the processGoneBust() function.
The naming of getHandWinner() is not ideal... either that or we shouldn't call processHandOutcome() from inside it. The reason is that the prefix 'get' is typically used for functions that have a single purpose - to return a value. As a coder you would expect to be able to call get functions multiple times without consequence. BUT - processHandOutcome() can ONLY be called once per hand. Something needs to be done about this - a refactoring would be ideal, but the way getHandWinner() is called in gamePhase = 6 makes it non-trivial. I won't try that here - every time I refactor I have to rewrite large chunks of the project and regenerate screen grabs. It works, its not perfect.
What we do in processHandOutcome() is keep track of how many games the player has won, and what their cumulative bet total is. We defined handsWon variable in Part 1, but we need a new variable now - cumulativeWinTotal = the sum of all bets. We'll keep track of how much winnings, or losings, we accrue in a game session. Define this variable and initialise it (set it to zero) in the On Start function (it might be there already, depending on how closely you are following the screen grabs).
To complete gamePhase = 6 create the processBothStuck() function as shown:
And call this from the Forever loop:
We need to define showHandSummary() before we can test:
And, you will notice we call showStats() above, so we need that too:
NOW you can test!
15: Edge casesWe need to cater for when someone gets blackjack. Its important to understand the rules around blackjack though:
- Blackjack means the first 2 cards dealt are an Ace and a picture card.
- IF the player has blackjack and the dealer has 21 then the player wins.
- If both player and dealer have blackjack then the dealer wins.
- The player can twist away a blackjack - they have to stick on their first 2 cards for it to be counted as blackjack
- As soon as blackjack is dealt to the dealer the hand is complete.
Reading the above points carefully and considering the design of our game, the optimal place to check for blackjack is in the dealToDealer() function... we ONLY need to check for blackjack once, when the first 2 cards have been dealt to the dealer. We need 3 new functions:
We call checkForBlackjack() from the dealToDealer() function as below:
You can ignore the warning shown in the screen-grab above. When we initialise variables outside of the On Start loop the error interpreter that runs in the background is not sure if they have been initialised - we call a function to initialise a bunch of variables we use, so you might see warnings like this one. The code compiles fine.
checkForBlackjack() uses the isBlackjack() function to determine whether a hand qualifies as 'blackjack':
I didn't use an AND in the second switch cos I find very long lines to be hard to read... the nested If is not ideal, but its a bit more readable.
checkForBlackjack() calls the processBlackjack() function if the player or dealer has blackjack. This skips the remaining gamePhase options - the hand is complete and processBlackjack() finishes it off:
Compile, test and play! If it all works out we are done. Sort of...
Challenge:
- We are pretty much down now... except for the 1 little thing I have left undone. Its a circumstance in the game where we have not built in a restriction that would avoid crashing the game. You will come across this when you play the game, although it will be rare.
- I have dealt with this scenario in the 'production' version of the code, which is attached to this project, so you won't see the glitch if you use my code
- Have a look and see if you can spot the omission :)
The full MakeCode project, with glitches removed, can be accessed here
16: Next stepsMulti-player blackjack, using micro:bit built-in radio, or over IoT with a Wi-Fi add-on. Its a tantalising idea, and I think doable. Perhaps I'll release a Part 4 some time.
In the meantime, please use and adapt / improve the code here and get in touch if you make any cool games with it.
Thanks for reading and please check out Part 1 and Part 2.
This project is meant to be easy to read and relatively simple to implement. It shows you how to achieve a useful outcome without labouring on irrelevant technical details. If you like this style please check out my book: Beginning Data Science, IoT and AI Using Single Board Computers.
Comments
Please log in or sign up to comment.