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.
- Monty Hall Problem in SwiftUI - part 1
- Monty Hall Problem in SwiftUI - part 2
- Monty Hall Problem in SwiftUI - part 3
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
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
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.