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.
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.
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.