Day 41 - 100 Days of Swift

8 minute read

Consolidation IV (Projects 7-9)

Day 41 is the fourth consolidation day. You review the things you learned in projects 7, 8 and 9, particularly .enumerated(), didSet and DispatchQueue.global.async{}. Then he gives you a challenge to build a hangman game on your own.

The first thing I did was outline a gameController class. I knew I would need a variable to hold the word being guessed, and I figured it would probably be good to keep an array of the individual letters in that word, and an array of the letters guessed so far, and an array that holds the guess word in its current state. I also added a score and a badGuesses count variable, and a couple of computed properties to make displaying the information easier for the view controller:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private(set) var currentWord: String = ""
private(set) var badGuesses: Int = 0
private(set) var score: Int = 0

private var repArray: [String] = []
private var currentLetters: [Character] = []
private var lettersGuessed: [Character] = []

var currentRepresentation: String {
    return repArray.joined(separator: " ")
}

var lettersGuessedRepresentation: String {
    return "Letters Guessed: " +
        lettersGuessed
        .map({ String($0) })
        .joined(separator: ", ")
}

Then I added a variable to hold all the possible words in memory and loaded them up. I decided to keep them in memory because it is limited in size (it is just plain text), it is a little faster, and it makes it easier to get rid of the possibility of repeating words.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private var allWords: [String]!

init() {
    loadNewWord()
}

func loadNewWord() {
    if allWords == nil {
        guard let url = Bundle.main.url(forResource: "words",
                                        withExtension: "txt"),
            let allWordsString = try? String(contentsOf: url) else {
                fatalError("Couldn't find the list of words.")
        }
        allWords = allWordsString.components(separatedBy: .newlines)
    }

    currentWord = allWords.randomElement()!
    currentLetters = Array(currentWord)
    repArray = Array(repeating: "_", count: currentLetters.count)
    lettersGuessed.removeAll()
    badGuesses = 0
}

The next thing I did was write a little helper function to update repArray when the user guesses a letter:

1
2
3
4
5
6
7
8
private func updateRepresentation(with letter: Character) {
    for (index, character) in currentLetters.enumerated() {
        if letter == character {
            repArray[index] = String(letter)
            score += 1
        }
    }
}

For scoring, I decided to add 1 for every correct letter guessed, plus the length of the word minus the number of bad guesses when the whole word has been guessed. In this loop I do the first part, adding 1 for every correct letter that is found.

Then I wrote the function to check an individual letter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func checkGuess(_ letter: Character) {
    // Make sure the user hasn't already guessed this letter.
    guard !lettersGuessed.contains(letter) else { return }
    // Make sure the letter is a letter
    let scalar = UnicodeScalar(letter.asciiValue ?? 0)
    guard CharacterSet.letters.contains(scalar) else { return }

    defer { // Check for the game-ending conditions
    }

    lettersGuessed.append(letter)

    // Check if the letter is in the current word
    guard currentLetters.contains(letter) else {
        badGuesses = badGuesses < 7 ? badGuesses + 1 : 7
        return
    }

    // It's a good guess, update the representation
    updateRepresentation(with: letter)
}

That was enough to get things started, so next I started building out the UI and setting up the view controller. I added a few labels, an imageView and a game controller, and setupViews() and updateViews():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let gameController = GameController()

@IBOutlet var scoreLabel: UILabel!
@IBOutlet var badGuessLabel: UILabel!
@IBOutlet var hangmanImageView: UIImageView!
@IBOutlet var lettersGuessedLabel: UILabel!

private func setupViews() {
    gameController.loadNewWord()
    updateViews()
}

private func updateViews() {
    title = gameController.currentRepresentation

    scoreLabel.text = "Score: \(gameController.score)"
    badGuessLabel.text = "Bad Guesses: \(gameController.badGuesses)"
    hangmanImageView.image = UIImage(named: "hangman-\(gameController.badGuesses)")
    lettersGuessedLabel.text = gameController.lettersGuessedRepresentation
}

Then, to give the user a way to input their guess, I added a button that presents an alert:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@IBAction func makeGuess(_ sender: Any) {
    presentGuessAlert()
}

private func presentGuessAlert() {
    let alertController = UIAlertController(title: "Make a guess",
                                            message: nil,
                                            preferredStyle: .alert)
    var guessTextField: UITextField!
    alertController.addTextField { (textField) in
        textField.placeholder = "Your guess"
        guessTextField = textField
    }

    let submitAction = UIAlertAction(title: "Submit",
                                     style: .default) { _ in
        guard let guess = guessTextField.text?.lowercased() else { return }
        self.submit(guess)
    }

    let cancelAction = UIAlertAction(title: "Cancel", style: .cancel)

    alertController.addAction(submitAction)
    alertController.addAction(cancelAction)

    present(alertController, animated: true)
}

And the submit method that loops through any letters and checks them individually:

1
2
3
4
5
6
private func submit(_ guess: String) {
    for letter in guess {
        gameController.checkGuess(letter)
        updateViews()
    }
}

With that, the game is playable, it just doesn’t end if you have too many bad guesses, or if you figure out the word. To account for that, I added checkForEndOfGame to GameController. It posts notifications for either of the end conditions and it updates the score in the case of a win:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private func checkForEndOfGame() {
    if badGuesses >= 7 {
        NotificationCenter.default.post(name: .gameOver, object: self)
        return
    }

    if !repArray.contains("_") {
        score += currentWord.count - badGuesses
        NotificationCenter.default.post(name: .levelWon, object: self)
    }
}

// In a separate extension
extension Notification.Name {
    static let gameOver = Notification.Name("GameOverNotification")
    static let levelWon = Notification.Name("LevelWonNotification")
}

Then, to handle those notifications, I added observers in setupViews of ViewController:

1
2
3
4
5
6
7
8
NotificationCenter.default.addObserver(self,
                                       selector: #selector(endOfGame),
                                       name: .gameOver,
                                       object: nil)
NotificationCenter.default.addObserver(self,
                                       selector: #selector(levelWon),
                                       name: .levelWon,
                                       object: nil)

And the functions that they call:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@objc private func endOfGame() {
    let word = gameController.currentWord
    let score = gameController.score
    let message = """
    You didn't beat the noose this time.
    We were looking for '\(word)'.
    Your score was: \(score)
    """
    presentInformationalAlert(title: "Game Over!",
                              message: message,
                              buttonTitle: "Try Again") { _ in
        self.gameController.resetGame()
        self.updateViews()
    }
}

@objc private func levelWon() {
    presentInformationalAlert(title: "You got it!",
                              message: "You live to see another day",
                              buttonTitle: "Next Level") { _ in
        self.gameController.loadNewWord()
        self.updateViews()
    }
}

And the helper function that they both call:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private func presentInformationalAlert(title: String,
                                       message: String,
                                       showCancel: Bool = false,
                                       buttonTitle: String = "Ok",
                                       buttonHandler: @escaping (UIAlertAction) -> Void = { _ in }) {
    let alertController = UIAlertController(title: title,
                                            message: message,
                                            preferredStyle: .alert)

    let action = UIAlertAction(title: buttonTitle,
                               style: .default,
                               handler: buttonHandler)
    alertController.addAction(action)

    if showCancel {
        let cancelAction = UIAlertAction(title: "Cancel",
                                         style: .default)
        alertController.addAction(cancelAction)
    }

    present(alertController, animated: true)
}

With that, I have a working version of the game hangman. After adding a few images I threw together in Illustrator, it looks like this:

Screenshots of working application.
More screenshots of working application.

You can find my version of this project at the end of day 41 on Github here.

Reflections

This was a fun one. I had some interesting challenges to think through, and I felt like I had a lot of room to make my own decisions. Almost like he was giving us plenty of rope, hoping that we wouldn’t accidentally hang ourselves (…forgive the pun). I feel like the solution I came up with is pretty robust, and fast. The only thing I can think of right now to improve the speed is to maybe combine looping through the letters in updating the rep array with checking to see if a letter is contained at all. I figure it isn’t a huge deal though because the longest English word is only something like 45 characters, so looping through twice is basically negligible for any modern computer.