Monty Hall Problem in SwiftUI - part 2

Monty Hall Problem in SwiftUI - Simulation. An SwiftUI app was developed for the Monty Hall problem and is described in part 1. The solution to the problem is that the contestant should always switch doors when the host opens the door to reveal a goat and offers the contestant the option to switch doors. This article will create a simulator to simulate playing the Monty Hall game a number of times to demonstrate that this is the winning strategy.

The Monty Hall problem is presented in the style of a game show, where a contestant is 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 the contestant selects 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.



Define a Model for the simulator

Start with the simulator model, which simply contains the target number of times to simulate playing the game and reuses an instance of MetricsModel. It exposes readonly properties of the simulation results and methods to reset the model data and run the Monty Hall game for the target number of times. The main logic is in the runSimulation method where it loops for the desired target number of times and creates a new instance of MontyGameModel, selects a door and plays the game. The result of the game are added to the metrics. A boolean parameter is used to determine whether or not to switch door.

 1struct SimulatorModel {
 2    private var targetRunCount: Int
 3    private var metrics: MetricsModel
 4    
 5    init() {
 6        targetRunCount = 0
 7        metrics = MetricsModel()
 8    }
 9    
10    var targetRuns: Int {
11        get { targetRunCount }
12    }
13    
14    var wins: Int {
15        get { metrics.won }
16    }
17    
18    var losses: Int {
19        get { metrics.lost }
20    }
21    
22    var gamesComplete: Int {
23        get { metrics.played }
24    }
25    
26    var percentageWon: Double {
27        get { metrics.percentageWon }
28    }
29    
30    mutating func setTargetNumber(_ n:Int) {
31        targetRunCount = n
32    }
33    
34    mutating func reset() {
35        targetRunCount = 0
36        metrics.reset()
37    }
38    
39    mutating func runSimulation(alwaysSwitch: Bool) {
40        // Loop for target count
41        for _ in 1...targetRunCount {
42            // 1. Create a new instance of MontyGameModel
43            var game = MontyGameModel()
44            
45            // 2. Select one of the three doors at random
46            let allDoors = Array(0..<game.doorCount)
47            var selectedDoorIndex: Int = allDoors.randomElement()!
48            
49            // 3. Open one of the other doors
50            let openedIndex = game.openGoatDoor(selectedDoorIndex)
51            
52            // 4. Switch selected door if required
53            if alwaysSwitch {
54                selectedDoorIndex = allDoors.filter { $0 != selectedDoorIndex && $0 != openedIndex }[0]
55            }
56            
57            // 5. Evaluate the game
58            game.playGame(selectedDoorIndex)
59            
60            // 6. Add result to metrics as either win or loss
61            metrics.gameOver(isWon: game.isGameWon)
62        }
63    }
64}

The metrics model is used to keep track of the number of games won and lost.

 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}


Define a ViewModel for the simulator

The ViewModel is the link between the Model and the View. It contains an instance of the SimulatorModel and a number of readonly properties. The SimulatorViewModel is defined as a class so that it can conform to ObservableObject protocol. This allows the view in SwiftUI to bind to the ViewModel with the use of the @Published property wrapper, so that whenever there are any changes to the simModel property, all views using that object will be reloaded to reflect those changes. There is one main method runSimulation that takes the target number of simulations to run and a boolean value on whether or not to always switch doors when one of the non-winning doors is opened.

 1class SimulatorViewModel: ObservableObject {
 2    @Published private var simModel: SimulatorModel
 3
 4    init() {
 5        simModel = SimulatorModel()
 6    }
 7
 8    var targetRuns: Int {
 9        get { simModel.targetRuns }
10    }
11    
12    var wins: Int {
13        get { simModel.wins }
14    }
15    
16    var losses: Int {
17        get { simModel.losses }
18    }
19    
20    var gamesComplete: Int {
21        get { simModel.gamesComplete }
22    }
23    
24    var percentageWon: Double {
25        get { simModel.percentageWon }
26    }
27    
28    func runSimulation(targerNumber n:Int, alwaysSwitch: Bool) {
29        simModel.reset()
30
31        simModel.setTargetNumber(n)
32
33        simModel.runSimulation(alwaysSwitch: alwaysSwitch)
34    }    
35}


Define a View for the simulator

The View for the Monty Hall Game simulator is bound to an instance of the SimulatorViewModel using ObservedObject property wrapper so the views are invalidated when the SimulatorModel changes. The view in MVVM can be composed of multiple SwiftUI views. The view simply present options to set the number of simulations, a button to run the simulation and a way of presenting the results. All the logic of running the simulations is in the models.

The main SwiftUI view MontyHallSimulatorView lays out the app to set the number of simulations to run, whether or not to switch doors and a button to run the simulation.

  1struct MontyHallSimulatorView: View {
  2    @ObservedObject private var montySimulator: SimulatorViewModel
  3    
  4    @State private var targetNumber = 100
  5    @State private var switchDoors = false
  6    
  7    let counts = [10, 100, 500, 1000]
  8    
  9    init() {
 10        montySimulator = SimulatorViewModel()
 11        runSimulation()
 12        UISegmentedControl.appearance()
 13            .selectedSegmentTintColor = UIColor(Color("background2"))
 14        UISegmentedControl.appearance()
 15            .setTitleTextAttributes([.foregroundColor: UIColor(Color("lightYellow"))],
 16                                    for: .selected)
 17        UISegmentedControl.appearance()
 18            .setTitleTextAttributes([.foregroundColor: UIColor(Color("background2"))],
 19                                    for: .normal)
 20    }
 21    
 22    private func runSimulation() {
 23        montySimulator.runSimulation(
 24            targerNumber: targetNumber,
 25            alwaysSwitch: switchDoors)
 26    }
 27    
 28    var body: some View {
 29        ZStack {
 30            Colors.bgGradient
 31                .edgesIgnoringSafeArea(.all)
 32            
 33            VStack {
 34                Spacer().frame(height:20)
 35                
 36                Text("Monty Hall Simulation")
 37                    .font(.custom("Helvetica Neue", size: 34, relativeTo: .largeTitle))
 38                    .fontWeight(.bold)
 39                
 40                ZStack {
 41                    BackGroundCardView()
 42                    
 43                    VStack {
 44                        Text("Select the Number of runs")
 45                            .font(.custom("Helvetica Neue", size: 22, relativeTo: .largeTitle))
 46                            .fontWeight(.bold)
 47                        
 48                        Picker("", selection: $targetNumber) {
 49                            ForEach(counts, id: \.self) {
 50                                Text("\($0)")
 51                                    .fontWeight(.heavy)
 52                            }
 53                        }
 54                        .pickerStyle(.segmented)
 55                        .background(Color("lightYellow"))
 56                        
 57                        
 58                        HStack {
 59                            Toggle(isOn: $switchDoors, label: {
 60                                Text("Always switch doors")
 61                                    .font(.custom("Helvetica Neue", size: 22, relativeTo: .largeTitle))
 62                                    .fontWeight(.bold)
 63                            })
 64                        }
 65                    }
 66                    .padding(.horizontal, 35)
 67                }
 68                .padding()
 69                
 70                
 71                Button("Run Simulation") {
 72                    runSimulation()
 73                }
 74                .frame(width:200)
 75                .buttonStyle(ActionButtonStyle())
 76                
 77                Spacer().frame(height:20)
 78                
 79                VStack(spacing:5) {
 80                    Text("Simulation Results")
 81                        .font(.custom("Helvetica Neue", size: 22, relativeTo: .largeTitle))
 82                        .fontWeight(.bold)
 83                }
 84                
 85                ZStack {
 86                    BackGroundCardView()
 87                    
 88                    VStack {
 89                        ResultChartView(wins: montySimulator.wins,
 90                                        losses: montySimulator.losses)
 91                            .frame(width: 300, height: 200)
 92                            .animation(.easeInOut(duration: 1))
 93                        
 94                        Spacer()
 95                    }
 96                }
 97                .frame(width: 350)
 98                
 99                Spacer().frame(height:20)
100                
101                CircularPercentageView(percentage: montySimulator.percentageWon)
102                    .frame(height: 130)
103                    .animation(.easeInOut(duration: 1))
104                
105                Spacer()
106            }
107            .foregroundColor(Color("mainText"))
108        }
109    }
110}

The ResultChartView presents the results as a vertical bar chart to more easily see the difference in results when the option to switch doors is selected.

 1struct ResultChartView: View {
 2    var wins: Int
 3    var losses: Int
 4    
 5    var body: some View {
 6        GeometryReader { gr in
 7            let maxValue = max(wins, losses)
 8            let scale = (gr.size.height * 0.85) / CGFloat(maxValue)
 9            let padWidth = gr.size.width * 0.07
10            VStack {
11                HStack {
12                    Spacer().frame(width: padWidth)
13                    
14                    BarView(text: "Won",
15                            value: wins,
16                            height: CGFloat(wins) * scale,
17                            color: Color("barWon"))
18                    
19                    Spacer().frame(width: padWidth)
20                    
21                    BarView(text: "Lost",
22                            value: losses,
23                            height: CGFloat(losses) * scale,
24                            color: Color("barLoss"))
25                    
26                    Spacer().frame(width: padWidth)
27                }
28            }
29        }
30    }
31}

BarView is used in the ResultChartView to keep the bars consistent.

 1struct BarView: View {
 2    var text: String
 3    var value: Int
 4    var height: CGFloat
 5    var color: Color
 6    
 7    var body: some View {
 8        let displayValue = value > 2 ? "\(value)" : ""
 9        let displatText = value > 2 ? text : "\(text) \(value)"
10        VStack(spacing: 0) {
11            Spacer()
12            RoundedRectangle(cornerRadius: 10)
13                .fill(color)
14                .frame(height: height, alignment: .trailing)
15                .overlay(
16                    VStack(spacing:10) {
17                        Text(displatText)
18                        if value > 2 {
19                            Text(displayValue)
20                        }
21                    }
22                )
23        }
24    }
25}

CircularPercentageView presents the number of wins as a percentage in a circular view.

 1struct CircularPercentageView: View {
 2    var percentage: Double
 3    
 4    var body: some View {
 5        let progressText = String(format: "%.0f%%", percentage)
 6        
 7        ZStack {
 8            Circle()
 9                .stroke(Color("lightYellow"), lineWidth: 20)
10            Circle()
11                .trim(from: 0, to: CGFloat(self.percentage / 100))
12                .stroke(
13                    Colors.purpleAngularGradient,
14                    style: StrokeStyle(lineWidth: 20, lineCap: .round))
15                .rotationEffect(Angle(degrees: -90))
16                .overlay(
17                    Text(progressText)
18                        .font(.system(size: 36, weight: .bold, design:.rounded))
19                        .fontWeight(.bold)
20                        .foregroundColor(Color("mainText"))
21                )
22        }
23    }
24}

BackGroundCardView displays a background outline and is used in the setting parameters area as well as behind the bar chart.

 1struct BackGroundCardView: View {
 2    var body: some View {
 3        RoundedRectangle(cornerRadius: 20)
 4            .fill(.clear)
 5            .overlay(
 6                RoundedRectangle(cornerRadius: 20)
 7                    .stroke(Color.gray, lineWidth: 4)
 8                    .blur(radius: 4)
 9                    .offset(x: 2, y: 2)
10                    .mask(
11                        RoundedRectangle(cornerRadius: 20)
12                            .fill(
13                                LinearGradient(
14                                    colors: [.black, .clear],
15                                    startPoint: .topLeading,
16                                    endPoint: .bottomTrailing)))
17            )
18            .overlay(
19                RoundedRectangle(cornerRadius: 20)
20                    .stroke(Color.white, lineWidth: 8)
21                    .blur(radius: 3)
22                    .offset(x: -2, y: -2)
23                    .mask(
24                        RoundedRectangle(cornerRadius: 20)
25                            .fill(
26                                LinearGradient(
27                                    colors: [.clear, .black],
28                                    startPoint: .topLeading,
29                                    endPoint: .bottomTrailing)))
30            )
31    }
32}

Simulation of Monty Hall Problem 1000 times when always selecting to switch doors
Simulation of Monty Hall Problem 1000 times when always selecting to switch doors



Supporting Light and Dark modes

Colors are defined in the Assets and different colors can be set for the same color name for 'Any Appearance' and 'Dark' mode. There is no need to set any color for colors that are the same in either mode. These and SwiftUI colors are used to define color gradients used in the app.

 1struct Colors {
 2    static let bgGradient = LinearGradient(
 3        gradient: Gradient(
 4            colors: [
 5                Color("background1"),
 6                Color("background2"),
 7                Color("background2")
 8            ]),
 9        startPoint: .topLeading,
10        endPoint: .bottomTrailing)
11    
12    static let doorGradient = LinearGradient(
13        gradient: Gradient(colors: [Color("door1"),Color("door2")]),
14        startPoint: .center,
15        endPoint: .bottomTrailing)
16    
17    static let buttonGradient = LinearGradient(
18        gradient: Gradient(colors: [Color("door1"),Color("door2")]),
19        startPoint: .top,
20        endPoint: .bottom)
21    
22    static let disabledbuttonGradient = LinearGradient(
23        gradient: Gradient(colors: [
24            Color("button4"),
25            Color("button4").opacity(0.5)]),
26        startPoint: .top,
27        endPoint: .bottom)
28    
29    static let purpleAngularGradient = AngularGradient(
30        gradient: Gradient(colors: [
31            Color("barLoss"),
32            Color(.green)
33        ]),
34        center: .center,
35        startAngle: .degrees(0),
36        endAngle: .degrees(340))
37}

Asset colors are defined to support light and dark modes
Asset colors are defined to support light and dark modes



Previewing Light mode and Dark mode

Multiple previews of the view can be shown in Xcode and the preferredColorScheme method can be used to set the color scheme to Dark.

1struct MontyHallSimulatorView_Previews: PreviewProvider {
2    static var previews: some View {
3        MontyHallSimulatorView().preferredColorScheme(.dark)
4        
5        MontyHallSimulatorView()
6    }
7}

Xcode preview showing light and dark previews
Xcode preview showing light and dark previews

Monty Hall Simulatior in light and dark mode
Monty Hall Simulatior in light and dark mode



Running the simulation

Running the simulation is simply a matter of selecting one of the options for the number of simulations and setting the always switch doors toggle and selecting Run Simulation.

Monty Hall simulation shows switching doors is the winning strategy

Monty Hall simulation shows switching doors is the winning strategy




Conclusion

This article showed how to build a simulator to run multiple executions of the Monty Hall problem to show that switching doors is the better option. It built on the SwiftUI app that was developed for the Monty Hall problem and is described in Monty Hall Problem in SwiftUI - part 1. It can be seen that when the number of simulations is small, such as 10, that the percentage won can vary greatly. However, as the number of simulations increases, the percentage of success with always switching doors gets closer to 66%. This confirms the hypothesis that there is a 2 in 3 probability that the contestant has chosen the incorrect door initially. When one of the remaining doors is opened to reveal a goat, there is a 2 in 3 possibility that the car is behind the non-selected door.

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

Yes. Always switch doors