Monty Hall Problem in SwiftUI - part 1

I love the Monty Hall problem, even though I find it counter-intuitive. The Monty Hall problem is a form of probability puzzle named after American game show host Monty Hall. It is presented in the style of the game show, called Let's Make a Deal, where you are given a choice of selecting one of three doors. Behind one of the doors is the desired prize of a car, while behind the other two doors are goats. After you pick one of the three doors, the host opens one of the remaining doors to reveal a goat and asks you if you would like to switch doors.

The question is

Is it to your advantage to switch doors or stick with the door you have already chosen?


Standard Assumptions

  • Host must always open a door that was not picked by the contestant
  • Host must always open a door to reveal a goat
  • Host must always offer the chance to switch doors


Define a Model for Monty Hall Problem

First define a model for a Monty Hall puzzle. We use emoji for the car and goat images, so we define a Door struct that contains the emoji and a property for whether the door is open or closed. It also has a computed property for the label on the door, which is based on the emoji.

1struct Door {
2    let emoji: String
3    
4    var label: String {
5        return emoji == "πŸš—" ? "car" : "goat"
6    }
7    var open = false
8}

The MontyGameModel contains three of the Doors - initialized with two goats and one car. It has Boolean property to store whether or not the game is won. It has a function to get the door at a specified index. There are three mutating functions which modify the model; one to shuffle the doors; one to open one of the unselected doors and finally one to play the game. The function openGoatDoor is to simulate the host opening one of the unchosen doors that reveals a goat and not a car.

 1struct MontyGameModel {
 2    private var doors: [Door]
 3    private var gameWon = false
 4    
 5    init() {
 6        self.doors = [
 7            Door(emoji: "🐐"),
 8            Door(emoji: "πŸš—"),
 9            Door(emoji: "🐐")
10        ]
11    }
12    
13    var isGameWon: Bool {
14        get { gameWon }
15    }
16   
17    var doorCount: Int {
18        get { doors.count }
19    }
20    
21    // Shuffle the doors
22    mutating func shuffle() {
23        // Close all the doors
24        for i in doors.indices {
25            doors[i].open = false
26        }
27        gameWon = false
28        self.doors.shuffle()
29    }
30
31    func doorAtIndex(_ i:Int) -> Door {
32        return doors[i]
33    }
34
35    mutating func openGoatDoor(_ i: Int) -> Int {
36        let otherDoors = [0,1,2].filter { $0 != i }
37        var doorToOpenIndex = -1
38        if doors[i].emoji == "πŸš—" {
39            // open a random of the other doors
40            doorToOpenIndex = otherDoors.randomElement()!
41        }
42        else {
43            // open the goat of the remaining doors
44            doorToOpenIndex = otherDoors.filter { doors[$0].emoji != "πŸš—" }[0]
45        }
46        self.doors[doorToOpenIndex].open = true
47        return doorToOpenIndex
48    }
49    
50    mutating func playGame(_ i: Int) {
51        // Open the selected door
52        doors[i].open = true
53        gameWon = doors[i].emoji == "πŸš—"
54    }
55}

Define a Model for the metrics to store results for multiple repeat runs of the game.

 1struct MetricsModel {
 2    private var wonCount: Int
 3    private var lostCount: Int
 4    
 5    init() {
 6        wonCount = 0
 7        lostCount = 0
 8    }
 9    
10    var won: Int {
11        get { wonCount }
12    }
13    
14    var lost: Int {
15        get { lostCount }
16    }
17    
18    var played: Int {
19        get { wonCount + lostCount }
20    }
21    
22    var percentageWon: Double {
23        get {
24            if wonCount + lostCount == 0 {
25                return 0.0
26            }
27            else {
28                return 100.0 * Double(wonCount) / Double(wonCount + lostCount)
29            }
30        }
31    }
32    
33    mutating func gameOver(isWon: Bool) {
34        if isWon {
35            wonCount += 1
36        } else {
37            lostCount += 1
38        }
39    }
40    
41    mutating func reset() {
42        wonCount = 0
43        lostCount = 0
44    }
45}


Create a SwiftUI view for the door

The DoorView is composed of an open view and a closed view contained in a ZStack. The closed view rotates to almost 90 degrees when the door is opened using rotation3DEffect. Whether the door is open or closed is passen into the view along with the index of the door. The number displayed on the closed door is the index plus one to avoid zero-based numbering on doors.

The view also has a button under each door to indicate whether or not the door is selected. This is bound to the selected property, which is determined by the selected index in MontyHallGameView. Binding is used to create a two-way connection between the door and the SwiftUI view so the selected index in MontyHallGameView is updated when the select button is selected on a door. The button under each door uses SF Symbols as either an empty circle or a star in a circle depending on whether or not the door is selected.

 1struct DoorView: View {
 2    var number: Int
 3    var door: Door
 4    var isGameOver: Bool
 5    @Binding var selected: Int
 6    
 7    var body: some View {
 8        Button(action: {
 9            selected = number
10        }) {
11            VStack {
12                ZStack {
13                    DoorOpenView(emoji: door.emoji, label: door.label)
14                    DoorClosedView(doorNumber: number + 1)
15                        .rotation3DEffect(
16                            Angle.degrees(door.open ? -87 : 0),
17                            axis: (0,1,0),
18                            anchor: .leading,
19                            perspective: 0.2
20                        )
21                }
22                .animation(.easeInOut(duration: door.open ? 1.0 : 0.0))
23                
24                Image(systemName: selected == number ? "checkmark.seal.fill" : "seal")
25                    .font(.system(size: 30))
26                    .foregroundColor(Color("mainText"))
27                    .padding(.vertical, 5)
28            }
29        }
30        .buttonStyle(DoorButtonStyle())
31        .disabled(isGameOver || door.open || selected == number)
32    }
33}

The layout and display of an open door.

 1struct DoorOpenView: View {
 2    var emoji: String
 3    var label: String
 4    
 5    var body: some View {
 6        GeometryReader { gr in
 7            let minSize = min(gr.size.width, gr.size.height)
 8            let emojiSize = minSize * 0.8
 9            let labelSize = minSize * 0.2
10            let borderWidth = minSize * 0.008
11            let pad = gr.size.height * 0.15
12            ZStack {
13                ArchShape()
14                    .fill(Color("innerDoor"))
15                ArchShape()
16                    .stroke(Color("door2").opacity(0.5), lineWidth: borderWidth)
17                Text(emoji)
18                    .font(.system(size: emojiSize))
19                    .offset(x: 0, y: -pad)
20                Text(label.capitalized)
21                    .font(.system(size: labelSize))
22                    .foregroundColor(Color("border"))
23                    .offset(x: 0, y: pad*2.2)
24            }
25        }
26    }
27}

The layout and display of a closed door.

 1struct DoorClosedView: View {
 2    var doorNumber: Int
 3    
 4    var body: some View {
 5        GeometryReader { gr in
 6            let minSize = min(gr.size.width, gr.size.height)
 7            let handleOffset = minSize * 0.35
 8            let borderWidth = minSize * 0.008
 9            let handleSize = minSize * 0.07
10            let fontSize = minSize * 0.6
11            ZStack {
12                ArchShape()
13                    .fill(Colors.doorGradient)
14                    .shadow(color: Color(#colorLiteral(red: 1, green: 1, blue: 1, alpha: 1)).opacity(0.3), radius: 3, x:-1, y:-1)
15                    .shadow(color: .black, radius: 3, x:5, y:5)
16                ArchShape()
17                    .stroke(Color("border"), lineWidth: borderWidth)
18                    .scaleEffect(CGSize(width: 0.99, height: 0.99), anchor: .center)
19                Circle()
20                    .fill(Color("border"))
21                    .frame(width: handleSize, height: handleSize)
22                    .offset(x: handleOffset, y: 0.0)
23                Text("\(doorNumber)")
24                    .foregroundColor(.white)
25                    .font(.system(size: fontSize, weight: .heavy, design: .rounded))
26            }
27        }
28    }
29}
 1struct ArchShape: Shape {
 2    func path(in rect: CGRect) -> Path {
 3        let w = rect.width
 4        let h = rect.height
 5        var path = Path()
 6        path.move(to: CGPoint(x: 0, y: h))
 7        path.addLine(to: CGPoint(x: 0, y: h*0.3))
 8        path.addCurve(to: CGPoint(x: w, y: h*0.3),
 9                      control1: CGPoint(x: 0, y: -(h*0.1)),
10                      control2: CGPoint(x: w, y: -(h*0.1)))
11        path.addLine(to: CGPoint(x: w, y: h))
12        path.closeSubpath()
13        return path
14    }
15}
16
17
18struct DoorButtonStyle: ButtonStyle {
19    func makeBody(configuration: Configuration) -> some View {
20        configuration.label
21            .scaleEffect(configuration.isPressed ? 0.98 : 1.0)
22    }
23}

SwiftUI view for Door with open and closed states
SwiftUI view for Door with open and closed states



Define the ViewModels

The MontyHallViewModel ViewModel creates the model and exposes read-only properties for the Monty Hall Doors. It also has functions to shuffle the doors to start a new game and to open a door by the host and to play the game. These call the relevant functions in the model. The ViewModel also exposes the result as a read-only property.

 1class MontyHallViewModel: ObservableObject {
 2    @Published private var montyModel: MontyGameModel
 3
 4    init() {
 5        montyModel = MontyGameModel()
 6    }
 7
 8    var isGameWon: Bool {
 9        get { montyModel.isGameWon }
10    }
11    
12    var doorCount: Int {
13        get { montyModel.doorCount }
14    }
15    
16    func door(index: Int) -> Door {
17        return montyModel.doorAtIndex(index)
18    }
19
20    func shuffle() {
21        montyModel.shuffle()
22    }
23    
24    func openDoor(selectedDoor i: Int) {
25        _ = montyModel.openGoatDoor(i)
26    }
27    
28    func playGame(selectedDoor i: Int) {
29        montyModel.playGame(i)
30    }
31}

The MetricsViewModel contains an instance of MetricsModel and exposes readonly properties to retrieve the results of multiple games played. It also provides methods reset the metrics and update the metrics when the game is over.

 1class MetricsViewModel: ObservableObject {
 2    @Published private var metricsModel: MetricsModel
 3
 4    init() {
 5        metricsModel = MetricsModel()
 6    }
 7
 8    var won: Int {
 9        get { metricsModel.won }
10    }
11    
12    var lost: Int {
13        get { metricsModel.lost }
14    }
15    
16    var played: Int {
17        get { metricsModel.played }
18    }
19    
20    var percentageWon: Double {
21        get { metricsModel.percentageWon }
22    }
23    
24    func gameOver(isWon: Bool) {
25        metricsModel.gameOver(isWon: isWon)
26    }
27    
28    func reset() {
29        metricsModel.reset()
30    }
31}


Create the Game

There is quite a bit of code in MontyHallGameView. The View does not know anything about the Model but has a property for the MontyHallViewModel and a new ViewModel is instantiated when the View is created. The montyHall property is marked with the @ObservedObject property wrapper, so the view can be notified when the state of the object has changed.

The "Open a door" button is disabled until the contestant has chosen a door. This then opens one of the unselected doors to reveal a goat. The "Play" button completes the game and reveals what is behind the selected door and whether the contestant won or lost.

 1struct MontyHallGameView: View {
 2    @ObservedObject private var montyHall: MontyHallViewModel
 3    @ObservedObject private var metrics: MetricsViewModel
 4    @State private var selected = -1
 5    @State private var doorOpened = false
 6    @State private var gameOver = false
 7    
 8    init() {
 9        self.montyHall = MontyHallViewModel()
10        self.metrics = MetricsViewModel()
11    }
12
13    var body: some View {
14        ZStack {
15            Colors.bgGradient
16                .edgesIgnoringSafeArea(.all)
17            
18            VStack {
19                Spacer().frame(height:20)
20                
21                Text("Monty Hall Problem")
22                    .foregroundColor(Color("mainText"))
23                    .font(.custom("Helvetica Neue", size: 36, relativeTo: .largeTitle))
24                    .fontWeight(.bold)
25                
26                HStack(spacing:20) {
27                    ForEach(0..<montyHall.doorCount) { i in
28                        VStack {
29                            DoorView(number: i,
30                                     door: montyHall.door(index: i),
31                                     isGameOver: gameOver,
32                                     selected: $selected)
33                                .frame(width:100, height:200)
34                        }
35                    }
36                }
37                
38                Spacer().frame(height:20)
39                
40                ZStack {
41                    Spacer()
42                        .frame(height:40)
43                    if gameOver {
44                        Text(montyHall.isGameWon ? "You Won" : "You Loose")
45                    }
46                }
47                .foregroundColor(Color("mainText"))
48                .font(.largeTitle)
49                
50                VStack {
51                    Button( action: {
52                        selected = -1
53                        montyHall.shuffle()
54                        doorOpened = false
55                        gameOver = false
56                    })
57                    {
58                        ActionButtonView(label: "New Game",
59                                         symbol: "asterisk")
60                    }
61                    
62                    Button( action: {
63                        montyHall.openDoor(selectedDoor: selected)
64                        doorOpened = true
65                    })
66                    {
67                        ActionButtonView(label: "Open a door",
68                                         symbol: "arrow.right.square")
69                    }
70                    .disabled(gameOver || doorOpened || selected < 0)
71
72                    Button( action: {
73                        montyHall.playGame(selectedDoor: selected)
74                        gameOver = true
75                        metrics.gameOver(isWon: montyHall.isGameWon)
76                    })
77                    {
78                        ActionButtonView(label: "play", symbol: "play.fill")
79                    }
80                    .disabled(gameOver || selected < 0)
81                }
82                .buttonStyle(ActionButtonStyle())
83                
84                Spacer().frame(height:30)
85                
86                Rectangle()
87                    .fill(Color("background1").opacity(0.3))
88                    .frame(height: 5)
89
90                GameMetricsView(metrics: metrics)
91                    .foregroundColor(Color("mainText"))
92
93                
94                Spacer()
95            }
96        }
97    }
98}

A button view is defined to ensure consistent appearance of buttons and uses a ButtonStyle.

 1struct ActionButtonView: View {
 2    var label: String
 3    var symbol: String
 4    
 5    var body: some View {
 6        HStack {
 7            Image(systemName: symbol).frame(width: 60)
 8            Text(label)
 9            Spacer()
10        }
11    }
12}
13
14
15struct ActionButtonStyle: ButtonStyle {
16    @Environment(\.isEnabled) var isEnabled
17    
18    func makeBody(configuration: Configuration) -> some View {
19        configuration.label
20            .font(.system(size: 20, weight: .bold, design: .rounded))
21            .frame(height:30)
22            .foregroundColor(.white)
23            .padding(5)
24            .frame(width: 250)
25            .background(isEnabled ? Colors.buttonGradient :  Colors.disabledbuttonGradient)
26            .cornerRadius(25)
27            .shadow(color: isEnabled ? .black : .clear, radius:2, x:3.0, y:3.0)
28            .scaleEffect(configuration.isPressed ? 0.95 : 1.0)
29    }
30}

The metrics from multiple games are laid out in a separate GameMetricsView with a button to reset the metrics. The view is bound to MetricsViewModel and calls the reset function when the button is pressed.

 1struct GameMetricsView: View {
 2    @ObservedObject var metrics: MetricsViewModel
 3    
 4    var body: some View {
 5        VStack(spacing:5) {
 6            Text("Games played Metrics")
 7                .font(.custom("Helvetica Neue", size: 28, relativeTo: .largeTitle))
 8                .fontWeight(.bold)
 9            
10            HStack(spacing: 10) {
11                Text("Played:")
12                    .frame(width:150, alignment: .trailing)
13                Text("\(metrics.played)")
14                    .frame(width:100, alignment: .leading)
15                Spacer()
16            }
17            HStack(spacing: 10) {
18                Text("Won:")
19                    .frame(width:150, alignment: .trailing)
20                Text("\(metrics.won)")
21                    .frame(width:100, alignment: .leading)
22                Spacer()
23            }
24            HStack(spacing: 10) {
25                Text("Lost:")
26                    .frame(width:150, alignment: .trailing)
27                Text("\(metrics.lost)")
28                    .frame(width:100, alignment: .leading)
29                Spacer()
30            }
31            HStack(spacing: 10) {
32                Text("Percentage Won:")
33                    .frame(width:150, alignment: .trailing)
34                Text("\(metrics.percentageWon, specifier: "%.1f")%")
35                    .frame(width:100, alignment: .leading)
36                Spacer()
37            }
38            
39            Button("Reset Metrics") {
40                metrics.reset()
41            }
42            .frame(width:200)
43            .buttonStyle(ActionButtonStyle())
44        }
45        .frame(width:300)
46    }
47}

iOS app to play Monty Hall game
iOS app to play Monty Hall game



Monty Hall Problem demo

Monty Hall Problem demo




Conclusion

Back to the question:

Is it to your advantage to switch doors or stick with the door you have already chosen?

The answer is that you should always switch doors. The easiest way I have to explain it is that when you select a door, there is 1 in 3 probability (33%) that this is the correct door. Resulting in a 2 in 3 probability (66%) that the car is behind the other two doors. When the host opens one of those other two doors, nothing in the setup has changed. There is still a 1 in 3 probability that you have selected the correct door and a 2 in 3 probability that the car is behind the other two doors. We now know that it is not behind the one the host opened. So of the two remaining closed doors, there is a 1 in 3 probability that the car is behind the door you have chosen and a 2 in 3 probability that the car is behind the other door. So switch doors when the host asks you for a 66% chance of getting the car.

Yes. Always switch doors

In part 2 of this article, I will modify the app to run a few simulations to prove that switching doors is the correct strategy.