watchOS With SwiftUI by Tutorials!

Build awesome apps for Apple Watch with SwiftUI,
the declarative and modern way.

Home Flutter Tutorials

Creating a Game Like Minesweeper in Flutter

Explore Flutter’s capability to create game UI and logic by learning to create a game like classic Minesweeper.

4/5 7 Ratings

Version

  • Dart 2.12, Flutter 2.2, Android Studio 4.2

Developing games with Flutter isn’t explored very much. In this tutorial, you’ll build a game like the classic Minesweeper in Flutter. You’ll learn to create the game’s UI and write clean, modular code to implement algorithms that bring the game logic to life.

Here’s a preview of the app you’ll build:

Minesweeper Flutter final project preview

To build this game, you’ll use several Flutter widgets to create the UI and write algorithms to implement the game logic.

While building this app, you’ll learn about:

  • Minesweeper’s logic and game theory.
  • Implementing the game user interface using Flutter widgets.
  • Building a dynamic and random game with a specific difficulty every time.
  • Using the GestureDetector widget to detect taps and long-presses.
  • Optimizing the game to avoid edge cases.
Note: This article assumes you know the basics of the Flutter framework, including common widgets and their properties. To learn about Flutter from scratch, check out the Flutter Apprentice book.

Getting Started

Download the starter project by clicking Download Materials at the top or bottom of the tutorial.

Unzip the downloaded file and open it with Android Studio 4.1 or later. You can use Visual Studio Code instead, but if you do, you’ll need to tweak some instructions to follow along.

Click Open an existing Android Studio project and choose the starter folder from your unzipped download.

Run the flutter create command in the starter folder to generate the android and ios folders. Next, download your dependencies by double-clicking pubspec.yaml on the left panel and then clicking pub get at the top of your screen. To avoid problems, delete the test folder, which Flutter generated when you executed the flutter create command.

Finally, build and run to see this:

Starter project on iOS Simulator

Here are a few files you’ll see in the starter project’s lib folder:

  • main.dart is the main file that acts as the entry point for the app. It contains MyApp, which contains MaterialApp. This widget uses MyHomePage as the child. This widget contains the game’s only screen.
  • cell.dart contains the model for the cells that you’ll lay out in a grid on the screen. It contains a class called CellModel.
  • cell_widget.dart contains CellWidget, which is a visual representation of a cell on the screen.

Introducing the Minesweeper Flutter Game

Before you get into writing the code to build this Flutter game, it’s important to understand the basics of Minesweeper. It’s a logical single-player game that requires careful assessment of the cells on the board before making the next move. This continues until the player clears the board — or hits a mine. The game originates from the 1960s, and it has been written for many computing platforms in use today. It has many variations and offshoots. Simply do a quick Google search for “Minesweeper”, and you can play a web version of the game right in your browser.

For this project, you’ll need to understand Minesweeper in more detail.

Playing Minesweeper

Minesweeper is a simple — but not to be underestimated — puzzle game that consists of a rectangular board of tiles laid out on the screen. The game’s objective is to clear the board without detonating any hidden “mines”, or bombs, by using help from numbers indicating how many mines are in neighboring cells.

The player selects a cell to uncover it. If the cell has a mine, the game is over. Otherwise, the cell displays the number of mines in the cell’s Moore neighborhood. Using these numbers, the player uncovers nearby cells. This continues until either they hit a mine or uncover all the non-mine cells on the board. Therefore, understanding the numbers in the cells is crucial to completing the game.

While the game is in progress, the player can flag a cell for their reference if they suspect it to be a mine. This has no impact on the game logic but allows the player to keep track of the suspected cells. A flagged cell can be either a mine or a non-mine.

Once the player uncovers all the non-mine cells, the game is complete. In this Minesweeper Flutter version, the game restarts with increased difficulty — the size of the grid increases, and the number of mines also increases.

Understanding the Moore Neighborhood

A Moore neighborhood is a 3×3 grid of cells. It consists of one central cell and all its surrounding cells, including cells with sides adjacent to the central cell and those that touch its corners. The Moore neighborhood is named for Edward F. Moore, a computer scientist known for his work in topics including cellular automata and finite state machines. Here’s a diagram of a cell with its Moore neighborhood, with numbers indicating cell coordinates:

Starter project on iOS Simulator

In Minesweeper, when the player selects a non-mine cell, the cell reveals a number. This represents the number of mines in the surrounding eight cells, or the cell’s Moore neighborhood. Using the number, the player can make a calculated guess about the next non-mine cell on the board that they can safely uncover. Keep in mind that no mines are uncovered at any point in the game. Tapping a cell with a mine is the only way to uncover a mine, and that ends the game.

When the player taps a non-mine cell, the game also uncovers all the non-mine cells in its Moore neighborhood. As a result, the non-mine cells in overlapping Moore neighborhoods are also uncovered, and this continues until there are no more non-mine cells in the Moore neighborhood of an uncovered cell. You use recursion to implement this in the code.

In the code, you’ll write unrevealRecursively, which takes in a CellModel object and uncovers the cells in the Moore neighborhood recursively.

Understanding the CellModel Class and Properties

Inside cell.dart, you’ll find the definition of the class CellModel. This is the foundational model class you use to keep track of the cells on the board in the memory. You also use this class to represent the cell visually in combination with CellWidget.

In the game, you store all the cells in a 2D array. Since you’re using Dart, you’ll create a List to store all the cells. So, each cell will have a row number and column number. With this in mind, now look at the properties of the CellModel class:

  • x is an integer that stores the cell’s column number.
  • y is an integer that stores the cell’s row number.
  • isMine is a boolean that stores true if the cell is a mine and false if it isn’t. The default value is false.
  • isRevealed is a boolean variable that stores true if the cell has been uncovered or false if it hasn’t.
  • value is an integer variable that stores the number of mines in the Moore neighborhood of the cell. The default value is zero.
  • isFlagged is a boolean variable that stores true if the player has flagged the cell or false if not. The default value is false.

Laying Out Widgets

Now that you know all the basics of the game and you’re also aware of CellModel, it’s time to start writing some code and building the user interface.

Building the Grid

Start by implementing generateGrid:

  void generateGrid() {
    cells = [];
    totalCellsRevealed = 0;
    totalMines = 0;

    for (int i = 0; i < size; i++) {
      var row = [];
      for (int j = 0; j < size; j++) {
        final cell = CellModel(i, j);
        row.add(cell);
      }
      cells.add(row);
    }
  }

The code above simply creates a list of CellModel objects for each position in the grid. i is the column number, and j is the row number. size is the size of the grid, which defaults to five when the game starts.

At this point, invoke generateGrid from initState so the app generates cells randomly as soon it starts:

  @override
  void initState() {
    super.initState();
    generateGrid();
  }

generateGrid randomly generates CellModel objects for all the cells in the game. If the size is five, the grid will have 25 cells. If you save everything and try running the app now, you won't see anything different on-screen. This is because you need to generate CellWidget objects from these CellModel objects and lay them out on the screen, which you'll do next.

Using Rows and Columns

The game board is essentially a 2D array of CellWidgets. These are created based on randomly generated CellModel objects. Start by implementing generateGrid, which creates a 2D list of CellModel objects that you'll use later to create CellWidgets.

You need to implement a buildButton that takes in a CellModel object and returns the corresponding CellWidget:

  Widget buildButton(CellModel cell) {
    return GestureDetector(
      onLongPress: () {
        // TODO
      },
      onTap: () {
        // TODO
      },
      child: CellWidget(
        size: size,
        cell: cell,
      ),
    );
  }

Import CellWidget from cell_widget.dart if you get errors regarding undefined symbols.

The code above simply wraps CellWidget inside GestureDetector and returns it. You'll use the onTap and onLongPress events later to implement user interactions with the cells. For now, you have empty functions bound to those events. CellWidget requires size and cell properties. The default cell size is calculated according to the screen size, and the corresponding CellModel is passed to the cell property.

Next, you'll implement buildButtonRow and buildButtonColumn.

Implement buildButtonRow first:

  Row buildButtonRow(int column) {
    List<Widget> list = [];
    //1
    for (int i = 0; i < size; i++) {
    //2
      list.add(
        Expanded(
          child: buildButton(cells[i][column]),
        ),
      );
    }
    //3
    return Row(
      children: list,
    );
  }

Here's what's happening in the code snippet above:

  1. For any given column, you loop over from 0 to size and use buildButton to create a cell.
  2. As the cell is created, it's added to a list of widgets.
  3. Finally, a Row widget is returned with the cells as its children.

Next, you'll implement buildButtonColumn:

  Column buildButtonColumn() {
    List<Widget> rows = [];
    //1
    for (int i = 0; i < size; i++) {
      rows.add(
        buildButtonRow(i),
      );
    }
    //2
    return Column(
      mainAxisAlignment: MainAxisAlignment.start,
      mainAxisSize: MainAxisSize.min,
      children: [
        Column(
          children: rows,
        ),
        // TODO
      ],
    );
  }

In the code snippet above, here's what's happening:

  1. A list of rows of cells is created using buildButtonRow.
  2. You return a column with the list of rows of cells as its children.
  3. TODO: Leave a placeholder for the rules text and a progress bar in the column, which you'll add later.

Finally, add buildButtonColumn to the body of Scaffold in build, as shown below:

  ...
  body: Container(
    margin: const EdgeInsets.all(1.0),
    child: buildButtonColumn(),
  ),
  ...

Save everything and restart the app. You'll see a grid of cells on the screen:

Grid on the game screen

Adding Game Logic

Now that you have the basic UI up and running, you need to set up a few things you'll use while implementing the game logic. Start by randomly generating mines and calculating the numerical values for each cell.

Generating Mines

Generating mines is as simple as setting the isMine property of a cell, an object of CellModel, to true. CellWidget then takes care of the rendering. You do this inside generateGrid right after generating the cells.

  void generateGrid() {
    ...
  
    // Marking mines
    for (int i = 0; i < size; ++i) {
      cells[Random().nextInt(size)][Random().nextInt(size)].isMine = true;
    }

    // Counting mines
    for (int i = 0; i < cells.length; ++i) {
      for (int j = 0; j < cells[i].length; ++j) {
        if (cells[i][j].isMine) totalMines++;
      }
    }
  }

You'll have to add the import for dart:math package to make the Random class work.

The code snippet above randomly assigns cells as mines. Since the size is set to five initially, five cells are randomly picked to set as mines. The second for loop counts the number of mines generated. You may think this is completely irrelevant and unneeded, but that's not true.

Since you're generating mines randomly, it's possible to pick the same cell two or more times and set it as a mine. In such a situation, the totalMines variable helps keep track of the total number of mines. Also, in this case, totalMines will be less than size.

Generating Cell Values

Now that you have mines — cells that have isMine=true — you can generate the values of cells around the mines. For all other cells that aren't in the Moore neighborhood of any mines, the value always stays 0 — use createInitialNumbersAroundMine to do this.

First, add the for loop to invoke createInitialNumbersAroundMine in generateGrid:

  void generateGrid() {
    ...
    
    // Updating values of cells in Moore's neighbourhood of mines
    for (int i = 0; i < cells.length; ++i) {
      for (int j = 0; j < cells[i].length; ++j) {
        if (cells[i][j].isMine) {
          createInitialNumbersAroundMine(cells[i][j]);
        }
      }
    }
  }

In the code snippet above, you call createInitialNumbersAroundMine for all the mine cells. Then, you pass the cell to the method as an argument.

Next, implement createInitialNumbersAroundMine. Add the following definition to createInitialNumbersAroundMine:

  void createInitialNumbersAroundMine(CellModel cell) {
    int xStart = (cell.x - 1) < 0 ? 0 : (cell.x - 1);
    int xEnd = (cell.x + 1) > (size - 1) ? (size - 1) : (cell.x + 1);

    int yStart = (cell.y - 1) < 0 ? 0 : (cell.y - 1);
    int yEnd = (cell.y + 1) > (size - 1) ? (size - 1) : (cell.y + 1);

    for (int i = xStart; i <= xEnd; ++i) {
      for (int j = yStart; j <= yEnd; ++j) {
        if (!cells[i][j].isMine) {
          cells[i][j].value++;
        }
      }
    }
  }

The code above increases the value of all cells in the input cell's Moore neighborhood by 1. Remember that the default value is 0 — it's as simple as that. The following illustration will help you understand the function above in more detail:

Grid showing mines and their Moore neighborhoods

As you can see in the illustration above, for each red cell representing a mine, the green cells represent its Moore neighborhood. Being in a Moore neighborhood increases the values of these cells by one, irrespective of their previous value. So, if a cell lies in the Moore neighborhood of more than one mine, its value increases by more than one.

At this point, if you want to look at the cell values and mines, you can quickly flip the default value of isRevealed in cell.dart to true.

Save the files and hot-restart the app. You'll see something like this:

Cells with mines and values

Note the following points from the image above:

  • The mines are randomly placed within the grid.
  • Every cell in the Moore neighborhood of a mine has its values increased from zero.
  • The final value of a cell is derived from the number of mines it has in its Moore neighborhood.
  • The total number of mines should be five because the size is five, but the actual number is four in this case. This is because mines are generated randomly, and it's why you used totalMines to keep track of the total number of mines.
  • CellWidget in cell_widget.dart defines the cells' visual representation.

Don't forget to flip the default value of isRevealed back to false and save the file.

Adding User Interactions

In this section, you'll write code that allows the player to tap a cell to uncover it and long-press a cell to flag or unflag it. When the player taps a cell, it's uncovered, but all the non-mine cells in the cell's Moore neighborhood are also uncovered recursively. Finally, you'll also check if the player has uncovered all the non-mine cells or has uncovered a mine, resulting in Game Over.

Adding onTap to Uncover Cells

You already have GestureDetector in place, which you return from buildButton. Earlier, you left onTap blank, but now it's time to wire it up to a method.

Add the following to GestureDetector:

  ...
  onTap: () {
    onTap(cell);
  },
  ...

In the code above, you simply pass cell to onTap, which uncovers the tapped cell. Next, you need to implement onTap:

  void onTap(CellModel cell) async {
    // 1
    if (cell.isMine) {
      // 2
      unrevealRecursively(cell);
      setState(() {});
      // TODO: Show game over dialog
      return;
    } else {
      // 3
      unrevealRecursively(cell);
      setState(() {});
      // TODO: Check if player won
    }
  }

In the code above, here's what's happening:

  1. Check if the tapped cell is a mine.
  2. If it is a mine, uncover cells recursively using unrevealRecursively and return. Later, you'll display a Game Over dialog here that also allows the player to restart.
  3. If the tapped cell isn't a mine, uncover cells recursively using unrevealRecursively. Later, you'll also check to see if the player has discovered all the non-mine cells and, if they have, display a Congratulations dialog.

The most important piece of code is still missing: unrevealRecursively. Time to implement that:

  void unrevealRecursively(CellModel cell) {
    if (cell.x > size || cell.y > size || cell.x < 0 || cell.y < 0 || cell.isRevealed) {
      return;
    }

    cell.isRevealed = true;
    totalCellsRevealed++;

    if (cell.value == 0) {
      int xStart = (cell.x - 1) < 0 ? 0 : (cell.x - 1);
      int xEnd = (cell.x + 1) > (size - 1) ? (size - 1) : (cell.x + 1);

      int yStart = (cell.y - 1) < 0 ? 0 : (cell.y - 1);
      int yEnd = (cell.y + 1) > (size - 1) ? (size - 1) : (cell.y + 1);

      for (int i = xStart; i <= xEnd; ++i) {
        for (int j = yStart; j <= yEnd; ++j) {
          if (!cells[i][j].isMine && !cells[i][j].isRevealed 
              && cells[i][j].value == 0) {
            unrevealRecursively(cells[i][j]);
          }
        }
      }
    } else {
      return;
    }
  }

The method above is a recursive one. It uncovers not only the current cell but also all the cells in its Moore neighborhood. It continues until it uncovers all non-mine cells in overlapping Moore neighborhoods, meaning all consecutive non-mine cells are uncovered at once. That's how it works in the classic Minesweeper game.

Save everything and perform a hot-restart of the app. Try tapping some cells in the game. You'll uncover a cell as you tap it. Also, notice how cells are uncovered recursively:

Uncovering the cells on tap

Adding longPress to Flag Cells

Now that you can uncover cells by tapping them, you just need to add one more gesture: long-press. In the same GestureDetector, you also have an onLongPress event that's currently wired to a blank function. It's now time to wire it up to an actual function that flags/unflags a cell:

  ...
  onLongPress: () {
    markFlagged(cell);
  },
  ...

Next, you need to implement markFlagged. Here's how it should look:

  void markFlagged(CellModel cell) {
    cell.isFlagged = !cell.isFlagged;
    setState(() {});
  }

The code above is very straightforward. It just toggles the Boolean isFlagged for the cell.

Save everything, hot-restart the app, and try long-pressing a cell — now you can flag it. If you repeat the action, the cell is unflagged:

Flagging/Unflagging the cells on long-press

Checking if the Game Is Over

Next, you need to check if the game is over. This is as simple as detecting if the cell the player tapped is a mine. Add this code inside onTap:

  void onTap(CellModel cell) async {
    if(cell.isMine) {
      unrevealRecursively(cell);
      setState(() {});

      // Add this
      bool response = await showDialog(
        context: context,
        builder: (ctx) => AlertDialog(
          title: Text("Game Over"),
          content: Text("You stepped on a mine. Be careful next time."),
          actions: [
            MaterialButton(
              color: Colors.deepPurple,
              onPressed: () {
                Navigator.of(context).pop(true);
              },
              child: Text("Restart"),
            ),
          ],
        ),
      );

      if (response) {
        restart();
      }
      return;
    }
    ...

The code above displays the Game Over dialog to the player with a button that says Restart. When the player taps Restart, restart is invoked and the game starts over. The next step is to implement restart:

  void restart() {
    setState(() {
      generateGrid();
    });
  }

It's so simple! restart invokes generateGrid, which regenerates the grid and therefore, restarts the game. setState rebuilds the UI.

Save everything. Restart the app and play:

Gameplay ending with Game Over message and Restart option

As soon as you tap a mine, you'll see the Game Over dialog. Tap Restart, and the game starts over.

Checking if the Player Won

Checking if the player has won is also very simple. You know the total number of cells, mines and uncovered cells. With this information, you can easily determine whether the user has uncovered all the non-mine cells.

Add the following code to onTap:

  void onTap(CellModel cell) async {
    if(cell.isMine) {
       ...
    } else {

      // Add this
      unrevealRecursively(cell);
      setState(() {});
      if (checkIfPlayerWon()) {
        bool response = await showDialog(
          context: context,
          builder: (ctx) => AlertDialog(
            title: Text("Congratulations"),
            content: Text("You discovered all the tiles without stepping on any mines. Well done."),
            actions: [
              MaterialButton(
                color: Colors.deepPurple,
                onPressed: () {
                  Navigator.of(context).pop(true);
                },
                child: Text("Next Level"),
              ),
            ],
          ),
        );

        if (response) {
          size++;
          restart();
        }
      } else {
        setState(() {});
      }
    }
  }

In the code snippet above, you use checkIfPlayerWon to determine whether the player has won. If they have, then checkIfPlayerWon returns true. Else, it returns false. If checkIfPlayerWon returns true, you display a Congratulations dialog with a Next Level button. Tapping this button increases the size of the grid by one and restarts the game.

Next, you need to implement checkIfPlayerWon:

  void checkIfPlayerWon() {
    if (totalCellsRevealed + totalMines == size * size) {
      return true;
    } else {
      return false;
    }
  }

In the code snippet above, you're calculating the sum of uncovered cells plus the number of mine cells and seeing if it equals the total number of cells.

Save everything and restart the app. At this point, you can perform a full run-through of the game:

Gameplay ending with Congratulations and Next Level option

After you finish the game successfully, you can move on to the next level, which has a bigger grid. Awesome, right? :]

Finishing Up

The game now works perfectly. You can play it, and the difficulty increases with each level. But you still need to put a few pieces into place to really polish your app.

Handling Edge Cases

What if the first cell the player taps is a mine? It wouldn't feel very good to lose immediately, right? You may have noticed this if you've played the game a couple times. While algorithmically, this is OK, fair play suggests avoiding this situation. Here's how to handle it.

When the user makes their first move in a new game and taps a cell that's a mine, you don't uncover the cell or display the Game Over dialog. Instead, you replace the cell they tapped with a non-mine cell. Even better, you regenerate the whole grid until the cell the user taps turns out to be a regular cell. Then you uncover that cell. All this happens so quickly that the user won't even notice.

Add the following code on the very top inside onTap:

  void onTap(CellModel cell) async {
    
    // If the first tapped cell is a mine, regenerate the grid
    if (cell.isMine && totalCellsRevealed == 0) {
      while (cells[cell.x][cell.y].isMine == true) {
        restart();
      }

      cell = cells[cell.x][cell.y];
    }

    if (cell.isMine) {
    ...

In the code above, you check if the first cell the player taps is a mine. If it is, use a while loop to regenerate the grid using restart until the player taps a regular cell.

Simple and elegant!

Adding ProgressBar

Next, you'll add a linear progress bar that tells the user their progress in the current game. It isn't required but is a nice touch to have.

Add the following code inside buildButtonColumn:

  return Column(
    ...
    children: [
        Column(
          children: rows,
        ),
 
        // Add this
        LinearProgressIndicator(
          backgroundColor: Colors.white,
          value: totalCellsRevealed / (size * size - totalMines),
          valueColor: AlwaysStoppedAnimation<Color>(Colors.deepPurple),
        ),
      ]   
      ...

In the code above, you add a simple LinearProgressIndicator widget that calculates the value based on the number of cells uncovered.

Adding Rules

Although Minesweeper is a very simple game, it would be helpful for new players to have the game's rules available. You have some vacant space at the bottom of the screen, so adding rules would be a great way to utilize that. You also have a pre-built method called getRules() already in the starter project and ready to be used.

Add the following code to the end of the inner Column widget's list of children inside buildButtonColumn, right after LinearProgressIndicator:

  return Column(
    ...
    children: [
        ...
        // Add this
        Expanded(
          child: SingleChildScrollView(
            primary: true,
            child: getRules(),
          ),
        ),
      ]   
      ...

There you go! Save everything, restart the app and enjoy the game. This is how your final app will look:

Minesweeper Final Preview

Where to Go From Here?

Download the final project using Download Materials at the top or bottom of the tutorial.

You just made a fully functional Minesweeper Flutter game and even added in a few extra niceties — that's awesome! Here are a few more ideas on how you can make the game even better:

  • Adjust the difficulty of the game at each level by tweaking the number of mines by increasing the value of size.
  • Optimize the edge-case handling by forgiving not just the first tap on a mine cell, but the first two or even the first five.
  • Make the game more fun and engaging by having a cloud-based scoreboard. This will add a sense of competition between players.
  • Add music and animation for a more fun and appealing environment.

To learn more about developing games with Flutter, check out the tutorial How to Create a 2D Snake Game in Flutter, and to learn the basics of Flutter, check out all the Flutter tutorials.

We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!

More like this

Contributors

Comments