← Build a Match-3 Game using Python and Kivy

Lesson 8 of 8

Final Project: Complete the Match-3 Prototype

Every piece from this course now exists on its own: a board of random gems, a grid of tappable buttons, adjacent swapping, match detection, and clearing/dropping/refilling with cascades. This final project assembles all of it into one complete, playable prototype.

Requirements

Your finished match3.py should satisfy every one of these:

  1. Creates an 8Γ—8 board of randomly colored gems (make_board()).
  2. Displays the gems on screen as colored buttons in a GridLayout (render_board()).
  3. Lets the player select two gems by tapping them (self.selected, on_gem_tapped).
  4. Swaps two adjacent gems, and only two adjacent gems, non-adjacent taps should start a new selection instead of swapping (adjacent(), swap()).
  5. Detects 3-in-a-row matches, both horizontal and vertical (find_matches()).
  6. Replaces matched gems: clears them, drops the gems above down, spawns new random gems at the top, and repeats until no matches remain (resolve_matches()).

Bring the pieces from every earlier lesson together into one file, in this order works well:

import random
from kivy.app import App
from kivy.uix.gridlayout import GridLayout
from kivy.uix.button import Button

BOARD_SIZE = 8
GEM_COLORS = ['red', 'blue', 'green', 'yellow', 'purple']
GEM_RGBA = {
    'red':    (0.9, 0.2, 0.2, 1),
    'blue':   (0.2, 0.4, 0.9, 1),
    'green':  (0.2, 0.8, 0.3, 1),
    'yellow': (0.95, 0.85, 0.2, 1),
    'purple': (0.6, 0.2, 0.8, 1),
}


def make_board():
    return [[random.choice(GEM_COLORS) for _ in range(BOARD_SIZE)] for _ in range(BOARD_SIZE)]


def adjacent(pos1, pos2):
    r1, c1 = pos1
    r2, c2 = pos2
    return abs(r1 - r2) + abs(c1 - c2) == 1


def find_matches(board):
    matched = set()

    for row in range(BOARD_SIZE):
        run_start = 0
        for col in range(1, BOARD_SIZE + 1):
            broke_run = col == BOARD_SIZE or board[row][col] != board[row][run_start]
            if broke_run:
                if col - run_start >= 3:
                    for c in range(run_start, col):
                        matched.add((row, c))
                run_start = col

    for col in range(BOARD_SIZE):
        run_start = 0
        for row in range(1, BOARD_SIZE + 1):
            broke_run = row == BOARD_SIZE or board[row][col] != board[run_start][col]
            if broke_run:
                if row - run_start >= 3:
                    for r in range(run_start, row):
                        matched.add((r, col))
                run_start = row

    return matched


def clear_matches(board, matched):
    for (row, col) in matched:
        board[row][col] = None


def drop_gems(board):
    for col in range(BOARD_SIZE):
        remaining = [board[row][col] for row in range(BOARD_SIZE) if board[row][col] is not None]
        missing = BOARD_SIZE - len(remaining)
        new_column = [None] * missing + remaining
        for row in range(BOARD_SIZE):
            board[row][col] = new_column[row]


def refill_board(board):
    for row in range(BOARD_SIZE):
        for col in range(BOARD_SIZE):
            if board[row][col] is None:
                board[row][col] = random.choice(GEM_COLORS)


class Match3App(App):
    def build(self):
        self.board = make_board()
        self.selected = None
        self.grid = GridLayout(cols=BOARD_SIZE)
        self.buttons = {}

        for row in range(BOARD_SIZE):
            for col in range(BOARD_SIZE):
                gem = Button(text='', background_normal='')
                gem.bind(on_press=self.make_gem_handler(row, col))
                self.buttons[(row, col)] = gem
                self.grid.add_widget(gem)

        self.render_board()
        return self.grid

    def make_gem_handler(self, row, col):
        def handler(instance):
            self.on_gem_tapped(row, col)
        return handler

    def on_gem_tapped(self, row, col):
        if self.selected is None:
            self.selected = (row, col)
            return

        first = self.selected
        second = (row, col)
        self.selected = None

        if first == second:
            return
        if not adjacent(first, second):
            self.selected = second
            return

        self.swap(first, second)
        if find_matches(self.board):
            self.resolve_matches()
        else:
            self.swap(first, second)

        self.render_board()

    def swap(self, pos1, pos2):
        r1, c1 = pos1
        r2, c2 = pos2
        self.board[r1][c1], self.board[r2][c2] = self.board[r2][c2], self.board[r1][c1]

    def resolve_matches(self):
        matched = find_matches(self.board)
        while matched:
            clear_matches(self.board, matched)
            drop_gems(self.board)
            refill_board(self.board)
            matched = find_matches(self.board)

    def render_board(self):
        for row in range(BOARD_SIZE):
            for col in range(BOARD_SIZE):
                color = self.board[row][col]
                self.buttons[(row, col)].background_color = GEM_RGBA[color]


if __name__ == '__main__':
    Match3App().run()

Run it, click two adjacent gems, and you should see them swap, and if they formed a match, watch the board clear, drop, and refill down to a stable state.

Stretch goals (Hard): production-quality polish

The prototype above is a fully playable Match-3 game, but a production-quality version would add considerably more. Pick as many of these as you like:

  • Animated swapping, instead of an instant color-swap, gradually move the two gems toward each other's positions over a few frames using kivy.animation.Animation.
  • Gems falling with physics, drop new gems in from above the board and animate them falling into place, rather than appearing instantly.
  • Cascading combos with a score system, award more points for each successive cascade in a single resolve_matches() call, not just a flat amount per gem.
  • Level objectives, e.g. "collect 20 blue gems" or "reach 5,000 points in 30 moves", tracked and displayed alongside the board.
  • Special gems, a match of 4 creates a bomb (clears a 3Γ—3 area when matched), a match of 5 creates a rainbow gem (clears every gem of one color).
  • Particle effects, a small burst of color where each gem is cleared.
  • Sound effects, a swap sound and a satisfying match/cascade sound with kivy.core.audio.SoundLoader.
  • Swipe gestures, detect a swipe direction with on_touch_down/on_touch_up instead of tap-tap-to-select, closer to how mobile Match-3 games actually feel.

For a real mobile release, using actual gem images (kivy.uix.image.Image) or Canvas drawing instead of flat button colors would make it feel far more like a real game. A reasonable project layout for that larger scope:

Match3 Game
β”‚
β”œβ”€β”€ main.py              # Starts app
β”œβ”€β”€ game.py              # Match-3 rules
β”œβ”€β”€ board.py             # Grid management
β”œβ”€β”€ gem.py               # Gem objects
β”œβ”€β”€ assets/
β”‚   β”œβ”€β”€ red.png
β”‚   β”œβ”€β”€ blue.png
β”‚   └── yellow.png
└── sounds/
    β”œβ”€β”€ swap.wav
    └── match.wav

Submit a link to your finished project (a repo or gist) below, an instructor will review it before you can mark this lesson complete.

This lesson ends in a project. Build it on your own machine, there's nowhere to submit it here, but the brief above is everything you need.