[Rod Stephens Books]
Index Books Python Examples About Rod Contact
[Mastodon] [Bluesky]
[Build Your Own Ray Tracer With Python]

[Beginning Database Design Solutions, Second Edition]

[Beginning Software Engineering, Second Edition]

[Essential Algorithms, Second Edition]

[The Modern C# Challenge]

[WPF 3d, Three-Dimensional Graphics with WPF and C#]

[The C# Helper Top 100]

[Interview Puzzles Dissected]

Title: Make a minesweeper game with Python and tkinter

[A minesweeper game built with Python and tkinter]

Recently I saw a post about Minesweeper and realized with some surprise that I had never built a Minesweeper program! So here's a Python version.

WARNING: This game is highly addictive! Don't start playing unless you have free time to waste. (Unfortunately, there aren't any official Minesweeper support groups. You're on your own.)
This example has three main parts: the user interface setup code, the start_game method, and the Cell class.

User Interface

The game's user interface basically consists of a whole bunch of Label widgets. You click on them and they drive the game.

Here's the code that builds the main user interface, not counting those labels.

def build_ui(self): # Toolbar toolbar = tk.Frame(self.window, relief=tk.RIDGE, borderwidth=2) toolbar.pack(side=tk.TOP, fill=tk.X, padx=MARGIN, pady=(MARGIN, 0)) # Difficulty self.difficulty_combo = ttk.Combobox(toolbar, width=12, values=['Beginner', 'Intermediate', 'Advanced']) self.difficulty_combo.set('Beginner') self.difficulty_combo.bind("<>", self.difficulty_selected) self.difficulty_combo.pack(side=tk.LEFT, padx=(5,0), pady=5) # Win/loss label. self.win_loss_label = tk.Label(toolbar, fg='red') self.win_loss_label.pack(side=tk.LEFT, expand=True, fill=tk.X) # Number of mines remaining. self.num_mines_var = tk.IntVar(value=0) label = tk.Label(toolbar, width=4, borderwidth=1, anchor='e', relief='sunken', textvariable=self.num_mines_var) label.pack(side=tk.RIGHT, padx=(0,5)) # Board area. self.board_frame = tk.Frame(self.window, background='pink') self.board_frame.pack(side=tk.TOP, padx=MARGIN, pady=(MARGIN, 0), fill=tk.BOTH, expand=True) # Key bindings for new games. self.window.bind('', lambda event: self.start_game('')) self.window.bind('', lambda event: self.start_game('Beginner')) self.window.bind('', lambda event: self.start_game('Intermediate')) self.window.bind('', lambda event: self.start_game('Advanced')) self.start_game('')

The main app class's constructor creates the window self.window. This code creates a Frame to act as a toolbar and then adds a difficulty Combobox, a win/loss Label, and a Label to display the remaining number of mines.

Next, the program creates a Frame to hold the playing area. Finally, the code binds F5 to start a new game at the current difficulty, and Ctrl+B, Ctrl+I, and Ctrl+A to start a new beginner, intermediate, and advanced game respectively.

Finally, the code calls the start_game method described next to launch a game.

start_game

The following start_game method starts a new game.

def start_game(self, level): '''Start a new game.''' if len(level) == 0: # Get the level from the difficulty combo. level = self.difficulty_combo.get() else: # Make the difficulty combo show the correct level. self.difficulty_combo.set(level) if level == 'Beginner': self.num_rows = 8 self.num_cols = 8 self.num_mines = 10 elif level == 'Intermediate': self.num_rows = 16 self.num_cols = 16 self.num_mines = 40 else: # Advanced. self.num_rows = 16 self.num_cols = 30 self.num_mines = 99 self.num_remaining = self.num_rows * self.num_cols - self.num_mines # Make the empty board. self.board = [[Cell(self, row, col) for col in range(self.num_cols)] for row in range(self.num_rows)] # Place the mines. num_placed = 0 while num_placed < self.num_mines: row = random.randrange(self.num_rows) col = random.randrange(self.num_cols) if not self.board[row][col].is_mine: self.board[row][col].is_mine = True num_placed += 1 # Calculate the adjacency counts. for row in self.board: for cell in row: cell.count_adjacent(self.board, len(self.board), len(self.board[0])) # Removed any previous labels. self.board_frame.pack_forget() for child in self.board_frame.winfo_children(): child.destroy() self.board_frame.pack(side=tk.TOP, padx=MARGIN, pady=MARGIN, fill=tk.BOTH, expand=True) # Make the tkinter widgets. font = ('Arial', 12, 'bold') for row in self.board: row_frame = tk.Frame(self.board_frame) row_frame.pack(side=tk.TOP) for cell in row: cell.label = tk.Label(row_frame, text='', borderwidth=2, relief='raised', width=2, fg='black', font=font) cell.label.pack(side=tk.LEFT, padx=1, pady=1) cell.label.bind('', lambda event, x=cell: self.clicked(x)) cell.label.bind('', lambda event, x=cell: self.right_clicked(x)) # Display the nubmer of mines. self.num_mines_var.set(self.num_mines) # Clear the win/loss label. self.win_loss_label.config(text='', bg=self.window['bg']) # Size the window to fit. self.window.update() wid = self.window.winfo_reqwidth() hgt = self.window.winfo_reqheight() self.window.geometry(f"{wid}x{hgt}")

The level parameter should be a blank string or one of the strings 'Beginner,' 'Intermediate,' or 'Advanced.' If level is blank, the code gets the selected value from the difficulty Combobox. If level is not blank, the code makes the difficulty Combobox display the difficulty.

Next, the code checks the difficulty level and sets the program's num_rows, num_cols, and num_mines values accordingly. The values used here are typical for Minesweeper games and they seem to work pretty well, but you could change them if you like.

The program sets num_remaining equal to the number of non-mine cells. It then uses a list comprehension to create a two-dimensional array (a list of lists) holding cells representing the positions on the board. (We'll get to the Cell class in the next section.

Now the program randomly places the mines. To do that, it enters a loop where it picks a random location for a mine. If that location is not already occupied by another mine, the program puts a mine there by setting the cell's is_mine property to True. If there is already a mine in that position, the loop simply continues. The loop continues until all of the mines have been placed.

Now that the mines are placed, the program loops through the cells and calls their count_adjacent methods to count the number of mines adjacent to each cell. (You'll see method that when we talk about the Cell class.)

At this point, the board is ready and we just need to perform a few more tasks to get the game ready to go. First, it removes the labels from the board area's Frame widget. It then uses a loop to create new Label widgets for each of the cells. It binds the new labels' Button-1 (left mouse button) and Button-2 (right mouse button) events to the corresponding cell's event handlers.

This is a trickier part of the code. The lambda events use the syntax x=cell to bind the variable x to the current value of cell. They then pass that value to the event handler. If you simply pass cell to the event handler, then all of the calls are bound to the cell variable not to the Cell that it currently points to. As they cell variable's value changes, those values also change so the event handlers don't operate for the cells they should. It's all very confusing. (Give it a try and see if you dare!)

TIP: If you pass a mutable value into a lambda within a loop like this, you probably need to use syntax like x=cell to bind the variable when the lambda method is defined.

The program updates num_mines_var to display the number of mines and clears the win/loss label.

This method finishes by asking the main window what size it wants to be and then setting it equal to that size. (This is much easier than some other UI systems I've used where you need to calculate the window's size yourself.

Cell Class

Each Cell object represents one of the game's cells. The class isn't too long, but I'll describe in pieces anyway to make it easier to digest.

The following code shows the class's declaration and constructor.

class Cell: FLAG_CH = '🚩' MINE_CH = '💥' COLORS = ['red', 'blue', 'green', 'red', 'purple', 'orange', 'teal', 'brown', 'gray'] def __init__(self, game_app, row, col): self.game_app = game_app self.row = row self.col = col self.is_mine = False self.is_shown = False self.is_flagged = False self.num_adjacent = 0

The class defines the characters we'll use for flags and mines. It then defines the colors we'll use for the various numbers of adjacent mines.

We don't draw a number on a cell that has no adjacent mines, but we do draw the mines at the end of the game. To make that easier, we set their num_adjacent values to 0 and they use COLORS[0] (red) so they look like red explosions. (You'll see how that works when we discuss the Cell class.) (Feel free to change the colors if you like, but these work okay.)

The constructor saves a reference to the game app class and the cell's row and column numbers. It also initializes the cell's various flag variables.

The following code shows the Cell class's __str__ dunder method.

def __str__(self): return f'({self.row}, {self.col}): {self.num_adjacent}'

This method returns the cell's row, column, and number of adjacent mines. It's only used for debugging.

The following neighbors method returns a list of the cell's neighboring cells. This is handy for other parts of the code that need to loop through a cell's neighbors.

def neighbors(self): '''Return a list of our neighbors.''' neighbor_list = [] for r in range(self.row - 1, self.row + 2): for c in range(self.col - 1, self.col + 2): if r < 0 or r >= self.game_app.num_rows: continue if c < 0 or c >= self.game_app.num_cols: continue if r == self.row and c == self.col: continue if self.game_app.board[r][c].is_shown: continue # Add this neighbor. neighbor_list.append(self.game_app.board[r][c]) return neighbor_list

This code makes r and c range from one less than to one more than the cell's row and column. It uses a series of if tests to ensure that position (r, c) lies within the grid and, if the neighbor's address is valid, it adds the neighboring cell to the neighbor_list. After it has checked all if the possible neighbors, the method returns the list.
TIP: Many algorithms need to loop through a position's neighbors and this kind of neighbors method makes that a lot cleaner. (An alternative strategy is to make "sentinels" cells that represent the edges of the board, but that makes indexing more confusing.)
The following method counts the mines adjacent to a cell.

def count_adjacent(self, board, num_rows, num_cols): '''Count this cell's adjacent cells.''' self.num_adjacent = 0 # Don't bother if we are a mine. if self.is_mine: return # Count the neighboring mines. for neighbor in self.neighbors(): if neighbor.is_mine: self.num_adjacent += 1

This method first resets the cell's num_adjacent count to 0. Next, if the cell is a mine, the code simply returns, leaving num_adjacent at 0. Later, when we draw the mines, they use COLORS[0] so they are red.

Next, the code loops through the cell's neighbors as returned by its neighbors method and increments num_adjacent for each neighbor that is a mine.

The following method displays the cell's text. That happens when you left-click a cell to expose its adjacent mine count and when the game ends.

def show_text(self): '''Display this cell's text.''' self.is_shown = True if self.is_mine: text = Cell.MINE_CH elif self.num_adjacent == 0: text = '' else: text = f'{self.num_adjacent}' self.label.config(text=text, relief='flat', bg='lightgray', fg=Cell.COLORS[self.num_adjacent])

If the cell is a mine, the code sets text to the mine character. Else if the cell has no adjacent mines, the code sets text to a blank string. Otherwise it sets text to the number of adjacent mines.

Having set text appropriately, the code sets the cell's label text to text. It also makes the label use the color for the number of adjacent mines.

The following clicked event handler executes when the player left-clicks a cell.

def clicked(self): '''The player clicked this cell.''' if self.is_shown: # Do nothing if we are already shown. return # See if this is a mine. if self.is_mine: # The game is over. self.game_app.game_over() self.game_app.win_loss_label.config(text='You lost!', fg='red', bg='pink') else: # Display this cell's text. self.show_text() # See if the player has found all of the non-mines. self.game_app.num_remaining -= 1 if self.game_app.num_remaining == 0: self.game_app.game_over() self.game_app.win_loss_label.config(text='You won!', fg='blue', bg='yellow') self.game_app.num_mines_var.set(0) return # If this cell has 0 adjacent cells, # recursively display adjacent cells. if self.num_adjacent == 0: # Recursively click neighbors. for neighbor in self.neighbors(): neighbor.clicked()

If this cell's contents are already visible, the method returns.

Next, if this is a mine, the player has lost. The code calls the main app's game_over method, which simply loops through all cells and calls their show_text methods. It also makes the win/loss label display "You lost!"

Else, if this is not a mine, the code calls the cell's show_text method to make it show its number of adjacent mines.

The code subtracts 1 from num_remaining and checks to see if the player has now found all of the non-mines. In that case, the code calls game_over to display all of the mines, and sets the win/loss label's text to You won!

If that was not the last non-mine, the code checks whether this cell has no adjacent mines. In that case, it's safe to click on them so the code recursively calls the neighbors' clicked methods to expose them. If they also have no adjacent mines, this may expose large safe areas.

When the player right-clicks a cell to mark it with a flag (or to unmark it), the following code executes.

def right_clicked(self): '''Toggle whether the cell is flagged.''' if self.is_shown: # Do nothing if we are already shown. return # Toggle is_flagged. self.is_flagged = not self.is_flagged if self.is_flagged: self.label.config(text=Cell.FLAG_CH, fg='black') self.game_app.num_mines_var.set(self.game_app.num_mines_var.get() - 1) else: self.label.config(text='') self.game_app.num_mines_var.set(self.game_app.num_mines_var.get() + 1)

First, if the cell's text is already visible, the method just returns. Otherwise, the method toggles the cell's is_flag property.

Then if is_flag is True, the program makes it display the flag character. If is_flag is False, the program clears the cell's text. In either case, the cell retains its ridged relief so it's clear that the player has not left-clicked on the cell.

Conclusion

That's pretty much all there is to it. Download it to waste a lot of time, see the whole thing in one piece, and experiment with it. In particular, I can think of several improvements that you could make.

  • It's usually not worth really starting a game (particularly an advanced one) until you randomly left-click a few cells and hopefully expose a large safe area. You could add a tool that automatically exposes some number (perhaps 10?) or non-mine cells to get you started.
  • Sometimes you know that a cell's neighbors are safe. For example, it might be a 1 cell and you have already flagged its adjacent mine. You could provide a method to let the player expose all of its neighbors, perhaps by double-clicking. That might also be too complicatedm though.
  • You could play sounds for left-click, right-click, exploded mine, and winning.
  • You could keep score. I mostly care about whether I finish or not, but other people's versions give a score that counts the number of clicks, total time, or some combination of those.

Notes on Addictive Games

One thing that helps make a game addictive (in addition to having few bugs, a compelling story, and natural mechanics) is making the player think they almost won when, in fact, they lost. When you lose in Minesweeper, it's generally because you were careless (and you tell yourself that you'll be more careful next time) or you had no evidence to go on and you made a wrong guess. If only you had guessed the other cell! It seems like you were *almost* there and you'll surely get lucky next time!

In fact, I suspect that's less true than it seems. In an advanced game, you often need to make a couple of lucky guesses. The odds of guessing correctly are often 1/2 or 2/3. It doesn't take too many such guesses before the odds of you getting them all correct are pretty small. However, the guess that you made wrong is the only one you think about and that one you almost got correct.

The same reasoning also applies to popular gambling games where it often seems like you *almost* won and will surely do better next time!

© 2025 Rocky Mountain Computer Consulting, Inc. All rights reserved.