Skip to main content

Building a card game with pygame

Card games are a great way to learn how to program. We get to build a model of the game, game logic, and a visual interface.

Playing SnaPy

A classic card game is Snap. The rules are pretty simple, which makes it great for building a first card game. We can spend more time on the modelling and visualization, which can be used on other card games too.

The game rules

To simplify this tutorial, we'll limit our game to 2 players. Here's the rules for our 2 player game:

  • The deck is shuffled, and dealt evenly between the two players.
  • Each player takes turns drawing a card and places it face up on top of the last card drawn.
  • If the card placed is a match (by value) of the previous card, the first player to call "Snap!" wins the entire pile of cards. These cards are added to their hand.
  • If a player calls "Snap!" and the card placed is not a match (i.e. falsely calls "Snap!"), then the other player takes the pile of cards.
  • The first player to run out of cards loses, and the other player wins.

Creating a new project

Let's head over to Replit and create a new repl. Choose Pygame as the template to create a repl from. Now, give this repl a name, like "SnaPy".

Creating a new repl

After the repl has booted up, you should see a main.py file. We'll use this file for the main game loop, but we'll create other files for the game models and logic.

For our Snap game, we'll need some images of cards to display. Create a folder called images in the file explorer of your repl.

Now download these card images to your computer. Unzip the file, and drag and drop the images it contains to the images folder in your repl.

Getting started with pygame

A popular game framework in Python is pygame. It has functionality to draw shapes and images to the screen, get user input, play sounds and more. We'll use some of the basic functionality in this game to see how it works.

We can import it into our project by adding the following line to our main.py file:

import pygame

To get the pygame framework started, we need to add some initialization code:

pygame.init()
bounds = (1024, 768)
window = pygame.display.set_mode(bounds)
pygame.display.set_caption("SnaPy")
  • Line 1 starts up the pygame system, by initializing its modules (for example, the font, sound and graphics code).
  • Line 2 creates a new tuple called bounds. This tuple contains the dimensions of the window that we'll run our Snap game in.
  • Line 3 creates a new window for us to display our game in.
  • Line 4 gives the window a caption, or title. This can be whatever you'd like to call the game.

If you run the project, using the "Run" button at the top center of the Repl, you should see a small blank window come up. That means everything is initialized and working so far. Not much, but it's our blank canvas to get started with!

Designing the game model

Python is an object-oriented language. With object-oriented programming, we identify different parts and entities of the game and model them as classes and objects. Let's draw the game, and see what parts we need to model.

Game objects

From the above image, we can see that we can model the following:

  • Individual cards
  • A deck of cards, which is a collection of all the cards
  • Players, with their hand of cards and name
  • A pile of cards, which is a collection of cards that are face up on the table

Note that this is one way to group the game parts, but it is not the only way. There are many ways to identify individual objects, and a big factor is how granular you need to be, and how responsibilities are assigned. This is part art, part experience and part computer science. Often we start with a model design, and then refine it over time as we learn more about the game and the dynamics of the model.

We'll also need logic to control the rules and the state of the game. Often this part of the program is known as the "game engine". We'll model this as a class as well.

Here is a diagram of all the classes we'll build.

Class model

The classes have some of the key properties and methods that they will need.

Building the game model

Let's start by building the Card class. This class will represent a single card.

Create a new file called models.py in Replit. Inside this file we can define the Card class.

At the top of the file, add in some package imports:

  • Enum for enumerations (which we'll use for defining the card suits).
  • pygame, to load up the card images.
  • random, to use when we shuffle the deck.
from enum import Enum
import pygame
import random

Now let's add in the suits we'll need for the cards. We'll use the Enum class to define the different suits.

class Suits(Enum):
CLUB = 0
SPADE = 1
HEART = 2
DIAMOND = 3

Alright, the boilerplate is out of the way. Now, lets define the Card class.

class Card:
suit = None
value = None
image = None

def __init__(self, suit, value):
self.suit = suit
self.value = value
self.image = pygame.image.load('images/' + self.suit.name + '-' + str(self.value) + '.svg')

From this small class, we can see the various parts of a Python class. The first line is the class definition. The next 3 lines are the class properties (variables that are controlled by objects made from the class). Then we have the __init__ method, which is called the constructor method. This is where we initialize the class. In this case, we'll initialize the suit and value properties for the card. We'll also load the image for the card using Pygame's image.load function, with the filepath constructed from the suit and value. This implies that all the card images are all actually named according to the suit and value.

Now that we have suits and cards, let's create a Deck of cards.

class Deck:
cards = None

def __init__(self):
self.cards = []
for suit in Suits:
for value in range(1,14):
self.cards.append(Card(suit, value))

def shuffle(self):
random.shuffle(self.cards)

def deal(self):
return self.cards.pop()

def length(self):
return len(self.cards)

The Deck class is a collection of cards. It has a list of cards, and a few methods to manipulate the list.

The constructor, __init__ initializes the cards list, and populates it with all the cards in a deck, using 2 for loops. The first loop iterates over the Suits enum, and the second loop iterates over the values 1 through 13, which is all the cards in a deck.

Every deck of cards needs to be shuffled before it is used, so we've defined a shuffle method. Python's random module has a very handy shuffle function built in that we can use. shuffle takes a list and rearranges the contents of that list in place (i.e. it doesn't return a new list).

Then we have the deal method, which removes the last card from the list and returns it. We'll use this to deal the cards to the players.

We have a length method that we'll use to determine if there are any cards left in the deck. This will be useful when dealing out cards to know when to stop.

The Deck class is now complete.

Let's move to the Pile class. This class models the pile of cards face up on the table, that each player adds to when playing.

class Pile:
cards = None

def __init__(self):
self.cards = []

def add(self, card):
self.cards.append(card)

def peek(self):
if (len(self.cards) > 0):
return self.cards[-1]
else:
return None

def popAll(self):
return self.cards

def clear(self):
self.cards = []

def isSnap(self):
if (len(self.cards) > 1):
return (self.cards[-1].value == self.cards[-2].value)
return False

The Pile class has one main property: a list of cards, which is initialized in the constructor.

The add method is used when a player plays a card, i.e. adds it to the pile.

The peek method returns the top card of the pile, while still keeping it on the pile. We'll use this to draw the top card, as it is face up. If there are no cards on the pile, indicated by the length of the card array being 0, we'll return None.

When a player wins, they get all the cards on the pile. The popAll method handles this by returning the list of cards. Then we can call the clear method to remove all the cards from the pile.

If a player calls "Snap!", we need to check if the top two cards are the same value. We'll use the isSnap method to check this. Python has a handy feature of negative indices. This means an index of -1 returns the last element in the list, -2 returns the second last, and so on. This allows us to easily get the last 2 cards added to the pile, and check if they are the same value. Note that in the rules of Snap, only the value is important - the suit is not used.

The last of the models is the Player class.

class Player:
hand = None
flipKey = None
snapKey = None
name = None

def __init__(self, name, flipKey, snapKey):
self.hand = []
self.flipKey = flipKey
self.snapKey = snapKey
self.name = name

def draw(self, deck):
self.hand.append(deck.deal())

def play(self):
return self.hand.pop(0)

The Player class has a few properties. The hand property is a list of cards that the player has.

The flipKey and snapKey properties are the keys assigned to player that they use to flip and snap cards.

The name property is the name of the player.

The draw method is used to draw a card from the deck passed in the arguments. The draw method calls the deal method on the deck, and adds the card to the hand list.

The play method pops a card off the hand list, used when the player plays a card.

Building the game engine

Ok, time to build the game engine that will coordinate the interactions between the models we created.

Make a new file called engine.py. Let's start by adding the libraries we'll need.

from enum import Enum
import pygame
from models import *

Besides the Enum and pygame libraries, we import everything, *, from our models.py file. This way we can use all the classes that we defined in models.

A common element in game engines is keeping track of the state of the game. We'll define a GameState enumeration to keep track of the state of the game.

class GameState(Enum):
PLAYING = 0
SNAPPING = 1
ENDED = 2

We only have 3 states, or phases, of the game that we will track. The first is PLAYING, which is the main phase of the game, where the players take turns putting down cards. The second is SNAPPING. This is the state the game is in when a player calls "Snap!". In this state, we check if the snap is valid, and also wait until the players are ready to resume playing. The third is ENDED, which is the phase where the game is over, i.e. one player has no more cards to play.

Now, let's start with the engine itself. First, let's add the definition, properties and constructor:

class SnapEngine:
deck = None
player1 = None
player2 = None
pile = None
state = None
currentPlayer = None
result = None

def __init__(self):
self.deck = Deck()
self.deck.shuffle()
self.player1 = Player("Player 1", pygame.K_q, pygame.K_w)
self.player2 = Player("Player 2", pygame.K_o,pygame.K_p)
self.pile = Pile()
self.deal()
self.currentPlayer = self.player1
self.state = GameState.PLAYING

In the properties, we define the deck of cards, players, pile of cards, and the state of the game. We also have a currentPlayer property, which keeps track of the player whose turn it is. The result property is used to communicate the outcome of a round, or the entire game.

The constructor initializes objects and stores them in the properties. Note that we assign default names and keys to the players.

We also call a method we have yet to define, deal, to deal the cards to the players.

We start off the game by setting the state to PLAYING, and the currentPlayer to player1.

Let's add some more methods and logic to the engine. To start, the deal method:

  def deal(self):
half = self.deck.length() // 2
for i in range(0, half):
self.player1.draw(self.deck)
self.player2.draw(self.deck)

The deal method is in charge of making sure each player gets half the deck of cards. There are a few ways to do this - this code takes a bit of a literal translation of dealing, by dealing one card to each player alternately. In practice, we'll use a for loop to do this, and in the loop we'll call the draw method on each of the players to draw a card.

Since each round of the for loop takes a card for each player, 2 cards per loop, we only need to loop for half the number of cards in the deck.

To find the middle of the deck, we get the length of the deck, and divide it by 2. Note that we use the // integer division operator to get back an integer after division, as the meaning of the middle is the index of the middle card. A number with decimals wouldn't make any sense here.

Next, we can add a helper method to switch the current player. We'll use this after each player plays a card to indicate that it's the next player's turn.

  def switchPlayer(self):
if self.currentPlayer == self.player1:
self.currentPlayer = self.player2
else:
self.currentPlayer = self.player1

Here we check which player is the current player and switch currentPlayer to the other player.

The last helper method we need on the engine is one that handles a player winning a round (by calling "Snap!" correctly, or the other player falsely calling "Snap!"). This method will change the state of the game. It will also add all the cards on the pile to the winner's hand. Then it will clear out the pile so the next round can start:

  def winRound(self, player):
self.state = GameState.SNAPPING
player.hand.extend(self.pile.popAll())
self.pile.clear()

Now we get to the main logic of the engine. This method will be called from our main game loop, which we will define later. Let's start with the method definition and some basic checks. Then we'll add the logic in sections. Start by adding this method to the engine:

  def play(self, key):
if key == None:
return

if self.state == GameState.ENDED:
return

We'll call this main logic method play. It takes whatever key is currently pressed, and processes the logic for that. The first thing we check is if a key has actually been pressed. If it hasn't, we return, as there is nothing to update with the game state.

The next check to make is if the game is over. If it is, we return, as again, there is nothing to do. If you want to improve the game, you could listen for a key press to restart the game.

Now let's add some of the logic. The first thing is to check if the current player has pressed the key to play, or flip a card onto the pile. If they have pressed their flipKey, we call their play method and add the returned card to the pile. Then we switch turn to the next player, by calling our switchPlayer method.

    if key == self.currentPlayer.flipKey:
self.pile.add(self.currentPlayer.play())
self.switchPlayer()

Next, let's check if any of the players have called "Snap!". We'll need to keep track of a few things: who called "Snap!", who didn't call "Snap!", and if there is a valid snap condition on the pile. Add this logic to the play method:

    snapCaller = None
nonSnapCaller = None
isSnap = self.pile.isSnap()

if (key == self.player1.snapKey):
snapCaller = self.player1
nonSnapCaller = self.player2
elif (key == self.player2.snapKey):
snapCaller = self.player2
nonSnapCaller = self.player1

Here we create two variables, snapCaller and nonSnapCaller, which keep track of the player who called "Snap!" and the player who didn't call "Snap!". We also create a variable isSnap to keep track of whether there is a valid snap condition on the pile. Then we check if either of the players has called "Snap!". If they have, then we set the snapCaller and nonSnapCaller variables as applicable.

We now know if "Snap!" has been called and which player called it. Let's add the logic to see if the player that called "Snap!" wins or loses. Add this logic to the play method:

    if isSnap and snapCaller:
self.winRound(snapCaller)
self.result = {
"winner": snapCaller,
"isSnap": True,
"snapCaller": snapCaller
}
self.winRound(snapCaller)
elif not isSnap and snapCaller:
self.result = {
"winner": nonSnapCaller,
"isSnap": False,
"snapCaller": snapCaller
}
self.winRound(nonSnapCaller)

We have two cases: one for a valid snap, and one for an invalid snap.

If the pile is a valid snap, we call the winRound method on the player who called "Snap!". Then we set the result property to a dictionary with the winner, whether it was a valid snap, and the player who called "Snap!". This will be used for information when we make the game user interface (UI).

Likewise, for an invalid snap, we call the winRound method on the player who didn't call "Snap!". Then we set the result property to a dictionary with the winner as the nonSnapCaller.

In both cases, we call the winRound method with whichever player won the cards. Recall in the winRound method we assign the pile to the player's hand. Then we clear the pile, and set the gameState to SNAPPING.

We've got just one last thing to check: if any player has run out of cards. If they have, then it means the other player wins. Add this logic to the play method:

    if len(self.player1.hand) == 0:
self.result = {
"winner": self.player2,
}
self.state = GameState.ENDED
elif len(self.player2.hand) == 0:
self.result = {
"winner": self.player1,
}
self.state = GameState.ENDED

If one of the players has run out of cards, then we set the result property to a dictionary with the winner as the other player. Then we set the state property to GameState.ENDED.

Setting up the game loop

We've built the model and the logic in the engine to play a game of Snap. Now we need to define the game loop to run the game.

Open up the main.py file again. We'll start by adding references to the models and engine, so that we can access them when building the UI. Add the following imports right under the import pygame line:

from models import *
from engine import *

We'll start by creating a new game engine object. Add this to the main.py file, underneath the Pygame initialization code:

gameEngine = SnapEngine()

Now the game loop. The game loop's job is to listen for user input, call the engine's play method to process that input, and update the UI with the result. Add this to the main.py file:

run = True
while run:
key = None;
for event in pygame.event.get():
if event.type == pygame.QUIT:
run = False
if event.type == pygame.KEYDOWN:
key = event.key

gameEngine.play(key)
renderGame(window)
pygame.display.update()

if gameEngine.state == GameState.SNAPPING:
pygame.time.delay(3000)
gameEngine.state = GameState.PLAYING

We start off by defining a variable run. Then we use this as a condition for a while loop. As long as run is true, the loop will continuously run the code inside.

In Pygame, we listen for events on the event queue. We use the pygame.event.get() method to get all the events that have happened since the last time we checked. We then iterate through the events, checking if any of them is a QUIT event. If they are, we set run to false, and break out of the loop to end the program. A QUIT event is sent if the user clicks the close button on the window.

If any of the events is a KEYDOWN event, we set the key variable to the key that was pressed.

Then we call out to the game engine to process the key that was pressed.

Once that is done, we can update the UI. We call the renderGame method, which we'll get to next.

After the UI has been updated, we call the pygame.display.update() method to draw it to the screen.

The final check is to see if the game is in the SNAPPING state. If it is, we wait 3 seconds before switching back to the PLAYING state. This is so that there is enough time for the players to pause and see what happened. Remember the game loop will go around very quickly, so the delay will help them to see any messages we display in the renderGame method.

Rendering the game

The last main task is to render the game through a UI. This is where we'll use Pygame to draw the game to the screen.

In the game loop, we call the renderGame method. This method takes a window parameter, which is the Pygame window we created in the main.py file. The window is the graphics surface that we will draw to. The renderGame method will look at the state and result of the gameEngine, as well as the players, and draw the appropriate UI to the window.

Let's implement that method now. We'll start by clearing the window, and drawing some fixed UI elements. Add the following code to the main.py file, above the game loop.

cardBack = pygame.image.load('images/BACK.png')
cardBack = pygame.transform.scale(cardBack, (int(238*0.8), int(332*0.8)))

def renderGame(window):
window.fill((15,0,169))
font = pygame.font.SysFont('comicsans',60, True)

window.blit(cardBack, (100, 200))
window.blit(cardBack, (700, 200))

text = font.render(str(len(gameEngine.player1.hand)) + " cards", True, (255,255,255))
window.blit(text, (100, 500))

text = font.render(str(len(gameEngine.player2.hand)) + " cards", True, (255,255,255))
window.blit(text, (700, 500))

topCard = gameEngine.pile.peek()
if (topCard != None):
window.blit(topCard.image, (400, 200))

Firstly we load an image of the back of a card outside the function. We do this to avoid loading it each time the screen is rendered - this way it is only loaded once. We will use this image to represent the cards in the players' hands, which are face down. pygame's transform.scale function is used to scale the image to 0.8 the size of the regular card size. This is just to indicate visually that they are in the background to the main part of the UI, which will be the card at top of the pile.

Inside the renderGame function, we first clear the window. We use the fill method to fill the window with a color, (15,0,169) in RGB (Red Green Blue) color notation, which is a dark blue color.

Then we choose a font to use. We use the SysFont method to load a font from the system. We pass in the font name, the font size, and a boolean value indicating whether the font should be bold.

Next is to draw the card backs representing the players' hands. We use the blit method to draw the card back to the window. We pass in the card back image we loaded outside the function, and the screen coordinates we want to draw it at. We draw two card backs, one for each player, one on the far left of the screen, and the other on the far right. We'll draw the pile between these two hands.

To indicate the current score of the game, we render text showing the number of cards each player currently holds. First, we construct a text object using the font's render method. We pass in the text we want to render (which is the player's card count and the word 'cards'), and the color we want to use. We then draw the text to the screen by blitting it to the window.

The last part of the code draws the card at the top of the pile. We use the peek method we implemented on the Pile class to get the top card. If there is no top card, we don't draw anything. Recall that for each card, we loaded its image in the constructor. This means we can get the image object from the image property on the card, without loading it here. Once again we blit the card image to the screen, this time passing in coordinates between the two hands.

The next part of the UI to draw is some indication of the current state of the game, and whose turn it is. We'll use the state property of the gameEngine to determine what to draw. We'll use if statements to draw the appropriate UI, based on the game state. There are three states to consider:

  • PLAYING The game is in progress, and we are waiting for the current player to flip a card, or either player to call "Snap!". In this case, we render the current player's name and a message to indicate that it is their turn to flip. This message is written in white (255,255,255) near the top left of the window.
  • SNAPPING A player has called snap. In this case, the message rendered depends on if the snap was valid or not. We can get this information from the result property of the gameEngine.
  • ENDING The game is over. In this case, we render the winner's name, and a message to indicate that they won.

Add this code to the renderGame function, to implement the above logic:

  if gameEngine.state == GameState.PLAYING:
text = font.render(gameEngine.currentPlayer.name + " to flip", True, (255,255,255))
window.blit(text, (20,50))

if gameEngine.state == GameState.SNAPPING:
result = gameEngine.result
if result["isSnap"] == True:
message = "Winning Snap! by " + result["winner"].name
else:
message = "False Snap! by " + result["snapCaller"].name + ". " + result["winner"].name + " wins!"
text = font.render(message, True, (255,255,255))
window.blit(text, (20,50))

if gameEngine.state == GameState.ENDED:
result = gameEngine.result
message = "Game Over! " + result["winner"].name + " wins!"
text = font.render(message, True, (255,255,255))
window.blit(text, (20,50))

Running the game

Now that we have implemented the game, we can give it a test run. Click the big green "Run" button at the top of your repl. You should see the game start up, prompting "Player 1" to flip a card.

Playing SnaPy

Player 1's keys are q to flip a card, and w to call snap. Player 2's keys are o to flip a card, and p to call snap.

Call over a friend and see who's the quickest finger on calling "Snap!".

Next steps

Congratulations! You've implemented a card game. You can use the ideas and principles from this tutorial to implement a card game of your own.

Some ideas to add features to this game are:

  • Add a timer to the game, so that the player has a limited amount of time to flip a card.
  • Add some sound effects, using the Pygame mixer module.
  • Add a key to restart the game after it ends. Currently, at the end of the game, the players would need to close the window and start the game to play again.
  • Try with different cards. For example, you could use a simplified card set, perhaps themed on something you love, like Pokemons, dinosaurs, cars, celebrities, etc.

You can find our repl below:

Was this helpful?