Game Development 12 min read

Build a Simple Match‑3 Game with Python and Pygame – Step‑by‑Step Tutorial

This tutorial walks you through creating a basic match‑3 puzzle game similar to Candy Crush using Python and Pygame, covering setup, grid rendering, random gem placement, scoring, timing, match detection, gem swapping, and game‑over logic with complete code snippets.

Python Programming Learning Circle
Python Programming Learning Circle
Python Programming Learning Circle
Build a Simple Match‑3 Game with Python and Pygame – Step‑by‑Step Tutorial

Build a Simple Match‑3 Game with Python and Pygame

The popular mobile puzzle game "Happy Xoxoxo" (开心消消乐) is used as a reference to demonstrate how to build a simple match‑3 game from scratch using Python and the Pygame library.

Implementation

The game consists of three main parts: the game board, a score counter, and a timer. Below are the essential steps and code snippets.

<code>import os
import sys
import time
import pygame
import random</code>

Define basic constants such as window size, grid dimensions, margins, and frame rate.

<code>WIDTH = 400
HEIGHT = 400
NUMGRID = 8
GRIDSIZE = 36
XMARGIN = (WIDTH - GRIDSIZE * NUMGRID) // 2
YMARGIN = (HEIGHT - GRIDSIZE * NUMGRID) // 2
ROOTDIR = os.getcwd()
FPS = 30</code>

Create the main window.

<code>pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption('消消乐')</code>

Draw an 8 × 8 grid on the window.

<code>def drawGrids(self):
    for x in range(NUMGRID):
        for y in range(NUMGRID):
            rect = pygame.Rect((XMARGIN + x * GRIDSIZE, YMARGIN + y * GRIDSIZE, GRIDSIZE, GRIDSIZE))
            self.drawBlock(rect, color=(255, 165, 0), size=1)

def drawBlock(self, block, color=(255, 0, 0), size=2):
    pygame.draw.rect(self.screen, color, block, size)</code>

Randomly place puzzle pieces (gems) on the grid.

<code>while True:
    self.all_gems = []
    self.gems_group = pygame.sprite.Group()
    for x in range(NUMGRID):
        self.all_gems.append([])
        for y in range(NUMGRID):
            gem = Puzzle(
                img_path=random.choice(self.gem_imgs),
                size=(GRIDSIZE, GRIDSIZE),
                position=[XMARGIN + x * GRIDSIZE, YMARGIN + y * GRIDSIZE - NUMGRID * GRIDSIZE],
                downlen=NUMGRID * GRIDSIZE
            )
            self.all_gems[x].append(gem)
            self.gems_group.add(gem)
    if self.isMatch()[0] == 0:
        break</code>

Display the score, incremental score, and remaining time.

<code>def drawScore(self):
    score_render = self.font.render('分数:' + str(self.score), 1, (85, 65, 0))
    rect = score_render.get_rect()
    rect.left, rect.top = (55, 15)
    self.screen.blit(score_render, rect)

def drawAddScore(self, add_score):
    score_render = self.font.render('+' + str(add_score), 1, (255, 100, 100))
    rect = score_render.get_rect()
    rect.left, rect.top = (250, 250)
    self.screen.blit(score_render, rect)

def showRemainingTime(self):
    remaining_time_render = self.font.render('倒计时: %ss' % str(self.remaining_time), 1, (85, 65, 0))
    rect = remaining_time_render.get_rect()
    rect.left, rect.top = (WIDTH - 190, 15)
    self.screen.blit(remaining_time_render, rect)</code>

When the timer expires, show a game‑over screen and allow restarting.

<code>while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            sys.exit()
        if event.type == pygame.KEYUP and event.key == pygame.K_r:
            flag = True
    if flag:
        break
    screen.fill((255, 255, 220))
    text0 = '最终得分: %s' % score
    text1 = '按 R 键重新开始'
    y = 140
    for idx, text in enumerate([text0, text1]):
        text_render = font.render(text, 1, (85, 65, 0))
        rect = text_render.get_rect()
        rect.left, rect.top = (100, y)
        y += 60
        screen.blit(text_render, rect)
    pygame.display.update()</code>

Detect which gem is selected by the mouse.

<code>def checkSelected(self, position):
    for x in range(NUMGRID):
        for y in range(NUMGRID):
            if self.getGemByPos(x, y).rect.collidepoint(*position):
                return [x, y]
    return None</code>

Swap two adjacent gems.

<code>def swapGem(self, gem1_pos, gem2_pos):
    margin = gem1_pos[0] - gem2_pos[0] + gem1_pos[1] - gem2_pos[1]
    if abs(margin) != 1:
        return False
    gem1 = self.getGemByPos(*gem1_pos)
    gem2 = self.getGemByPos(*gem2_pos)
    # set direction flags based on relative positions
    if gem1_pos[0] - gem2_pos[0] == 1:
        gem1.direction, gem2.direction = 'left', 'right'
    elif gem1_pos[0] - gem2_pos[0] == -1:
        gem2.direction, gem1.direction = 'left', 'right'
    elif gem1_pos[1] - gem2_pos[1] == 1:
        gem1.direction, gem2.direction = 'up', 'down'
    elif gem1_pos[1] - gem2_pos[1] == -1:
        gem2.direction, gem1.direction = 'up', 'down'
    gem1.target_x, gem1.target_y = gem2.rect.left, gem2.rect.top
    gem2.target_x, gem2.target_y = gem1.rect.left, gem1.rect.top
    gem1.fixed = gem2.fixed = False
    self.all_gems[gem2_pos[0]][gem2_pos[1]] = gem1
    self.all_gems[gem1_pos[0]][gem1_pos[1]] = gem2
    return True</code>

Check for matches of three or more identical gems.

<code>def isMatch(self):
    for x in range(NUMGRID):
        for y in range(NUMGRID):
            if x + 2 < NUMGRID:
                if self.getGemByPos(x, y).type == self.getGemByPos(x+1, y).type == self.getGemByPos(x+2, y).type:
                    return [1, x, y]
            if y + 2 < NUMGRID:
                if self.getGemByPos(x, y).type == self.getGemByPos(x, y+1).type == self.getGemByPos(x, y+2).type:
                    return [2, x, y]
    return [0, x, y]
</code>

Remove matched gems and generate new ones.

<code>def removeMatched(self, res_match):
    if res_match[0] > 0:
        self.generateNewGems(res_match)
        self.score += self.reward
        return self.reward
    return 0

def generateNewGems(self, res_match):
    # Simplified logic: clear matched gems, shift down existing gems, and fill empty cells with new random gems.
    if res_match[0] == 1:  # horizontal match
        start = res_match[2]
        while start > -2:
            for col in [res_match[1], res_match[1]+1, res_match[1]+2]:
                gem = self.getGemByPos(col, start)
                if start == res_match[2]:
                    self.gems_group.remove(gem)
                    self.all_gems[col][start] = None
                elif start >= 0:
                    gem.target_y += GRIDSIZE
                    gem.fixed = False
                    gem.direction = 'down'
                    self.all_gems[col][start+1] = gem
                else:
                    new_gem = Puzzle(img_path=random.choice(self.gem_imgs), size=(GRIDSIZE, GRIDSIZE), position=[XMARGIN+col*GRIDSIZE, YMARGIN-GRIDSIZE], downlen=GRIDSIZE)
                    self.gems_group.add(new_gem)
                    self.all_gems[col][start+1] = new_gem
            start -= 1
    elif res_match[0] == 2:  # vertical match
        start = res_match[2]
        while start > -4:
            if start == res_match[2]:
                for offset in range(3):
                    gem = self.getGemByPos(res_match[1], start+offset)
                    self.gems_group.remove(gem)
                    self.all_gems[res_match[1]][start+offset] = None
            elif start >= 0:
                gem = self.getGemByPos(res_match[1], start)
                gem.target_y += GRIDSIZE * 3
                gem.fixed = False
                gem.direction = 'down'
                self.all_gems[res_match[1]][start+3] = gem
            else:
                new_gem = Puzzle(img_path=random.choice(self.gem_imgs), size=(GRIDSIZE, GRIDSIZE), position=[XMARGIN+res_match[1]*GRIDSIZE, YMARGIN+start*GRIDSIZE], downlen=GRIDSIZE*3)
                self.gems_group.add(new_gem)
                self.all_gems[res_match[1]][start+3] = new_gem
            start -= 1</code>

The game loop repeatedly executes these steps until the timer runs out, at which point the final score is displayed.

Below are screenshots of the game at various stages.

Initial grid
Initial grid
Grid with random gems
Grid with random gems
Score and timer displayed
Score and timer displayed
Match detection
Match detection
Game over screen
Game over screen
Animated gameplay
Animated gameplay
PythonGame DevelopmentTutorialPygameMatch-3
Python Programming Learning Circle
Written by

Python Programming Learning Circle

A global community of Chinese Python developers offering technical articles, columns, original video tutorials, and problem sets. Topics include web full‑stack development, web scraping, data analysis, natural language processing, image processing, machine learning, automated testing, DevOps automation, and big data.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.