Building a collaborative iOS Minesweeper game with Realm
Tags: swiftui, ios, database, realm
Published: 2022-03-14 16:32:56

## Introduction

I wanted to build an app that we could use at events to demonstrate [Realm Sync](https://www.mongodb.com/realm/mobile/sync). It needed to be fun to interact with, and so a multiplayer game made sense. Tic-tac-toe is too simple to get excited about. I'm not a game developer and so _Call Of Duty_ wasn't an option. Then I remembered Microsoft's [Minesweeper](https://en.wikipedia.org/wiki/Minesweeper). 

Minesweeper was a Windows fixture from 1990 until Windows 8 relegated it to the app store in 2012. It was a single-player game, but it struck me as something that could be a lot of fun to play with others. Some family beta-testing of my first version while waiting for a ferry proved that it did get people to interact with each other (even if most interactions involved shouting, "Which of you muppets clicked on that mine?!").

![Family sat around a table, all playing the Realm-Sweeper game on their iPhones](https://mongodb-devhub-cms.s3.us-west-1.amazonaws.com/IMG_1301_81fa444116.jpeg)

You can download the back end and iOS apps from the [Realm-Sweeper repo](https://github.com/realm/Realm-Sweeper), and get it up and running in a few minutes if you want to play with it.

This article steps you through some of the key aspects of setting up the backend Realm app, as well as the iOS code. Hopefully, you'll see how simple it is and try building something for yourself. If anyone's looking for ideas, then [Sokoban](https://en.wikipedia.org/wiki/Sokoban) could be interesting.

## Prerequisites

- [Realm-Cocoa 10.20.1+](https://github.com/realm/realm-swift/releases/tag/v10.23.0)
- iOS 15+

## The Minesweeper game

The gameplay for Minesweeper is very simple.

You're presented with a grid of gray tiles. You tap on a tile to expose what's beneath. If you expose a mine, game over. If there isn't a mine, then you'll be rewarded with a hint as to how many mines are adjacent to that tile. If you deduce (or guess) that a tile is covering a mine, then you can plant a flag to record that.

You win the game when you correctly flag every mine and expose what's behind every non-mined tile.

### What Realm-Sweeper adds

Minesweeper wasn't designed for touchscreen devices; you had to use a physical mouse. Realm-Sweeper brings the game into the 21st century by adding touch controls. Tap a tile to reveal what's beneath; tap and hold to plant a flag.

Minesweeper was a single-player game. All people who sign into Realm-Sweeper with the same user ID get to collaborate on the same game in real time.

![Animation of two iPhones. As a user taps a tile on one device, the change appears almost instantly on the other](https://mongodb-devhub-cms.s3.us-west-1.amazonaws.com/realm_sweeper_24ef46cef1.gif)

You also get to configure the size of the grid and how many mines you'd like to hide.

## The data model

I decided to go for a simple data model that would put Realm sync to the test. 

Each game is a single document/object that contains meta data (score, number of rows/columns, etc.) together with the grid of tiles (the board):

![Data model for the Game Class](https://mongodb-devhub-cms.s3.us-west-1.amazonaws.com/schema_f791f6acf6.png)

This means that even a modestly sized grid (20x20 tiles) results in a `Game` document/object with more than 2,000 attributes. 

Every time you tap on a tile, the `Game` object has to be synced with all other players. Those players are also tapping on tiles, and those changes have to be synced too. If you tap on a tile which isn't adjacent to any mines, then the app will recursively ripple through exposing similar, connected tiles. That's a lot of near-simultaneous changes being made to the same object from different devices—a great test of Realm's automatic conflict resolution!

## The backend Realm app

If you don't want to set this up yourself, simply follow the [instructions from the repo](https://github.com/realm/Realm-Sweeper/tree/main/Realm) to import the app.

If you opt to build the backend app yourself, there are only two things to configure once you create the empty Realm app:

1. [Enable email/password authentication](https://docs.mongodb.com/realm/authentication/email-password/#:~:text=Summary-,Overview,email%20addresses%20are%20case%2Dsensitive.). I kept it simple by opting to auto-confirm new users and sticking with the default password-reset function (which does nothing).
2. [Enable partitioned Realm sync](https://docs.mongodb.com/realm/sync/configure/enable-sync/). Set the partition key to `partition` and enable developer mode (so that the schema will be created automatically when the iOS app syncs for the first time).

The `partition` field will be set to the username—allowing anyone who connects as that user to sync all of their games.

You can also add sync rules to ensure that a user can only sync their own games (in case someone hacks the mobile app). I always prefer using Realm functions for permissions. You can add this for both the read and write rules:

```json
{
  "%%true": {
    "%function": {
      "arguments": [
        "%%partition"
      ],
      "name": "canAccessPartition"
    }
  }
}
```

The `canAccessPartition` function is:

```js
exports = function(partition) {
  const user = context.user.data.email;
  return partition === user;
};
```

## The iOS app

I'd suggest starting by downloading, configuring, and running the app—just follow the [instructions from the repo](https://github.com/realm/Realm-Sweeper/tree/main/iOS). That way, you can get a feel for how it works.

This isn't intended to be a full tutorial covering every line of code in the app. Instead, I'll point out some key components. 

As always with Realm and MongoDB, it all starts with the data…

### Model

There's a single top-level Realm Object—`Game`:

```swift
class Game: Object, ObjectKeyIdentifiable {
    @Persisted(primaryKey: true) var _id: ObjectId
    @Persisted var numRows = 0
    @Persisted var numCols = 0
    @Persisted var score = 0
    @Persisted var startTime: Date? = Date()
    @Persisted var latestMoveTime: Date?
    @Persisted var secondsTakenToComplete: Int?
    @Persisted var board: Board?
    @Persisted var gameStatus = GameStatus.notStarted
    @Persisted var winningTimeInSeconds: Int?
    …
}
```

Most of the fields are pretty obvious. The most interesting is `board`, which contains the grid of tiles:

```swift
class Board: EmbeddedObject, ObjectKeyIdentifiable {
    @Persisted var rows = List<Row>()
    @Persisted var startingNumberOfMines = 0
    ... 
}
```

`row` is a list of `Cells`:

```swift
class Row: EmbeddedObject, ObjectKeyIdentifiable {
    @Persisted var cells = List<Cell>()
    ...
}

class Cell: EmbeddedObject, ObjectKeyIdentifiable {
    @Persisted var isMine = false
    @Persisted var numMineNeigbours = 0
    @Persisted var isExposed = false
    @Persisted var isFlagged = false
    @Persisted var hasExploded = false
    ...
}
```

The model is also where the ~~business~~ game logic is implemented. This means that the views can focus on the UI. For example, `Game` includes a computed variable to check whether the game has been solved:

```swift
var hasWon: Bool {
    guard let board = board else { return false }
    if board.remainingMines != 0 { return false }

    var result = true

    board.rows.forEach() { row in
        row.cells.forEach() { cell in
            if !cell.isExposed && !cell.isFlagged {
                result = false
                return
            }
        }
        if !result { return }
    }
    return result
}
```

### Views

As with any SwiftUI app, the UI is built up of a hierarchy of many views.

![Screen capture from Xcode showing the hierarchy of views making up the RealmSweeper UI](https://mongodb-devhub-cms.s3.us-west-1.amazonaws.com/views_aaac46f078.png)

Here's a quick summary of the [views](https://github.com/realm/Realm-Sweeper/tree/main/iOS/RealmSweeper/Views) that make up Real-Sweeper:

**[`ContentView`](https://github.com/realm/Realm-Sweeper/blob/main/iOS/RealmSweeper/Views/ContentView.swift)** is the top-level view. When the app first runs, it will show the `LoginView`. Once the user has logged in, it shows `GameListView` instead. It's here that we set the Realm Sync partition (to be the `username` of the user that's just logged in):

```swift
if username == "" {
    LoginView(username: $username)
} else {
    GameListView()
        .environment(\.realmConfiguration, realmApp.currentUser!.configuration(partitionValue: username))
        .navigationBarItems(leading: realmApp.currentUser != nil ? LogoutButton(username: $username) : nil)
}
```

`ContentView` also includes the `LogoutButton` view.

**[`LoginView`](https://github.com/realm/Realm-Sweeper/blob/main/iOS/RealmSweeper/Views/Users/LoginView.swift)** allows the user to provide a username and password:

![Screen capture of the login view. Fields to enter username and password. Checkbox to indicate that you're registering a new user. Button to login,](https://mongodb-devhub-cms.s3.us-west-1.amazonaws.com/Login_View_034d3f058f.png)

Those credentials are then used to register or log into the backend Realm app:

```swift
func userAction() {
    Task {
        do {
            if newUser {
                try await realmApp.emailPasswordAuth.registerUser(
                    email: email, password: password)
            }
            let _ = try await realmApp.login(
                    credentials: .emailPassword(email: email, password: password))
            username = email
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}
```


[**`GameListView`**](https://github.com/realm/Realm-Sweeper/blob/main/iOS/RealmSweeper/Views/Game%20List/GameListView.swift) reads the list of this user's existing games.

```swift
@ObservedResults(Game.self, 
    sortDescriptor: SortDescriptor(keyPath: "startTime", ascending: false)) var games
```

It displays each of the games within a `GameSummaryView`. If you tap one of the games, then you jump to a `GameView` for that game:

```swift
NavigationLink(destination: GameView(game: game)) {
    GameSummaryView(game: game)
}
```

![GameListView, Screen capture of a list of games, together with buttons to logout, set settings, or create a new game](https://mongodb-devhub-cms.s3.us-west-1.amazonaws.com/Game_List_View_22711c2b4a.png)

Tap the settings button and you're sent to `SettingsView`.

Tap the "New Game" button and a new `Game` object is created and then stored in Realm by appending it to the `games` live query:

```swift
private func createGame() {
    numMines = min(numMines, numRows * numColumns)
    game = Game(rows: numRows, cols: numColumns, mines: numMines)
    if let game = game {
        $games.append(game)
    }
    startGame  = true
}
```

**[`SettingsView`](https://github.com/realm/Realm-Sweeper/blob/main/iOS/RealmSweeper/Views/SettingsView.swift)** lets the user choose the number of tiles and mines to use:

![SettingsView. Steppers to set the number of rows, columns, and mines](https://mongodb-devhub-cms.s3.us-west-1.amazonaws.com/Settings_View_39aa24c7ce.png)

If the user uses multiple devices to play the game (e.g., an iPhone and an iPad), then they may want different-sized boards (taking advantage of the extra screen space on the iPad). Because of that, the view uses the device's `UserDefaults` to locally persist the settings rather than storing them in a synced realm:

```swift
@AppStorage("numRows") var numRows = 10
@AppStorage("numColumns") var numColumns = 10
@AppStorage("numMines") var numMines = 15
```

**[`GameSummaryView`](https://github.com/realm/Realm-Sweeper/blob/main/iOS/RealmSweeper/Views/Game%20List/GameSummaryView.swift)** displays a summary of one of the user's current or past games.

![GameSummaryView. Screen capture of view containing the start and completion times + emoji for the status of the game](https://mongodb-devhub-cms.s3.us-west-1.amazonaws.com/Game_Summary_View_e80a138236.png)

**[`GameView`](https://github.com/realm/Realm-Sweeper/blob/main/iOS/RealmSweeper/Views/Game/GameView.swift)** shows the latest stats for the current game at the top of the screen: 
 
![GameStatusView. Screen capture showing remaining mines, status of the game (smiling emoji) and elapsed time](https://mongodb-devhub-cms.s3.us-west-1.amazonaws.com/Game_Status_5ee8b8b6e0.png)

It uses the `LEDCounter` and `StatusButton` views for the summary.

Below the summary, it displays the `BoardView` for the game.

**[`LEDCounter`](https://github.com/realm/Realm-Sweeper/blob/main/iOS/RealmSweeper/Views/Control%20Box/Counters/LEDCounter.swift)** displays the provided number as three digits using a retro LED font:

![CounterView – 3 red LED numbers](https://mongodb-devhub-cms.s3.us-west-1.amazonaws.com/Counter_View_d7b73c42ce.png)

**[`StatusButton`](https://github.com/realm/Realm-Sweeper/blob/main/iOS/RealmSweeper/Views/Control%20Box/StatusButton.swift)** uses a `ZStack` to display the symbol for the game's status on top of a tile image:

![StatusButton. Smiling emoji in front of a gray tile](https://mongodb-devhub-cms.s3.us-west-1.amazonaws.com/Status_Button_8975894d39.png)

The view uses SwiftUI's [`GeometryReader`](https://www.hackingwithswift.com/quick-start/swiftui/how-to-provide-relative-sizes-using-geometryreader) function to discover how much space is available so that it can select an appropriate font size for the symbol:

```swift
GeometryReader { geo in
    Text(status)
        .font(.system(size: geo.size.height * 0.7))
}
```


**[`BoardView`](https://github.com/realm/Realm-Sweeper/blob/main/iOS/RealmSweeper/Views/Game/BoardView.swift)** displays the game's grid of tiles:

![BoardView. A grid of tiles. Some tiles have been removed, revealing colored numbers. One tile contains a red flag](https://mongodb-devhub-cms.s3.us-west-1.amazonaws.com/Board_View_703d6386db.png)

Each of the tiles is represented by a `CellView` view.

When a tile is tapped, this view exposes its contents:

```swift
.onTapGesture() {
    expose(row: row, col: col)
}
```

On a tap-and-hold, a flag is dropped:

```swift
.onLongPressGesture(minimumDuration: 0.1) {
    flag(row: row, col: col)
}
```

When my family tested the first version of the app, they were frustrated that they couldn't tell whether they'd held long enough for the flag to be dropped. This was an easy mistake to make as their finger was hiding the tile at the time—an example of where testing with a mouse and simulator wasn't a substitute for using real devices. It was especially frustrating as getting it wrong meant that you revealed a mine and immediately lost the game. Fortunately, this is easy to fix using iOS's haptic feedback:

```swift
func hapticFeedback(_ isSuccess: Bool) {
    let generator = UINotificationFeedbackGenerator()
    generator.notificationOccurred(isSuccess ? .success : .error)
}
```

You now feel a buzz when the flag has been dropped.

**[`CellView`](CellView)** displays an individual tile: 

![CellView. Tile containing a crossed out red flag](https://mongodb-devhub-cms.s3.us-west-1.amazonaws.com/Cell_View_6ee46c6103.png)

What's displayed depends on the contents of the `Cell` and the state of the game. It uses four further views to display different types of tile: `FlagView`, `MineCountView`, `MineView`, and `TileView`.

**[`FlagView`](https://github.com/realm/Realm-Sweeper/blob/main/iOS/RealmSweeper/Views/Tiles/FlagView.swift)**

![FlagView. 2 Tiles, both containing a flag, one shows the  flag crossed out](https://mongodb-devhub-cms.s3.us-west-1.amazonaws.com/Flag_View_fd57e245f3.png)

**[`MineCountView`](https://github.com/realm/Realm-Sweeper/blob/main/iOS/RealmSweeper/Views/Tiles/MineCountView.swift)**

![MineCountView, 6 gray tiles. One is empty the others containing numbers 1 through 5, each in a different color](https://mongodb-devhub-cms.s3.us-west-1.amazonaws.com/Mine_Count_View_e8392ff46d.png)

**[`MineView`](https://github.com/realm/Realm-Sweeper/blob/main/iOS/RealmSweeper/Views/Tiles/MineView.swift)**

![MineView. Two tiles containing mines, one with a gray background, one with a red background](https://mongodb-devhub-cms.s3.us-west-1.amazonaws.com/Mine_View_18eaaf517b.png)

**[`TileView`](https://github.com/realm/Realm-Sweeper/blob/main/iOS/RealmSweeper/Views/Tiles/TileView.swift)**

![TileView. A single gray tile](https://mongodb-devhub-cms.s3.us-west-1.amazonaws.com/Tile_View_1a01815c26.png)

## Conclusion

Realm-Sweeper gives a real feel for how quickly Realm is able to synchronize data over the internet.

I intentionally avoided optimizing how I updated the game data in Realm. When you see a single click exposing dozens of tiles, each cell change is an update to the `Game` object that needs to be synced.

![GIF showing changes in one game board appearing in near-realtime in the game board on a different device](https://mongodb-devhub-cms.s3.us-west-1.amazonaws.com/sync_526ade2926.gif)

Note that both instances of the game are running in iPhone simulators on an overworked Macbook in England. The Realm backend app is running in the US—that's a 12,000 km/7,500 mile round trip for each sync.

I took this approach as I wanted to demonstrate the performance of Realm synchronization. If an app like this became super-popular with millions of users, then it would put a lot of extra strain on the backend Realm app.

An obvious optimization would be to condense all of the tile changes from a single tap into a single write to the Realm object. If you're interested in trying that out, just fork the [repo](https://github.com/realm/Realm-Sweeper) and make the changes. If you do implement the optimization, then please create a pull request. (I'd probably add it as an option within the settings so that the "slow" mode is still an option.)

Got questions? Ask them in our [Community forum](https://www.mongodb.com/community/forums/c/realm/realm-sync/111).