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:
- Creates an 8Γ8 board of randomly colored gems (
make_board()). - Displays the gems on screen as colored buttons in a
GridLayout(render_board()). - Lets the player select two gems by tapping them (
self.selected,on_gem_tapped). - Swaps two adjacent gems, and only two adjacent gems, non-adjacent taps should start a new selection instead of swapping (
adjacent(),swap()). - Detects 3-in-a-row matches, both horizontal and vertical (
find_matches()). - 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_upinstead 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.