Build a Match-3 Game using Python and Kivy

Lesson 6 of 8

Detecting Matches

Swapping gems is only useful once the game can tell whether that swap actually created a match, three or more identical gems in a row, either horizontally or vertically.

Scanning rows

def find_matches(board):
    matched = set()

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

    return matched

This walks each row left to right, tracking where the current run of identical gems started (run_start). Every time the gem changes (or the row ends), it checks whether the run that just ended was long enough (>= 3), and if so, adds every position in that run to matched. Using a loop bound of BOARD_SIZE + 1 (not just BOARD_SIZE) is what lets the last run in the row get checked too, without it, a run reaching all the way to the last column would never trigger the "broke run" check.

Adding vertical runs

Columns need the exact same logic, just swapped: outer loop over columns, inner loop over rows.

def find_matches(board):
    matched = set()

    for row in range(BOARD_SIZE):
        run_start = 0
        for col in range(1, BOARD_SIZE + 1):
            end_of_row = col == BOARD_SIZE
            broke_run = end_of_row 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):
            end_of_col = row == BOARD_SIZE
            broke_run = end_of_col 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

matched is a set of (row, col) tuples, not a list, deliberately: a gem at the intersection of a horizontal and vertical run (an L or T shape) would otherwise get added twice, a set automatically collapses duplicates.

Using it after a swap

    def on_gem_tapped(self, row, col):
        # ... same selection logic as the previous lesson ...
        self.swap(first, second)

        if find_matches(self.board):
            self.render_board()
        else:
            self.swap(first, second)  # no match, swap back

Calling self.swap(first, second) a second time with the same two positions undoes the first swap, exactly the "swap back if it didn't help" rule real Match-3 games use to stop players from making pointless moves.

TIP

find_matches only reports which cells matched, it doesn't remove anything from the board itself, keeping "detect" and "remove" as separate steps (next lesson) makes each one easy to test on its own.

📝 Detecting Matches Quiz

Passing score: 70%
  1. 1.Why does find_matches return a set instead of a list?

  2. 2.Swapping the same two positions a second time undoes the first swap.

  3. 3.A run of identical gems only counts as a match once its length is 3 or ____.