Editor

Minesweeper in Python

Intermediate · Games · about 90 minutes to build · 150 lines

Open squares by typing their coordinates, flag the mines you find and clear the board without stepping on one.

The program

Press Run and answer its questions in the box that appears under the code.

minesweeper.py
"""Minesweeper in the console: open every safe square, avoid the mines."""
import random
import re
# Each level is (name, rows, columns, mines).
LEVELS = {"1": ("Small", 6, 6, 4), "2": ("Medium", 9, 9, 10),
"3": ("Large", 10, 12, 18)}
HIDDEN, FLAG, MINE, EMPTY = "#", "F", "*", "."
LETTERS = "ABCDEFGHIJKL"
MOVE = re.compile(r"(f\s+)?([a-l])\s*(\d+)") # "b3", or "f b3" for a flag
# --- The board ---
def new_game(rows, cols, mines):
"""Return a fresh game. The mines are placed after the first move."""
return {"rows": rows, "cols": cols, "mine_count": mines,
"mines": set(), "open": set(), "flags": set()}
def neighbours(game, row, col):
"""Return the squares around (row, col) that are on the board."""
return [(r, c)
for r in range(max(row - 1, 0), min(row + 2, game["rows"]))
for c in range(max(col - 1, 0), min(col + 2, game["cols"]))
if (r, c) != (row, col)]
def place_mines(game, row, col):
"""Scatter the mines, keeping the first square and its neighbours clear."""
safe = set(neighbours(game, row, col)) | {(row, col)}
squares = [(r, c) for r in range(game["rows"])
for c in range(game["cols"]) if (r, c) not in safe]
game["mines"] = set(random.sample(squares, game["mine_count"]))
def mines_around(game, row, col):
"""Count the mines touching a square."""
return len(set(neighbours(game, row, col)) & game["mines"])
def open_square(game, row, col):
"""Open a square; around a 0, keep opening the neighbours (flood fill)."""
to_open = [(row, col)]
while to_open:
square = to_open.pop()
if square in game["open"] or square in game["flags"]:
continue
game["open"].add(square)
if mines_around(game, *square) == 0:
to_open.extend(neighbours(game, *square))
def reveal(game, row, col):
"""Open the chosen square and return "lost", "won" or None."""
if not game["mines"]:
place_mines(game, row, col) # so the first square is always safe
if (row, col) in game["mines"]:
return "lost"
open_square(game, row, col)
safe_squares = game["rows"] * game["cols"] - game["mine_count"]
return "won" if len(game["open"]) == safe_squares else None
# --- Drawing ---
def show(game, reveal_mines=False):
"""Print the board; at the end of a game, show where the mines were."""
letters = " ".join(LETTERS[:game["cols"]])
flags = len(game["flags"])
print(f"\n {letters} Mines: {game['mine_count']} Flags: {flags}")
for r in range(game["rows"]):
cells = []
for c in range(game["cols"]):
if reveal_mines and (r, c) in game["mines"]:
cells.append(MINE)
elif (r, c) in game["flags"]:
cells.append(FLAG)
elif (r, c) in game["open"]:
count = mines_around(game, r, c)
cells.append(str(count) if count else EMPTY)
else:
cells.append(HIDDEN)
print(f"{r + 1:>2} " + " ".join(cells))
# --- Playing ---
def parse_move(game, text):
"""Turn "b3" into (False, 2, 1) and "f b3" into (True, 2, 1), or None."""
match = MOVE.fullmatch(text.strip().lower())
if not match:
return None
row, col = int(match.group(3)) - 1, LETTERS.lower().index(match.group(2))
if 0 <= row < game["rows"] and col < game["cols"]:
return bool(match.group(1)), row, col
return None
def play(game):
"""Play one game until it is won or lost."""
moves = 0
show(game)
while True:
move = parse_move(game, input("Move (like B3, or f B3 to flag): "))
if move is None:
print("Type a letter and a number that are on the board, like C4.")
continue
flag, row, col = move
if (row, col) in game["open"]:
print("That square is already open.")
elif flag:
game["flags"] ^= {(row, col)} # add the flag, or take it away
show(game)
elif (row, col) in game["flags"]:
print("Flagged. Type f and the square again to unflag it.")
else:
moves += 1
result = reveal(game, row, col)
show(game, reveal_mines=result is not None)
if result == "lost":
print(f"Boom. {LETTERS[col]}{row + 1} was a mine. Game over.")
return
if result == "won":
print(f"You cleared the board in {moves} moves.")
return
def main():
"""Play games until the player stops."""
print("Minesweeper")
print("Open every safe square. A number counts the mines touching it.")
while True:
for key, (name, rows, cols, mines) in LEVELS.items():
print(f" {key}) {name:<7} {rows} x {cols}, {mines} mines")
choice = input("Choose a level (1-3): ").strip()
while choice not in LEVELS:
choice = input("Please type 1, 2 or 3: ").strip()
play(new_game(*LEVELS[choice][1:]))
again = input("\nPlay again? (y/n) ").strip().lower()
while again not in ("y", "n"):
again = input("Please type y or n: ").strip().lower()
if again == "n":
print("Goodbye.")
break
if __name__ == "__main__":
main()

Open it in the full editor to save it, share it and keep building.

Sample run

What the program printed, and what was typed (in yellow).

Minesweeper
Open every safe square. A number counts the mines touching it.
  1) Small   6 x 6, 4 mines
  2) Medium  9 x 9, 10 mines
  3) Large   10 x 12, 18 mines
Choose a level (1-3): 1

    A B C D E F     Mines: 4  Flags: 0
 1  # # # # # #
 2  # # # # # #
 3  # # # # # #
 4  # # # # # #
 5  # # # # # #
 6  # # # # # #
Move (like B3, or f B3 to flag): C5

    A B C D E F     Mines: 4  Flags: 0
 1  . . . 2 # #
 2  1 1 . 2 # #
 3  # 1 . 1 2 #
 4  1 1 . . 1 #
 5  . . . . 1 1
 6  . . . . . .
Move (like B3, or f B3 to flag): f A3

    A B C D E F     Mines: 4  Flags: 1
 1  . . . 2 # #
 2  1 1 . 2 # #
 3  F 1 . 1 2 #
 4  1 1 . . 1 #
 5  . . . . 1 1
 6  . . . . . .
Move (like B3, or f B3 to flag): F3

    A B C D E F     Mines: 4  Flags: 1
 1  . . . 2 # #
 2  1 1 . 2 # #
 3  F 1 . 1 2 2
 4  1 1 . . 1 #
 5  . . . . 1 1
 6  . . . . . .
Move (like B3, or f B3 to flag): G2
Type a letter and a number that are on the board, like C4.
Move (like B3, or f B3 to flag): F2

    A B C D E F     Mines: 4  Flags: 1
 1  . . . 2 # #
 2  1 1 . 2 # 2
 3  F 1 . 1 2 2
 4  1 1 . . 1 #
 5  . . . . 1 1
 6  . . . . . .
Move (like B3, or f B3 to flag): F1

    A B C D E F     Mines: 4  Flags: 1
 1  . . . 2 * 2
 2  1 1 . 2 * 2
 3  * 1 . 1 2 2
 4  1 1 . . 1 *
 5  . . . . 1 1
 6  . . . . . .
You cleared the board in 4 moves.

Play again? (y/n) n
Goodbye.

How it is built

1. The game in one dictionary

One dictionary holds a whole game: the board size, the mines, the open squares and the flags. The last three are sets of (row, col) pairs, so checking a square is quick.

def new_game(rows, cols, mines):
    """Return a fresh game. The mines are placed after the first move."""
    return {"rows": rows, "cols": cols, "mine_count": mines,
            "mines": set(), "open": set(), "flags": set()}

2. A safe first move

The mines are placed only after the first move, and never on that square or next to it, so the first move always opens an area. random.sample() picks different squares, never the same one twice.

def place_mines(game, row, col):
    """Scatter the mines, keeping the first square and its neighbours clear."""
    safe = set(neighbours(game, row, col)) | {(row, col)}
    squares = [(r, c) for r in range(game["rows"])
               for c in range(game["cols"]) if (r, c) not in safe]
    game["mines"] = set(random.sample(squares, game["mine_count"]))

3. Counting mines

& between two sets gives the squares that are in both, so the neighbours that are also mines. len() of that is the number shown on the square.

def mines_around(game, row, col):
    """Count the mines touching a square."""
    return len(set(neighbours(game, row, col)) & game["mines"])

4. Flood fill

When a square has no mines around it, all its neighbours are safe, so they are opened too, and so on outwards. A list used as a stack holds the squares still to open; squares already open or flagged are skipped.

def open_square(game, row, col):
    """Open a square; around a 0, keep opening the neighbours (flood fill)."""
    to_open = [(row, col)]
    while to_open:
        square = to_open.pop()
        if square in game["open"] or square in game["flags"]:
            continue
        game["open"].add(square)
        if mines_around(game, *square) == 0:
            to_open.extend(neighbours(game, *square))

5. Reading a move

The pattern accepts b3, B3 or f b3. The letter becomes a column with index() and the number a row, and anything off the board gives None.

def parse_move(game, text):
    """Turn "b3" into (False, 2, 1) and "f b3" into (True, 2, 1), or None."""
    match = MOVE.fullmatch(text.strip().lower())
    if not match:
        return None
    row, col = int(match.group(3)) - 1, LETTERS.lower().index(match.group(2))
    if 0 <= row < game["rows"] and col < game["cols"]:
        return bool(match.group(1)), row, col
    return None

6. The game loop

Each move is checked before it is used: an open square or a flagged one gets a message. ^= toggles a flag, adding the square if it is not in the set and removing it if it is.

def play(game):
    """Play one game until it is won or lost."""
    moves = 0
    show(game)
    while True:
        move = parse_move(game, input("Move (like B3, or f B3 to flag): "))
        if move is None:
            print("Type a letter and a number that are on the board, like C4.")
            continue
        flag, row, col = move
        if (row, col) in game["open"]:
            print("That square is already open.")
        elif flag:
            game["flags"] ^= {(row, col)}  # add the flag, or take it away
            show(game)
        elif (row, col) in game["flags"]:
            print("Flagged. Type f and the square again to unflag it.")
        else:
            moves += 1
            result = reveal(game, row, col)
            show(game, reveal_mines=result is not None)
            if result == "lost":
                print(f"Boom. {LETTERS[col]}{row + 1} was a mine. Game over.")
                return
            if result == "won":
                print(f"You cleared the board in {moves} moves.")
                return

Make it bigger

  • Mark the mine that ended the game with an X and wrongly flagged squares with a dash.
  • Add chording: typing a number that already has that many flags around it opens all its other neighbours.
  • Time each game with time.perf_counter and keep a best time for each level.
  • Let the player choose the board size and the number of mines.

What it practises