# Text Based Blackjack

I wanted to make a blackjack game to use while bored, but it turned out to be far more complicated than I thought.

from random import choice, randint

MASTER_DECK = ["A", "A", "A", "A",
"2", "2", "2", "2",
"3", "3", "3", "3",
"4", "4", "4", "4",
"5", "5", "5", "5",
"6", "6", "6", "6",
"7", "7", "7", "7",
"8", "8", "8", "8",
"9", "9", "9", "9",
"10", "10", "10", "10",
"J", "J", "J", "J",
"Q", "Q", "Q", "Q",
"K", "K", "K", "K"]

def setup(deck):
"""Sets up all game variables"""
# Initialize all of the hands
player_hand, deck = pick_cards(deck)
dealer_hand, deck = pick_cards(deck)
return deck, player_hand, dealer_hand

def pick_cards(deck):
"""Deals two random cards"""
hand = []
if len(deck) <= 6:
deck = MASTER_DECK.copy()
for card in range(0, 2):
chosen_card = choice(deck)
hand.append(chosen_card)
deck.remove(chosen_card)
return hand, deck

def print_ui(player_hand, dealer_hand, deck, game_state):
"""Prints out the display that tells the user there cards"""
print()
if game_state == "player_dealing":
print("The dealer has these cards:\n_, " + ", ".join(dealer_hand[1:]))
print()
print("You have these cards:\n" + ", ".join(player_hand))
print()
print(f"There are {len(deck)} cards left in the deck")
elif game_state == "dealer_dealing":
print("The dealer has these cards:\n" + ", ".join(dealer_hand))
print()
print("You have these cards:\n" + ", ".join(player_hand))
print()
if have_won(player_hand, dealer_hand):
print("You have beaten the dealer.")
else:
print("You have not beaten the dealer.")
else:
print("Something has gone wrong")
while True:
pass

def have_won(player_hand, dealer_hand):
"""Checks if the player has won"""
numeric_player_hand = numeric_cards(player_hand.copy())
player_hand_total = 0
for card in numeric_player_hand:
player_hand_total += card
numeric_dealer_hand = numeric_cards(dealer_hand.copy())
dealer_hand_total = 0
for card in numeric_dealer_hand:
dealer_hand_total += card
if dealer_hand_total > 21:
if player_hand_total > 21:
return False
return True
if dealer_hand_total == 21:
return False
if dealer_hand_total < 21:
if dealer_hand_total < player_hand_total <= 21:
return True
return False

def betting_phase(tokens):
"""Takes the users bet"""
print(f"You have {tokens} tokens.")
while True:
try:
bet = int(input("Please enter you bet: "))
if int(bet) > 0:
if (tokens - bet) >= 0:
break
print("Do not bet more than you have.")
else:
print("Please enter a number greater than zero.")
except ValueError:

def player_dealing(deck, player_hand, game_state):
"""Handles dealing to the player"""
if not deck:
print("As there are no more cards left, the round ends.")
game_state = "dealer_dealing"
else:
while True:
user_command = input("Would you like to hit or to stay? (H/S): ").lower()
if user_command == "h":
chosen_card = choice(deck)
player_hand.append(chosen_card)
deck.remove(chosen_card)
break
elif user_command == "s":
game_state = "dealer_dealing"
break
else:
print("Please only enter H for hit or S for stay.")
return deck, player_hand, game_state

def dealer_dealing(deck, dealer_hand):
"""Handles dealing to the dealer"""
while True:
if not deck:
break
numeric_dealer_hand = numeric_cards(dealer_hand.copy())
hand_total = 0
for card in numeric_dealer_hand:
hand_total += card
if hand_total < 16:
chosen_card = choice(deck)
dealer_hand.append(chosen_card)
deck.remove(chosen_card)
elif hand_total == 16:
if randint(0, 1):
chosen_card = choice(deck)
dealer_hand.append(chosen_card)
deck.remove(chosen_card)
else:
break
elif 11 in numeric_dealer_hand and hand_total > 21:
for card_number, card in enumerate(numeric_dealer_hand):
if card == 11:
numeric_dealer_hand[card_number] = 1
else:
break
return deck, dealer_hand

def numeric_cards(hand):
"""Turns card letters into their number values"""
for card_number, card in enumerate(hand):
if card == "J" or card == "Q" or card == "K":
hand[card_number] = 10
elif card == "A":
hand[card_number] = 11
else:
hand[card_number] = int(hand[card_number])
hand_total = 0
for card in hand:
hand_total += card
if hand_total > 21 and 11 in hand:
for card_number, card in enumerate(hand):
if card == 11:
hand[card_number] = 1
return hand

def play_again():
"""Allows user to play again or quit"""
while True:
play_again = input("Do you want to play again? (Y/N): ").lower()
if play_again == "y":
break
elif play_again == "n":
quit()
print("Please only enter a Y or N")

deck = MASTER_DECK.copy()
tokens = 200

while True:
game_state = "betting"
playing_game = True

deck, player_hand, dealer_hand = setup(deck)

while playing_game:
if game_state == "betting":
tokens, bet = betting_phase(tokens)
game_state = "player_dealing"
else:
print_ui(player_hand, dealer_hand, deck, game_state)
deck, player_hand, game_state = player_dealing(deck, player_hand, game_state)
if game_state == "dealer_dealing":
deck, dealer_hand = dealer_dealing(deck, dealer_hand)
if have_won(player_hand, dealer_hand):
tokens += 2 * bet
print_ui(player_hand, dealer_hand, deck, game_state)
playing_game = False
if tokens:
play_again()
else:
input("You have no more tokens to spend. Hit enter to quit.")
quit()



Originally I wanted to add some AI players as well as including the split and double functions, but the code got so complicated that I thought it would be better not to include those unless the code was cleaned up.

Is there any way to clean this up and make it easier to add more features? As well, is there anything else that could be made better?

• Just a note; you don't seem to be handling the card suits in this, unless it's my mis-understanding of the source. It might therefore be better, as in a more complete game, if the cards were ordered by suit in the master deck; maybe a muti-dimensional array so that you have MASTER_CARD.HEARTS = [ ... ] etc...? Aug 20, 2021 at 11:54
• @ShaunBebbers it might not matter for blackjack specifically. As long as you are tracking the correct number of cards as a stand-in for the suit, it's just the values that really have an impact Aug 20, 2021 at 13:13
• @C.Nivs - I agree that it might not matter in terms of the script and game logic, but it would be a better experience for the user. The simplest solution is to have each element in the MASTER_CARD object as 'AH', 'AD', 'AC', 'AS' ... Aug 20, 2021 at 13:24
• To refine your application. Unicode has characters for playing cards, which of course needs to use a font having them. Aug 24, 2021 at 21:20

K00lman, I wanted to chime in with my points too, some of these are covered by other answers. The most obvious problems that jump out with the code:

Master deck

• No suits and the deck creation is literal - not programmatic.

Deck Setup

• Lots of duplicated code

Pick Cards

• Using .copy() to duplicate a deck when card count is low (bad structure)
• Using a loop to remove and append cards

Printing card status

• Using print() instead of "\n"
• Using join and slice instead of a function

Determining Winning hand

• Using .copy()
• using a function to convert strings to values numeric_dealer_hand repeatedly instead of once on a new card received
• early exit if the player busts, instead of confirming for dealer bust too (draw) -using only True and False to determine outcomes (draw, loss, win)

Betting Phase

• not sanitising input/restricting input
• using break (spaghetti code)
• returning multiple values and undefined values (bet) in separate code paths

Player dealing

• Using While True and break for logic control (spaghetti code)
• 3 variables in, 3 variables out - implies this function and the caller both need refactoring

Dealer dealing:

• 2 variables in, 2 variables out (same comment as above - but why only 2 now?
• Using While True and break for logic control (spaghetti code)

etc. more of the above. Let's run over a few of these to help you understand how you can improve your coding.

## The Card

This is probably the start of the design issues, the intermixing of the physical cards verses intangible values associated with the card. To explain - a card is a card. Representing the card is important, but it has no value until you decide what game to play. The game determines the value of the cards, not the cards themselves. That might be confusing - but here's a perfect example: Aces. What are they? Are they 1 or 11? The answer is it depends on the game situation. And it's THERE where the value determination should take place, not with the card.

When you program, most of the issues you encounter are due to bad design, data or functions crossing over each domain's barrier, or state (value of variables) changing where it shouldn't. Ensuring separating the domains is something you'll learn as you improve your craft. A book named "Code Complete" can help you to recognise common mistakes. It's good for beginners and intermediate coders. After that, "Clean Code" will begin the introduction to improving how you think about coding, but it's quite advanced.

With that said, let’s construct a proper deck, starting with the card object.

class Card:
def __init__(self, rank, suit):
self._rank, self._suit = rank, suit

@property
def rank(self):
return self._rank

@property
def suit(self):
return self._suit

def __str__(self):
return f"{self._rank}{self._suit}"


As we can see, we create the card via the init method, and when the card is printed, it will display its rank and suit. Properties of the card, we can request these individually (.rank or .suit).

So, we now think about the types of cards. Cards of rank 2-10 in suits Diamonds, Hearts, Clubs and Spades (D/H/C/S) are simple, Face cards are ranked as King (KD, KH, KC, KS), the Queen and the Jack. Lastly, 4 Ace cards, one per suit. Cards 2-10 will work fine as a Card object, but Face cards and Aces require small modifications.

class Ace(Card):
def __init__(self, rank, suit):
super().__init__(rank, suit)

class FaceCard(Card):
def __init__(self, rank, suit):
super().__init__(rank, suit)
self._rank = ["K", "Q", "J"][self.rank]


FaceCard(Card) looks different, but Ace(Card) you could reduce down to a simple Card - but we don’t - because Aces are seen as unique by us. It doesn’t cost us any more to represent them separately. Just a quick comment on the FaceCard ranking code:

>>> ["K", "Q", "J"][1]
'Q'


If that is still confusing, open python and change 1 to either 0 or 2 to understand the indexing.

## The Deck

The deck will be made up of 52 cards. The deck has an action of dealing a card. Does a Blackjack deck have any other actions? Not that I can think of.

Let's create the deck then. We'll call it BackjackDeck - but you could use this skeleton for different card games, and add Jokers too.

from random import shuffle

class BlackjackDeck:
""" A set of cards suitable for a Blackjack game which deals a card already shuffled """

def __init__(self):
self.cards = []
for suit in ["H", "D", "C", "S"]:
self.cards += [Card(rank, suit) for rank in range(2, 11)]
self.cards += [FaceCard(rank, suit) for rank in range(3)]
self.cards += [Ace("A", suit)]
shuffle(self.cards)

def deal(self):
for card in self.cards:
yield card


As mentioned previously, cards with face 2-10 are built easily, FaceCards and Aces override their classes when instantiated. Looking at Aces, we have only a single Ace per suit, which is why there's no range(x) for them. We create them independently using "A" to make it obvious.

We could do some trick, but the next programmer who reads your code will need to mentally deconstruct your trick to understand your code. Tricks cost companies time and money, so try to make your code obvious and easy to read.

If they don't understand your trick, it's likely they will rewrite your trick. If there are dependencies on the trick, there will be broken code somewhere downstream.

## The Game

After creating the deck like I have, it's now incompatible with your existing code. We will resolve those issues mentioned at the top with changed code.

Firstly, we have to modify you code to include the entry point. This is a basic step in Python, if it's not there, when another script loads your code, it will start playing automatically, rather than loading the objects (breaking the import system).

if __name__=="__main__":

deck = MASTER_DECK.copy()
tokens = 200

while True:


So, thinking about design. Who, What, When, Where, Why.

Who plays the game? The player and the dealer. What wins the game? A better hand than the other players, 21. When is the hand calculated? After each card. Where? Why? Never mind those two.

So, we need a player object which is the same - in real life the dealer has a few rules - but they're essentially the same. Winning is actually calculated after all the players have played their hands and the dealer finishes theirs - so we have a set method of playing. Calculating the hand should be done after a card is given, so we will put the logic for that into the Player.hit() function.

Okay, with this design, we need a Player object, that can hit (and stand), can calculate, can track tokens obviously. What else? Well, as Player and Dealer are going to be the same but different, we need some way to know who is the dealer and who isn't.

class Player:
def __init__(self, name, tokens, is_dealer=None):
self._name, self._tokens = name, tokens
self._cards = []
self._hand_value = 0
self._is_dealer = True if is_dealer else False
self._has_busted = False


That looks pretty good. We have what we needed, and added a property if they've busted.

Now, let's add the .hit() function - when we hit, we're given a new card, we calculate our hand value, and let's set our busted flag if we exceed 21:

def hit(self, card):
self._cards.append(card)
self._hand_value = self._calc_hand_value()

if self._hand_value > 21:
self._has_busted = True


We need a calculate function, the ._calc_hand_value() -

def _calc_hand_value(self):
total = 0
for card in self._cards:
is_numeric = str(card.rank).isnumeric()
if is_numeric:
total += card.rank
else:
if card.rank in ("J", "Q", "K"):
total += 10
for card in self._cards:
if card.rank == "A":
total += 11 if total + 11 <= 21 else 1


We know that the number cards and the face cards have static values, with the Ace card being variable. So, we calculate the value of Ace after the other cards. Admittedly there is lots more optimisation we can perform in this function, but for this, it's fine.

But what about code that the dealer has, which the player doesn't? Primarily this comes down to the hidden card when looking at the hand. So, if we need to change the output, let's add a local variable current_cards and loop through them, hiding the first card if the player is a dealer:

    def show_cards(self):
current_cards = []

for card in self._cards:
if self._is_dealer:
if not current_cards:
current_cards.append("_")
continue
current_cards.append(str(card))
print(f"{self._name} currently has cards: {current_cards}")


Which raises the point, when the dealer flips over his card - the other players are already finished with their hands. So we need a way to revert the dealer back into a player, to use all the standard logic.

    @property
def is_dealer(self):
return self._is_dealer

@is_dealer.setter
def is_dealer(self, value):
if self._is_dealer and value == False:
print("Dealer flips over his hidden card")
self._is_dealer = value



Here we have the "is this player a dealer?" property, and the "switch dealer to player" setter. Admittedly, you could design a class Player(Gambler): and a class Dealer(Gambler): to both inherit the parent class Gambler - but I'll leave that up to you (for when you create AI players).

Now, during the game, when we set the dealer to:

dealer.is_dealer = False
dealer.show_cards()


the dealer will "flip over the hidden card" and play out the hand like a regular player with the same logic. This is another design issue - when making games - it's important that as much behavior reuses code - except when it's very different. Such as Fighter(Character) and Mage(Character) all using .walk() and .run() from Character. When unique behavior is necessary, those should only appear in the Fighter or Mage objects. In this Blackjack instance, only a single flag differentiates a player from a dealer. The benefit is all the actions are the same, and if any bugs are there, they will appear quickly.

## Status

This is getting rather long, so here we will give a quick status.

We've covered the points raised in Master deck, Deck Setup, Pick Cards, and Printing card status.

Next steps are Determining Winning hand, Betting Phase, Player dealing, Dealer dealing.

## Betting & Determining the Winning Hand

Betting consists of asking how many tokens the player wishes to wager, and upon winning, return double, upon losing, return zero - but we also have the scenarios of drawing - both player and dealer having the same - or both busting.

When we have multiple outcomes - you cannot use True or False. Boolean is for only a single outcome, yes, or no.

    from enum import Enum, auto

class Outcome(Enum):
Dealer = auto()
Player = auto()
Draw = auto()
Unknown = auto()


The Unknown scenario isn't YAGNI (You Ain't Gonna Need It), there were situations after adjusting your code where the outcome was indeterminate. Hence, it's been left in for that random cosmic ray flipping memory bits (even though I'm pretty sure all the outcomes are now covered). The winning hand is classified here from the status in both player/dealer objects.

    print("\nFinal Game State: ")
print_game_state(player, dealer)
if dealer.has_busted and player.has_busted:
print("\t\t\t\tBoth Dealer and Player lost. Returning initial bet")
return Outcome.Draw
if dealer.has_busted:
print("\t\t\t\tDealer Busted")
return Outcome.Player
if player.has_busted:
print("\t\t\t\tPlayer busted")
return Outcome.Dealer
if player.hand_value > dealer.hand_value:
print("\t\t\t\tPlayer beats dealer")
return Outcome.Player
if dealer.hand_value > player.hand_value:
print("\t\t\t\tDealer beat the Player")
return Outcome.Dealer
if player.hand_value == dealer.hand_value:
print("\t\t\t\tDealer draws the Player. Returning initial bet")
return Outcome.Draw

print("\t\t\t\t?Unknown scenario?")
return Outcome.Unknown


Now, onto the betting. Let's address user input firstly. We know that there are limited inputs, hit, stand, quit, token amount. Let's put these parameters into the choice function and get a value returned.

def show_menu_in_game(tokens):
separator()
print(f"Your currently have {tokens} tokens remaining.")
print("1. Play another hand")

....

choice = get_user_choice([1, "q"])
if choice == "q":
do_play = False

....

def get_bet_amount(tokens):
print(f"How many tokens do you want to bet for this game of Blackjack? 1-{tokens}")
return get_user_choice(range(1, tokens + 1), f"between 1 and {tokens}")

def get_user_choice(params, display_params=""):
is_a_valid_choice = False
if display_params == "":
display_params = params
while not is_a_valid_choice:
choice = input(f"\nPlease select a choice ({display_params}): ")
if choice.isalpha():
choice = choice.lower()
if choice.isnumeric():
choice = int(choice)
if choice in params:
return choice

print(f"That's unfortunately not a selection you can make. Please select one of these: {display_params}")


Admittedly we don't do anything with is_a_valid_choice - but it does make it clearer to the reader that we're going to continue to loop until the user selects only what is possible.

## Dealer/Player Dealing

I've not bothered to optimise the start of the game (it's not pretty):

def get_game_result(tokens):
deck = BlackjackDeck()
cards = deck.deal()
player = Player("Player", tokens)
dealer = Player("Dealer", 999, True)
player.hit(next(cards))
player.hit(next(cards))
dealer.hit(next(cards))
dealer.hit(next(cards))

print_game_state(player, dealer)
print(f"Player's hand calculates to {player.hand_value}")


But if I was going to make it cleaner, there would be a list of players chosen from the menu (Human, AI, Dealer) and passed into the game function. From that, loop over each and allocate 2 cards from the deck.

The player phase is much longer than the dealer, due to the inputs, so let's just look at the dealer:

from time import sleep

if dealer.hand_value < 17:
playing = True
while playing:
print("Dealer takes another card...")
sleep(2)
dealer.hit(next(cards))
dealer.show_cards()
print(f"Dealer's hand calculates to {dealer.hand_value}")

if dealer.hand_value > 21:
playing = False
print(f"Dealer Busted with {dealer.hand_value}")
else:
if 17 <= dealer.hand_value <= 21:
print(f"Dealer stands according to the rules with {dealer.hand_value}")
playing = False


Most casinos have rule that the dealer must hit on a certain value or less to keep the game interesting. I consulted Wikipedia and took the most discussed value, 17.

Adding the time pauses in, gave it a little more excitement.

Well, that covers the issues which jumped out at me - and through class objects and their methods being used, demonstrating how to perform other functionality such as hand calculations to reduce chunks of coding.

I haven't pasted my whole rewrite of your code intentionally, because the last few pieces are reasonably easy to finish off, but I'm happy to answer questions if you do attempt this exercise.

Keep coding!

# Creating the deck

Rather than using copy to clone the deck, it's simple enough to just create a new deck:

def create_deck():
# need 4 copies of each card, representing each suit
deck = [face for _ in range(4) for face in 'AJKQ']
deck.extend([str(i) for _ in range(4) for i in range(2,11)])

# This shuffle happens in-place
shuffle(deck)

return deck


Also, it might be easier to just shuffle the deck, since that's what you would do in a normal game.

# Choosing Cards

Since we've shuffled the deck, it becomes significantly easier to choose cards. You can treat deck like a stack and just pop cards off of the top of the stack.

def pick_cards(deck):
hand = []

for _ in range(2):
hand.append(deck.pop())

return hand



You could use list comprehension syntax:

hand = [deck.pop() for _ in range(2)]


But personally, I am not a huge fan of the side effect being wrapped into the statement like this.

I'd also move the length check out of the function, since it's a bit clearer to re-generate the deck, then choose cards:

if len(deck) < 6:
deck = generate_deck()

hand = pick_cards(deck)


# String Concatenation

There are a few places in your code where you have something like:

print("The dealer has these cards:\n" + ", ".join(dealer_hand))


Snippets like this can be replaced by f-strings:

print(f"The dealer has these cards:\n{','.join(dealer_hand)")


# print_ui

On top of the f-string refactor, the print statements are the same no matter the game state, so print first. Furthermore, the name of the function is to print the ui, so just print:

def print_ui(player_hand, dealer_hand, deck):
message = '\n\n'.join((
f"The dealer has these cards:\n{', '.join(dealer_hand)}",
f"You have these cards:\n{', '.join(player_hand)}",
f"There are {len(deck)} cards left in the deck"
))
print(message)


# Getting Card Values

It is better to check for multiple equality conditions using membership testing:

# go from this
if card == 'J' or card ==...

# to this
if card in 'JQK':


Better yet, a dictionary might be best for this:

# this can stay in global scope, hence why it
# is named with ALL_CAPS
CARD_VALUES = {str(i): i for i in range(2, 11)}

for face in 'JQK':
CARD_VALUES[face] = 10

CARD_VALUES['A'] = 11

# Then in your function, it's simply a matter of:
def get_hand_total(hand):
hand_total = sum(CARD_VALUES[card] for card in hand)

if hand_total <= 21:
return hand_total

aces = hand.count('A')

# if there are any aces to swap, this loop
# will execute
while aces and hand_total > 21:
# swap the value from 11 to 1 by just
# subtracting 10
hand_total -= 10
aces -= 1

return hand_total


# Checking for a Win

This gets much simpler now that get_values has been refactored. I'd name it player_won, it's a more descriptive name since you are only returning True if the player wins. Have this function take just the totals:

def player_won(player_total, dealer_total):
if player_total > 21 and dealer_total > 21:
return False
elif player_total > 21 and dealer_total <= 21:
return False
elif player_total <= 21 and dealer_total > 21:
return True
elif dealer_total < player_total <= 21:
return True
else:
return False


# The Dealer's Turn

This looks like it's combining the efforts of get_values and pick_cards. We can at least slim it down via the deck.pop() method:

def dealer_turn(deck, hand):
while True:
if not deck:
# I'll raise an error here so that we can
# remake the deck, I'll handle that operation
# shortly
raise ValueError("No cards left in deck, regenerating")

hand_total = get_hand_total(hand=hand)

if hand_total < 16:
# choose a card
hand.append(deck.pop())
elif hand_total == 16 and randint(0, 1):
hand.append(deck.pop())
else:
break


We don't need to return anything, since deck and hand get modified in-place. To use this function:

while True:
try:
dealer_turn(deck=deck, hand=dealer_hand)
except ValueError:
hands = dealer_hand + player_hand

deck = []
for card in create_deck():
try:
hands.remove(card)
except:
deck.append(cards)
else:
break


It's a single-iteration while loop. If an exception is raised, then the deck gets reset, minus the cards in the players' hands, then dealer_turn gets called again. Otherwise, the loop exits. This can be wrapped into a take_turn function:

def take_turn(deck, player_hand, dealer_hand, turn):
"""Function that wraps turn-taking and recreates the deck if it's emtpy"""
if turn == 'player_turn':
turn_func = player_turn
hand = player_hand
else:
turn_func = dealer_turn
hand = dealer_hand

while True:
try:
turn_func(deck=deck, hand=hand)
except ValueError:
hands = dealer_hand + player_hand
deck = []
for card in create_deck():
try:
hands.remove(card)
except:
deck.append(cards)
else:
break

return deck



We return deck here because if we re-create the deck, that won't carry out of the scope of this function. However, this looks bad, and this is where the suits come in:

# The Case for Suits

The suits make checking for drawn cards easier, because each card is unique. The easy way to create suits for each card is to modify our create_deck function:

def create_deck():
suits = 'HDCS'
deck = [f"{suit}{face}" for suit in suits for face in 'AJKQ']
deck.extend([f"{suit}{i}" for suit in suits for i in range(2,11)])

# This shuffle happens in-place
shuffle(deck)

return deck


Which cleans up the uniqueness check a lot:

def take_turn(deck, player_hand, dealer_hand, turn):
"""Function that wraps turn-taking and recreates the deck if it's emtpy"""
if turn == 'player_turn':
turn_func = player_turn
hand = player_hand
else:
turn_func = dealer_turn
hand = dealer_hand

while True:
try:
turn_func(deck=deck, hand=hand)
except ValueError:
hands = set(dealer_hand + player_hand)
deck = [card for card in create_deck() if card not in hands]
else:
break

return deck


Though this requires a slight modification of get_hand_total as well:

def get_hand_total(hand):
hand_total = sum(
# use card.strip() to get rid of the suits
CARD_VALUES[card.strip('DCSH')] for card in hand
)

if hand_total <= 21:
return hand_total

aces = sum('A' in card for card in hand)

# if there are any aces to swap, this loop
# will execute
while aces and hand_total > 21:
# swap the value from 11 to 1 by just
# subtracting 10
hand_total -= 10
aces -= 1

return hand_total


A few things here. First, I think calling the function place_bet is a bit better as far as naming. Second, no need to do the second int conversion on bet, it's already an int. Third, you can just compare if tokens < bet rather than doing the subtraction twice. Last, I don't think you need to do the subtraction in the function, subtract the bet from the tokens after a good bet is chosen:

def place_bet(tokens):
"""User places bet"""
print(f"You have {tokens} tokens")

while True:
try:
except ValueError:
continue

if bet > tokens:
print("Don't bet more than you have")
else:
break

return bet

tokens = 200

tokens -= place_bet(tokens)


# User's Turn

This will wind up looking quite similar to the dealer_turn function. However, the user should be able to hit multiple times, so the break in the while loop will only occur when the stay choice is picked.

def player_turn(deck, hand):
"""Player takes their turn"""
while True:
if not deck:
raise ValueError("No cards left in deck, regenerating")

user_command = input("Would you like to hit or to stay? (H/S): ").lower()

if user_command == 'h':
player_hand.append(deck.pop())
elif user_command == 's':
break
else:
print("Please choose either H for hit or S for stay")
continue


Again, no need to return deck or hand because they are modified in-place.

# Main function

I'd wrap your game in a main function, it makes it a bit easier to wrap in the while loop:

def main(deck, tokens):
player_hand, dealer_hand = pick_cards(), pick_cards()

for state in ('betting', 'player_turn', 'dealer_turn'):
print_ui(
player_hand=player_hand,
dealer_hand=dealer_hand,
deck=deck
)

if state == 'betting':
tokens -= place_bet(tokens)
else:
deck = take_turn(
deck=deck,
player_hand=player_hand,
dealer_hand=dealer_hand,
turn=state
)

# This will skip the rest of the loop
# until the dealer's turn is over
if state != 'dealer_turn':
continue

player_total, dealer_total = get_hand_total(player_hand), get_hand_total(dealer_total)

if player_won(player_total, dealer_total):
print("You won!")
tokens += 2 * bet
break
else:
print("Dealer won")
break

return deck, tokens

if __name__ == "__main__":
deck = create_deck()
tokens = 200

while True:
deck, tokens = main(deck, tokens)
if not tokens:
input("You have no more tokens to spend. Hit enter to quit.")
quit()

again = input("Play Again? (y/n) ")

if again == 'y':
continue
elif again == 'n':
quit()
else:
print("Please input either y or n")


I've kept the main function wrapped in a while as you had it, however, the game itself is a for loop. There are only three stages, and they go in order. Once the dealer has played, a win is checked and the user is asked if they want to play again.

# What if I want the real suits?

You can print the unicode for the suit emojis:

SUITS = [chr(i) for i in range(9828, 9832)]

print(SUITS)
['♤', '♥', '♦', '♧']


A couple of things that I've noticed; for a better player experience, it might be worth allocating a suit per card, the easiest way to do this would be to modify the MASTER_DECK array as follows (with the correct character encoding, you could also have ♡♤♧♢ but I'm not sure whether your intention was to stick with ASCII/Extended ASCII only or not, I also don't know how Python handles extended character sets):

MASTER_DECK = ["AH", "AD", "AS", "AC",
"2H", "2D", "2S", "2C",
...
"JH", "JD", "JS", "JC",
"QH", "QD", "QS", "QC",
"KH", "KD", "KS", "KC"]


In your numeric_cards function, you will then need to convert the values by sub-string, taking the first digit; in this case, the substring 1 will represent 10H, 10D etc, so:

def numeric_cards(hand):
"""Turns card letters into their number values"""
for card_number, card in enumerate(hand):
if card[0] == "J" or card [0]== "Q" or card[0] == "K" or card[0] == "1":
hand[card_number] = 10
elif card[0] == "A":
hand[card_number] = 11
...