Master Object-Oriented Programming by Creating a Card Game

What will we cover?

The best way to learn Object-Oriented Programming is by creating something object-oriented. In this tutorial we will create a simple card game.

Step 1: What is Object-Oriented Programming?

At is core, Object-Oriented Programming helps you with structuring your program to resemble reality.

That is, you declare objects with parameters and methods (functions) you need on them.

The best way is to learn it by creating an easy example you can relate to.

Consider the following.

This diagram represents three objects we want to model. The first is a Card, second a Deck, and finally a Hand.

There are many things to notice, but fist that Hand is actually a sub-class of Deck. What does that mean? Don’t worry, we’ll get there.

Step 2: Implement the Card class

What does it all mean?

Well, first of all, there are many ways to represent a class and the above is just one possible option. But the if we look at Card, we have two groups to look at. First, suit and rank. Second, __str__() and __lt__(other).

The suit and rank are instance variables, while __str__() and __lt__(other) are class methods.

Instance variables are variables only available to a specific object instance. Hence, different instances of the same class can have different values.

Class methods are methods you can call on an object instance.

The function __str__() is a special method, which will give the string representation of the object instance. This is how the object will be represented if printed.

The function __lt__(other) is also a special method, which returns whether the object and another object other is greater. Hence, it returns a truths statement.

One way to implement is as follows.

class Card:
    suits = ['\u2666', '\u2665', '\u2663', '\u2660']
    ranks = ["2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"]
    
    def __init__(self, suit, rank):
        self.suit = suit
        self.rank = rank
        
    def __str__(self):
        return f"{Card.ranks[self.rank]}{Card.suits[self.suit]}"
    
    def __lt__(self, other):
        if self.rank == other.rank:
            return self.suit < other.suit
        else:
            return self.rank < other.rank

Notice we also have class variables suits and ranks (with s). They are used to give a representation in the __str__() method.

Class variables are available and the same across all objects.

Also, notice the __init__(self, suit, rank), which is a method which is called at creation of the object, and it assigns variables to the instance variables (the ones with self)

Step 3: Implement the Deck class

A Deck should represent a pile of card.

Here we want it to create a new shuffled deck of cards when you create a new instance of the Deck object.

That can be accomplished as follows.

import random
class Deck:
    def __init__(self):
        self.deck = []
        for suit in range(4):
            for rank in range(13):
                self.deck.append(Card(suit, rank))
        self.shuffle()
        
    def __len__(self):
        return len(self.deck)
    
    def add_card(self, card):
        self.deck.append(card)
        
    def pop_card(self):
        return self.deck.pop()
    
    def shuffle(self):
        random.shuffle(self.deck)

Notice that __len__() method is also a special, and returns the length of the object. This is handy, if you want to use len(…) on an object instance of Deck.

The rest of the methods are simple and straightforward.

Step 4: Implement the Hand class

The hand class is a sub-class of Deck. How does that make sense?

Well, it will share the same instance variable and methods with some additional ones.

Think about it, a Hand is like a Deck of card, as it is a collection of cards.

How to implement that.

class Hand(Deck):
    def __init__(self, label):
        self.deck = []
        self.label = label
        self.win_count = 0
        
    def __str__(self):
        return self.label + ': ' + ' '.join([str(card) for card in self.deck])
    
    def get_label(self):
        return self.label
    
    def get_win_count(self):
        return self.win_count
    
    def round_winner(self):
        self.win_count = self.win_count + 1

Notice that we overwrite the __init__(…) method, as we do not want to create a full deck of cards. Here we start with empty hands.

Step 5: A simple game

  • Create a Deck of cards.
  • Create 4 players (P1, P2, P3, P4)
  • Divided all cards to 4 players.
  • Assume you are P1, print the hand of P1.
  • The game has 13 rounds:
    • Each player plays 1 card.
    • The player with highest card wins.
    • Update the score for the winning hand.
    • Print cards played in round and the winner (with winning card).
  • After the 13 rounds – print score for all players (P1, P2, P3, P4).

How to do that?

deck = Deck()
hands = []
for i in range(1, 5):
    hands.append(Hand(f'P{i}'))
    
while len(deck) > 0:
    for hand in hands:
        hand.add_card(deck.pop_card())
        
print(hands[0])
for i in range(13):
    input()
    played_cards = []
    for hand in hands:
        played_cards.append(hand.pop_card())
    
    winner_card = max(played_cards)
    winner_hand = hands[played_cards.index(winner_card)]
    winner_hand.round_winner()
    
    print(f"R{i}: " + ' '.join([str(card) for card in played_cards]) + f' Winner: {winner_hand.get_label()} {str(winner_card)}')
    
for hand in hands:
    print(f"Score for {hand.get_label()}: {hand.get_win_count()}")

Amazing, right?

Want to learn more?

If this is something you like and you want to get started with Python, then this is part of a 8 hours FREE video course with full explanations, projects on each levels, and guided solutions.

The course is structured with the following resources to improve your learning experience.

  • 17 video lessons teaching you everything you need to know to get started with Python.
  • 34 Jupyter Notebooks with lesson code and projects.
  • A FREE 70+ pages eBook with all the learnings from the lessons.

See the full FREE course page here.

Avoid Common Object Oriented Programming Pitfalls

What will we cover in this tutorial?

I did them myself. When you first learn about object oriented programming, you get exited about it and want to turn everything into objects.

Very often it fails to make the code easier to maintain and understand. Yes, that is one core goal of creating object oriented programming, to make it easier to maintain and understand.

Often we just turn the traditional way of programming into objects. But that is not the point. The point is to model it in an object oriented way.

Before that, let’s just look at why we use object oriented programming.

Step 1: Why use object oriented programming?

When introducing object oriented programming to beginners it often introduces too many concepts at once.

Simply because object oriented programming is awesome and can do so many things.

Let’s just start simple with the core of the object oriented programming idea.

We want to make your program easier to understand and maintain.

That is the goal for any programmer. Also, when it comes to object oriented programming.

Object oriented programming tries to make the link between the real world and the programming world as close as possible. We humans understand the objects in the real world better than we understand how a computer pushes bits and bytes around in the memory and CPU.

Luckily, we do not need to understand all that. We just need to understand how to program the computer through a programming language.

Object oriented programming tries to make that easier with modeling the programs with objects, which are related to the way we humans understand things.

Let’s try with a simple example of a Stack.

A Stack has a top and bottom, it has operations push and pop.

How would you model the above without using object oriented programming.

stack_size = 8
stack = [None]*stack_size
top = -1 # empty stack
def pop():
    global top, stack
    if top == -1:
        return None
    else:
        top -= 1
        return stack[top + 1]
def push(element):
    global top, stack, stack_size
    if top + 1 >= stack_size:
        stack += [None]*stack_size
        stack_size *= 2
    top += 1
    stack[top] = element
def print_stack():
    global top, stack, stack_size
    for i in range(top + 1):
        print(stack[i], '', end='')
    print(" (size: " + str(stack_size) + ")")

print_stack()
for i in range(10):
    push(i)
print_stack()
for i in range(8):
    pop()
print_stack()

That is confusing code, right?

First of all, we use global variables. That makes the function calls hard to understand, as they have side effects.

This is made more confusing than necessary. It does use Python lists like an Array. That is not needed, but it is just to exemplify how difficult it is to make intuitive code if you model the world like a computer works.

Step 2: Using object oriented programming to solve the above (first step – the less bad, but still not good solution)

First time you are asked to create stack using an object oriented approach, you will probably do it in a non intuitive way.

Say, you think of a Stack like a object. The stack is the full object.

What does a stack consists of?

Well items which are piled on top of each other, and you can take the top off.

How could that be modeled?

Often the straight forward way from a classical thinking way into the a class.

class Stack:
    def __init__(self):
        self.stack_size = 8
        self.stack = [None]*self.stack_size
        self.top = -1 # empty stack
    def pop(self):
        if self.top == -1:
            return None
        else:
            self.top -= 1
            return self.stack[self.top + 1]
    def push(self, element):
        if self.top + 1 >= self.stack_size:
            self.stack += [None]*self.stack_size
            self.stack_size *= 2
        self.top += 1
        self.stack[self.top] = element
    def print_stack(self):
        for i in range(self.top + 1):
            print(self.stack[i], '', end='')
        print(" (size: " + str(self.stack_size) + ")")

s = Stack()
s.print_stack()
for i in range(10):
    s.push(i)
s.print_stack()
for i in range(8):
    s.pop()
s.print_stack()

If you inspect the code, it is actually the same code. Which in some ways has improved it.

  1. We do not have global variables anymore. They are tied to the Stack class.
  2. The function calls push and pop are also tied to the Stack class.
  3. Also, when we use the Stack, it is clear from the context, as it is tied to the variable s, in this case.

So what is wrong?

Well, it is still not simple to understand what happens in the code. It takes time to understand, even with this simple code.

The functions use variables like top and assigns it to -1 if the stack is empty. It requires you to investigate pop and the constructor to understand that.

The key is to keep it simple.

So how to do that?

Step 3: A simple way to model it

Let’s take a look at the drawing again.

A stack.

A stack actually consists of object on it. Hence, the stack is the abstraction that keeps objects in a specific order.

That said, we need to model that closer, as it will be easier to understand for the reader of the code.

The objects on the stack we will call Nodes.

What characterizes a Node? It lies either on the bottom or on top of another Node.

What characterizes a Stack? It knows the top of the stack and can push and pop Nodes.

How can that be turned into code?

class Node:
    def __init__(self, element=None, next_node=None):
        self.element = element
        self.next_node = next_node

class Stack:
    def __init__(self):
        self.top = None
    def push(self, element):
        self.top = Node(element, self.top)
    def pop(self):
        element = self.top.element
        self.top = self.top.next_node
        return element

s = Stack()
for i in range(20):
    s.push(i)
for i in range(10):
    print(s.pop(), '', end='')
print()

How is that code? Easier to understand?

Of course, normally a Stack would as a minimum have a helper function is_empty() to return if stack is empty. That can be added easily. You see how?

class Node:
    def __init__(self, element=None, next_node=None):
        self.element = element
        self.next_node = next_node

class Stack:
    def __init__(self):
        self.top = None
    def push(self, element):
        self.top = Node(element, self.top)
    def pop(self):
        element = self.top.element
        self.top = self.top.next_node
        return element
    def is_empty(self):
        return self.top == None

s = Stack()
for i in range(20):
    s.push(i)
while not s.is_empty():
    print(s.pop(), '', end='')
print()

Do you get the sense of it now?

Model the code as closely to the reality we humans understand. It makes the code easy to read and understand. Hence, easy to maintain.