3
\$\begingroup\$

This is my attempt at a terminal based blackjack game. Currently, the game does not support betting, although I intend to add it in the future.

I would very much appreciate general feedback, but I am especially interested in the following:

  • Comments on docstrings and type hints, as this is my first time trying to properly use either

  • Advice about printing long strings to the terminal. I am at present using \ line continuation characters to avoid overly long lines in the code, but this seems to either require weird indentation or printing excess whitespace to the terminal.

  • I intend to, in the future, use many of these classes to write a GUI based blackjack game with tkinter. If I've made any decisions that will make that kind of reuse challenging, advice would be great!

import random


SUITS_DICT = {
    'spades': 0x1f0a0,
    'hearts': 0x1f0b0,
    'diamonds': 0x1f0c0,
    'clubs': 0x1f0d0,
}

KNIGHT_RANK = 12


class Card():
    """A class used to represent a playing card"""

    def __init__(self, number: int, suit: str):
        self.suit = suit
        self.number = number

    def __repr__(self) -> str:
        return f'Card({self.number}, {self.suit})'

    def __str__(self) -> str:
        cardNo = SUITS_DICT[self.suit] + self.number
        return chr(cardNo) if self.number < KNIGHT_RANK else chr(cardNo + 1)

    def blackjack_value(self) -> int:
        """Returns the card's value in blackjack"""
        return self.number if self.number < 10 else 10


CUT_CARD = Card(0, 'spades')


class CardCollection(list):
    """A subclass of list for collections of playing cards"""

    def __str__(self) -> str:
        outStr = ''
        for card in self:
            outStr += str(card) + ' '
        return outStr


class Deck(CardCollection):
    """A class to represent a deck (or multiple decks) of cards"""

    def __init__(self, deckMultiple: int):
        self.deckMultiple = deckMultiple
        self.fill_deck()

    def cut(self):
        random.shuffle(self)
        self.insert(random.randrange(-75, -60), CUT_CARD)

    def fill_deck(self):
        """Fills the deck with cards"""
        for i in range(self.deckMultiple):
            for suit in SUITS_DICT:
                for n in range(1, 14):
                    self.append(Card(n, suit))

    def refresh(self):
        """Removes any cards remaining in the deck,
        and fills it with fresh cards"""
        self.clear()
        self.fill_deck()

    def deal_to(self, hand: CardCollection):
        """Removes a card from the deck, and adds it to another card
        collection. If the card drawn is a card used to mark where
        the deck is cut, refreshes the deck."""
        card = self.pop(0)
        if card == CUT_CARD:
            self.refresh()
            hand.append(self.pop(0))
        else:
            hand.append(card)


class BlackjackHand(CardCollection):
    """A Class to represent a hand of cards in blackjack"""

    def __init__(self, isDealer: bool = False):
        self.isDealer = isDealer

    def score(self) -> int:
        """Returns the hands value in blackjack"""
        aceCount = 0
        total = 0
        for card in self:
            if card.number == 1:
                aceCount += 1
            else:
                total += card.blackjack_value()
        for i in range(aceCount):
            if total + 11 > 21:
                total += 1
            else:
                total += 11
        return total

    def evaluate(self):
        """Returns the cards reuslt in blackjack"""
        if self.score() == 21 and len(self) == 2:
            return 'blackjack'
        elif self.score() < 22:
            return self.score()
        else:
            return 'bust'


class HandList(list):
    """A class to represent a group of hands"""

    def __str__(self) -> str:
        strList = []
        for hand in self:
            strList.append(str(hand))
        return '    '.join(strList)


class Blackjack():
    """A class to represent a game of blackjack"""

    def __init__(self, noDecks: int = 6):
        self.reset_hands()
        self.shoe = Deck(noDecks)
        self.shoe.cut()

    def reset_hands(self):
        """Removes all hands associated with the game and creates empty ones"""
        self.playerHands = HandList()
        self.playerHands.append(BlackjackHand())
        self.dealerHand = BlackjackHand(True)

    def hit(self, hand: BlackjackHand):
        """Moves one hand from the shoe to a hand"""
        self.shoe.deal_to(hand)

    def split(self, hand: BlackjackHand):
        """Splits a hand containing two cards of the same value into two hands"""
        if (len(hand) == 2 and
                hand[0].blackjack_value() == hand[-1].blackjack_value()):
            self.playerHands.append(BlackjackHand())
            self.playerHands[-1].append(hand.pop(-1))
            self.hit(hand)
            self.hit(self.playerHands[-1])
        else:
            raise ValueError

    # Doubling down will be implemented with betting
    # def doubleDown(self, hand: BlackjackHand):
    #     """"""
    #     if len(hand) == 2:
    #         self.hit(hand)
    #     else:
    #         raise ValueError

    def player_actions_text(self) -> list:
        """Allows the player to make their moves,
        returning a list of the results"""
        results = []
        for hand in self.playerHands:
            print(f'Your hand is {hand}, which scores {hand.score()}')
            while hand.score() < 22:
                playerAction = input('What would you like to do? ').lower()
                match playerAction:
                    case 'hit':
                        self.hit(hand)
                    # Doubling down will be implemented with betting
                    # case 'double down':
                    #     try:
                    #         game.doubleDown(hand)
                    #         break
                    #     except ValueError:
                    #         print('You can only double down on the initial deal!')
                    case 'split':
                        try:
                            self.split(hand)
                        except ValueError:
                            print('You can only split two cards with \
                                the same value!')
                    case 'stand':
                        break
                    case _:
                        print("That's not a valid move!")
                print(f'Your hand is {hand}, which scores {hand.score()}.')
            else:
                print(f"Your hand is {hand}, which scores {hand.score()}. \
                        You're bust!")
            results.append(hand.evaluate())
        return results

    def dealer_actions_text(self):
        """Performs the dealer's moves, and returns the dealer's result"""
        while self.dealerHand.score() < 17:
            print("That's less than 17, so the dealer hits")
            self.hit(self.dealerHand)
            print(f"The dealer's hand is {self.dealerHand}, \
                    which scores {self.dealerHand.score()}.")
        else:
            if self.dealerHand.score() > 21:
                print(f"The dealer's hand is {self.dealerHand}, \
                        which scores {self.dealerHand.score()}. They're bust!")
            else:
                print(f"The dealer's hand is {self.dealerHand}. \
                        They stand on {self.dealerHand.score()}.")
            return self.dealerHand.evaluate()


def declare_results(playerResults: list, dealerResult: list):
    """Prints the results of a game to the terminal"""
    for i in range(len(playerResults)):
        handNumber = i+1
        if (playerResults[i] == 'blackjack' and
                dealerResult == 'blackjack'):
            print(f"Hand {handNumber} and the dealer both got blackjack. \
                    It's a push!")
        elif playerResults[i] == 'blackjack':
            print(f"Hand {handNumber} got blackjack and won!")
        elif playerResults[i] == 'bust':
            print(f"Hand {handNumber} went bust and lost!")
        elif dealerResult == 'blackjack':
            print(f"Hand {handNumber} scored {playerResults[i]} \
                    and the dealer got blackjack. Hand {handNumber} lost!")
        elif dealerResult == 'bust':
            print(f"Hand {handNumber} scored {playerResults[i]} \
                    and the dealer went bust. Hand {handNumber} won!")
        else:
            print(f"Hand {handNumber} scored {playerResults[i]} \
                    and the dealer scored {dealerResult}")
            if playerResults[i] == dealerResult:
                print("It's a push!")
            elif playerResults[i] > dealerResult:
                print(f"Hand {handNumber} wins!")
            elif playerResults[i] < dealerResult:
                print(f"Hand {handNumber} loses!")
            else:
                raise Exception


def main():
    my_game = Blackjack()
    while True:
        for i in range(2):
            for hand in my_game.playerHands:
                my_game.hit(hand)
            my_game.hit(my_game.dealerHand)
        print(f"The dealer's facing card is {my_game.dealerHand[0]}")
        playerResults = my_game.player_actions_text()
        print(f"The dealer's hand is {my_game.dealerHand}, \
              which scores {my_game.dealerHand.score()}.")
        dealerResult = my_game.dealer_actions_text()
        declare_results(playerResults, dealerResult)
        if input('Do you want to play another round? (y/n) ').lower() == 'y':
            my_game.reset_hands()
        else:
            break


if __name__ == '__main__':
    main()
\$\endgroup\$
2
  • \$\begingroup\$ Deck.__init__() should immediately raise fatal error if deck_multiple < 2. Else we have a ticking time bomb where cut() will blow up with IndexError. If app developer wrote a bug it should be easy to find, pointing blame at the immediate cause rather than waiting for some innocent caller to trigger the bomb. Rename the class to plural Decks, or to Shoe. Rename noDecks to num_decks for clarity. Having evaluate return str or int wouldn't be my first choice. Rather than 'bust', consider returning 0, float('NaN'), or None. Spell the parameter: blackjack_hand \$\endgroup\$
    – J_H
    Jun 11, 2023 at 15:31
  • \$\begingroup\$ The [player, dealer] list of hands makes sense until we split(). Consider instead modeling it as a dict with keys of 'player', 'dealer', and after a split 'player2'. \$\endgroup\$
    – J_H
    Jun 11, 2023 at 15:40

1 Answer 1

2
\$\begingroup\$

Run your code through a PEP8 linter. The most frequent issue is variable naming for which you use lowerCamelCase but this needs to be changed to lower_snake_case.

SUITS_DICT is a neat trick, but has a few issues:

  • you should add documentation, something like
Suit = Literal[
    'spades', 'diamonds', 'clubs', 'hearts',
]

# This is a dictionary of Unicode suit codepoints. The codepoints themselves
# don't represent anything and need to be added to the card number.
SUITS_DICT: dict[Suit, int] = {
    'spades':   0x1f0a0,  # then 🂡 (+1, ace) through 🂮 (+14, king)
    'hearts':   0x1f0b0,  # similarly for the other suits
    'diamonds': 0x1f0c0,
    'clubs':    0x1f0d0,
}
  • it's not very legible or accessible. The card values and suits are extremely small and require zooming in; and there's more risk that the user's font cannot render these symbols. So as neat of a trick as it is, it's probably not as well-advised as to simply print the value in regular numerals aside the suit symbol.

You're missing several type hints. Anything (including constructors) that doesn't have a return value needs to be marked -> None.

() after a class declaration is redundant and should be deleted.

CardCollection.__str__ is likely to suffer from O(n^2) runtime due to repeated string concatenation. Options to solve this include a join on a generator. HandList is slightly better, but needn't construct an in-memory list; just use a bare generator i.e. ' '.join(str(hand) for hand in self).

isDealer seems unused so delete it.

The user interface is a little hostile to usability. When you ask what the user would like to do, you should print the available options.

This:

                        print('You can only split two cards with \
                            the same value!')

is unlikely to do your intent. After the newline-escape, the string will include all of the indentation spaces. Instead,

print('You can only split two cards with '
      'the same value!')

for i in range(2) should replace i with _ since it's unused.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.