8
\$\begingroup\$

This is a very simple (no ability to flag tiles) mine sweeping game written in Python and designed for a terminal. Due to its limitations it is not recommended to change the board size as that will break the alignment of the visual indicators.

It will display the game board as shown below and ask the user for input in the form of m and n until either a mine is encountered or all empty tiles have been uncovered.
Quitting the program is done by entering two non-numeric space-separated values (this could likely be done in a more graceful manner but I'm unsure how).

  0 1 2 3 4 5 6 7 8 9
  --------------------
0|~ ~ ~ ~ ~ ~ ~ ~ ~ ~ |0
1|~ ~ ~ ~ ~ ~ ~ ~ ~ ~ |1
2|~ ~ ~ ~ ~ ~ ~ ~ ~ ~ |2
3|~ ~ ~ ~ ~ ~ ~ ~ ~ ~ |3
4|~ ~ ~ ~ ~ ~ ~ ~ ~ ~ |4
5|~ ~ ~ ~ ~ ~ ~ ~ ~ ~ |5
6|~ ~ ~ ~ ~ ~ ~ ~ ~ ~ |6
7|~ ~ ~ ~ ~ ~ ~ ~ ~ ~ |7
8|~ ~ ~ ~ ~ ~ ~ ~ ~ ~ |8
9|~ ~ ~ ~ ~ ~ ~ ~ ~ ~ |9
  --------------------
  0 1 2 3 4 5 6 7 8 9

Enter m n:

I have never written anything substantial in Python so I'm very interested in any advice regarding best practices and other things I did wrong/not in a pythonic way.

Code:

"""A simple minesweeping game"""

import os
from random import randint
import sys


class Tile:
    """Tile has a display value and can have a mine"""

    def __init__(self, value):
        """Tile constructor"""

        self.value = value
        self.has_mine = False


class Board:
    """Board has a collection of tiles and provides methods
    to interact with them
    """

    def __init__(self, width, height, mines, closed_tile, open_tile):
        """Board constructor"""

        self.width = width
        self.height = height
        self.mines = mines
        self.closed_tile = closed_tile
        self.open_tile = open_tile
        self.tiles = []
        self.uncovered_tiles = 0

    def initialize(self):
        """Create the board with initial values and randomly place mines"""

        for i in range(self.height):
            sublist = []
            for j in range(self.width):
                sublist.append(Tile(self.closed_tile))
            self.tiles.append(sublist)

        i = 0
        while i < self.mines:
            m, n = randint(0, self.height - 1), randint(0, self.width - 1)
            if not self.tiles[m][n].has_mine:
                self.tiles[m][n].has_mine = True
                i += 1

    def display(self):
        """Print the board along with coordinate indicators"""

        os.system('cls' if os.name == 'nt' else 'clear')

        print("  " + " ".join(str(i) for i in range(0, self.width)))
        print("  " + "-" * (self.width * 2))

        i = 0
        for col in range(len(self.tiles)):
            sys.stdout.write(str(i) + "|")
            for row in range(len(self.tiles[col])):
                print(self.tiles[col][row].value, end=" ")
            print("|" + str(i))
            i += 1

        print("  " + "-" * (self.width * 2))
        print("  " + " ".join(str(i) for i in range(0, self.width)))

    def count_mines(self, m, n):
        """Count the mines adjacent to the given tile"""

        mines = 0
        for y in range(m - 1, m + 2):
            for x in range(n - 1, n + 2):
                if (y < 0 or y >= self.height or x < 0 or x >= self.width):
                    continue

                if self.tiles[y][x].has_mine:
                        mines += 1

        return mines

    def check_tile(self, m, n):
        """Check tiles recursively"""

        if self.tiles[m][n].value != self.closed_tile:
            return

        self.uncovered_tiles += 1

        mines = self.count_mines(m, n)
        if mines > 0:
            self.tiles[m][n].value = mines
            return

        self.tiles[m][n].value = self.open_tile

        for y in range(m - 1, m + 2):
            for x in range(n - 1, n + 2):
                if (x == n and y == m):
                    continue

                if (y < 0 or y >= self.height or x < 0 or x >= self.width):
                    continue

                self.check_tile(y, x)

    def is_board_uncovered(self, goal):
        """Check if all tiles except for mines have been uncovered"""

        return self.uncovered_tiles == goal

    def tile_has_mine(self, m, n):
        """Determine if a user-picked tile contains a mine or not"""

        return self.tiles[m][n].has_mine


class Game:
    """Game provides the main loop and passes user input to board"""

    def __init__(self):
        """Game constructor"""

        width = 20
        height = 20
        mines = 10
        closed_tile = "~"
        open_tile = " "
        self.board = Board(width, height, mines, closed_tile, open_tile)
        self.goal = width * height - mines
        self.board.initialize()

    def play(self):
        """Play the game until a win/lose condition is encountered"""

        while True:
            self.board.display()

            m, n = self.get_input()

            if self.board.tile_has_mine(m, n):
                print("Game Over")
                sys.exit(0)

            self.board.check_tile(m, n)

            if self.board.is_board_uncovered(self.goal):
                print("You Won\n")
                sys.exit(0)

    def get_input(self):
        """make sure the user enters 2 valid numbers or quit"""

        while True:
            try:
                m, n = (input("\nEnter m n: ").split())
            except:
                print("Invalid number of arguments")
                continue

            try:
                m = int(m)
                n = int(n)
            except:
                sys.exit(0)

            if (m >= 0 and m < self.board.height and
               n >= 0 and n < self.board.width):
                return m, n
            else:
                print("Invalid range")


def main(argv):
    game = Game()
    game.play()


if __name__ == "__main__":
    main(sys.argv)
\$\endgroup\$
6
\$\begingroup\$

In what regards naming conventions I'd say the code is very good, following most of PEP8 guides. The code is also documented which is a big plus.

Pythonic

There are some things you can do to make the code more pythonic:

  • Chained Comparisons

    Consider the following comparison in the code:

    if (m >= 0 and m < self.board.height and
       n >= 0 and n < self.board.width):
    

    You can turn it into:

    if 0 <= m < self.board.height and 0 <= n < self.board.width:
    

    Which is a more pythonic way of doing the same. Note how I also removed the parentheses since they are redundant and just clutter the code. Also note that these extra parentheses appear in other places of the code as well.

  • List Comprehensions

    There are some places where you can use them, such as the initialize:

    for i in range(self.height):
        sublist = []
        for j in range(self.width):
            sublist.append(Tile(self.closed_tile))
        self.tiles.append(sublist)
    

    Can be transformed to:

    for i in range(self.height):
        sublist = [Tile(self.closed_tile) for _ in range(self.width)]
        self.tiles.append(sublist)
    

    You can even go as far as doing:

    for i in range(self.height):
        self.tiles.append([Tile(self.closed_tile) for _ in range(self.width)])
    

    Or:

    self.tiles = [[Tile(self.closed_tile) for i in range(self.width)] for j in range(self.height)]
    

    But as you can see in this last case, it starts getting harder to read so personally I'd leave as one of the first two alternatives that I have shown.

    There are also other places where you can use them.

  • Favor interpolation over concatenation

    You only have two situations where you concatenate and even these are so simple, that are mostly fine as they are. Lets consider this one:

    print("  " + " ".join(str(i) for i in range(0, self.width)))
    print("  " + "-" * (self.width * 2))
    

    One can even argue if they should be changed. However, interpolation is easier for most cases if not all. You could do them like so:

    print("  {}".format(" ".join(str(i) for i in range(0, self.width))))
    print("  {}".format("-" * (self.width * 2)))
    

Exceptions

Avoid catching exceptions with a bare except as it is too broad:

try:
    m = int(m)
    n = int(n)
except:

In this case, the failed conversion will raise a ValueError exception. Therefore you can adjust your except to capture it specifically:

try:
    m = int(m)
    n = int(n)
except ValueError:

This other case:

while True:
    try:
        m, n = (input("\nEnter m n: ").split())
    except:
        print("Invalid number of arguments")
        continue

There is no need for an exception here, and you can check directly if the split results in 2 elements:

while True:
    coordinates = input("\nEnter m n: ").split()
    if len(coordinates) != 2:
        print("Invalid number of arguments")
        continue

    m, n = coordinates

I kept both m and n so that its easier to see how it fits with the rest of the code, although i would favor more meaningful names.

sys.exit

While sys.exit may be fine and even handy some very small scripts, it gets hard to manage in larger applications. Also they tend to make the code harder to debug because there are a lot of scattered exit points.

Curiously enough most of the ones you used in the code weren't really necessary for the logic you were after. The play method is the only method called in the main function, therefore exiting it in this case is literally equivalent to the sys.exits you had.

So you could rewrite it to:

def play(self):
    """Play the game until a win/lose condition is encountered"""

    while True:
        self.board.display()
        m, n = self.get_input()

        if self.board.tile_has_mine(m, n):
            print("Game Over")
            break  # instead of sys.exit(0)

        self.board.check_tile(m, n)
        if self.board.is_board_uncovered(self.goal):
            print("You Won\n")
            break  # instead of sys.exit(0)

Which has the exact same meaning in this case.

While I'm at it let me make another two extra side notes on this method:

  • The prints used have inconsistent termination, since the second one has a trailing \n. Consistency is key
  • board.is_board_uncovered ends up repeating the board word. Better and simpler would be board.is_uncovered

You can actually avoid the sys.exit altogether if you structure both play and get_input differently, and make use of exceptions to signal when the input wasn't possible to read:

def play(self):
    """Play the game until a win/lose condition is encountered"""

    self.board.display()
    while True:
        try:
            m, n = self.get_input()
        except ValueError:
            break  # non-numeric exit
        except Exception as ex:
            print(ex)
            continue

        self.board.display()
        if self.board.tile_has_mine(m, n):
            print("Game Over")
            break

        self.board.check_tile(m, n)
        if self.board.is_board_uncovered(self.goal):
            print("You Won")
            break

def get_input(self):
    """make sure the user enters 2 valid numbers or quit"""

    coordinates = input("\nEnter m n: ").split()
    if len(coordinates) != 2:
        raise Exception("Invalid number of arguments")

    m, n = map(int, coordinates)
    if m < 0 or m >= self.board.height or n < 0 or n >= self.board.width:
        raise Exception("Invalid range")

    return m, n

For this solution the get_input only returns the inputs if they were valid otherwise it raises an exception. It also removes the need for a while loop in the get_input. In the play method if an exception is caught it means that the input wasn't captured properly and therefore it prompts it again by continuing to the next iteration.

Quitting the game

Quitting the program is done by entering two non-numeric space-separated values (this could likely be done in a more graceful manner but I'm unsure how)

In fact the program quits when either of the given values is non-numeric and not both.

That aside, I don't see too many other ways of doing it, and this would certainly be easier with a GUI that has a close button. However I would suggest the following:

When awaiting the m and n input values if the user presses Enter twice you quit the game.

This is somewhat more graceful than the current solution and also avoids an unintentional Enter press. It is sort of reminiscent of the double back button on android apps to quit them (at least on some of them). On the other hand this solution will imply a bit more logic in the input processing part.

\$\endgroup\$
  • \$\begingroup\$ You did an outstanding work here! Great answer \$\endgroup\$ – German Attanasio Apr 12 '18 at 1:47
  • \$\begingroup\$ (1) In your last snippet: why did you move the display function and why is it duplicated?; The range check needs to be >= for n as well. (2) In your 2nd variation of the list comprehension example: is it okay to use underscore for the outer loop as well since we don't actually use i inside the loop? E.g. for _ in range(self.height): \$\endgroup\$ – yuri Apr 12 '18 at 17:27
  • \$\begingroup\$ @yuri (1) i wanted to maintain the exact same behavior, and not re-print the board when the input is incorrect, which would happen if i maintained the same display() call at the start of the loop. The >= for n i just missed it while restructuring, nice catch :) (2) It is ok to use underscore as well for the inner loop, i just opted for i, j to make it clear that its a different one. This could be debatable though. \$\endgroup\$ – Isac Apr 12 '18 at 18:58

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.