How to Make a Game Like Wordle in SwiftUI: Part Two

Extend your Wordle word-game clone with animation, accessibility, statistics and shareable results, all in SwiftUI. By Bill Morefield.

Leave a rating/review
Download materials
Save for later
Share

In part one, you created models for the game elements and combined views based on these models into a functional Wordle clone called Guess The Word.

In part two, you’ll continue to add features and polish to the app by:

  • Displaying the player’s results.
  • Adding animation to the player’s guesses.
  • Improving accessibility with feedback on the keyboard.
  • Allowing the player to save and share their results, as well as displaying statistics.

Getting Started

You can pick up this tutorial from where you ended part one or access the starter project by clicking the Download Materials button at the top or bottom of this tutorial. Run the app, and you’ll find you can play the game and attempt to guess the word.

Wordle clone screen showing two incorrect guesses and then the correctly guessed word

While the game works, it’s a bit unspectacular. The only indication that the player won comes in the line of green boxes, and a lost game provides no feedback. In the next section, you’ll give the player better feedback on their result.

Showing the Player’s Result

Open ContentView.swift and add the following new property after the game property:

@State private var showResults = false

You’ll use this Boolean to trigger a view showing the final result when a game ends. Add the following code just before the frame(width:height:alignment:) modifier:

.sheet(isPresented: $showResults) {
  GameResultView(game: game)
}

This will show a property sheet with GameResultView(game:) when showResults becomes true. To set the property, add the following code immediately after what you just added:

// 1
.onChange(of: game.status) { newStatus in
  // 2
  if newStatus == .won || newStatus == .lost {
    // 3
    DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
      showResults = true
    }
  }
}

Here’s how this code handles the state change:

  1. The onChange(of:perform:) method executes the code inside the closure when game.status changes. The new value will be passed into the closure as newStatus.
  2. If newStatus is either won or lost, this means the game ended so it’s time to show the results.
  3. Using DispatchQueue.main.asyncAfter(deadline:qos:flags:execute:) allows you to execute the code inside the method’s closure after a delay. Here, you wait 1.5 seconds before changing the state. This allows the player to see the result on the game board before showing the sheet.

Next, you’ll update the view the player sees when a game ends. Open GameResultView.swift in the GameBoardViews group and change the body to:

VStack {
  if game.status == .won {
    Text("You got it!")
      .font(.title)
      .foregroundColor(.green)
  } else {
    Text("Sorry you didn't get the word in \(game.maxGuesses) guesses.")
      .font(.title2)
      .foregroundColor(.red)
  }
  Text("The word was \(game.targetWord).")
    .font(.title2)
}

Also, update your preview to show both the win and loss states:

Group {
  GameResultView(
    game: GuessingGame.wonGame()
  )
  GameResultView(
    game: GuessingGame.lostGame()
  )
}

This view shows a congratulatory message if the player wins and a condolence message if they lose. Either way, the player sees the target word. Build and run the app and play a few games to see the new view in action. Remember the app shows the target word on the console when debugging.

Wordle clone screen animation showing congratulatory message upon a successful guess

Now that you have a better view telling the player their result, you’ll add some feedback to the player on the keyboard in the next section.

Colorizing the Keyboard

To set the background color of the keys to reflect the known status of letters, open GuessingGame.swift in the Models group and add the following code to the end of the class:

func statusForLetter(letter: String) -> LetterStatus {
  // 1
  if letter == "<" || letter == ">" {
    return .unknown
  }

  // 2
  let finishedGuesses = guesses.filter { $0.status == .complete }
  // 3
  let guessedLetters =
    finishedGuesses.reduce([LetterStatus]()) { partialResult, guess in
    // 4
    let guessStatuses = 
      guess.word.filter { $0.letter == letter }.map { $0.status }
    // 5
    var currentStatuses = partialResult
    currentStatuses.append(contentsOf: guessStatuses)
    return currentStatuses
  }

  // 6
  if guessedLetters.contains(.inPosition) {
    return .inPosition
  }
  if guessedLetters.contains(.notInPosition) {
    return .notInPosition
  }
  if guessedLetters.contains(.notInWord) {
    return .notInWord
  }

  return .unknown
}

This method returns a letter’s status as follows:

For each pass, you filter any GuessedLetters for the letter in question. You then map the status property of any matching GuessedLetters into an array.

  1. The special keys always return unknown.
  2. You only need to check completed guesses.
  3. You use the reduce method on finishedGuesses to build an array of LetterStatus enums for the letter.
  4. The reduce method loops through the array with the current result in partialResult and the current letter from the array as guess.
  5. For each pass, you filter any GuessedLetters for the letter in question. You then map the status property of any matching GuessedLetters into an array.

  6. Since the partialResult passed to the closure is nonmutable, you create a copy and append the results from step four to it. You send this array to the next step.
  7. Now guessedLetters contains an array with the status of the letter for any guess using the letter in question. You check if the array contains a status in order of preference. For example, if in one guess, E was in the correct position and in another guess, E is not in the correct position, the method will return inPosition since you check for that first.

Next, add the following code to the end of the class:

func colorForKey(key: String) -> Color {
  let status = statusForLetter(letter: key)

  switch status {
  case .unknown:
    return Color(UIColor.systemBackground)
  case .inPosition:
    return Color.green
  case .notInPosition:
    return Color.yellow
  case .notInWord:
    return Color.gray.opacity(0.67)
  }
}

This returns a color for each status corresponding to those used in the GuessedLetter struct. Open KeyboardView.swift in the KeyboardViews group and add the following code after the KeyButtonView view:

.background(
  game.colorForKey(key: key)
)

Build and run to see how your keyboard now reflects the status of each letter by color, helping the player prepare better guesses.

Wordle clone screen showing colorized keyboard to indicate letter guess status

Next, you’ll jazz things up with some animation.

Adding Animation

When the player submits a guess, the letter status changes appear instantly. An animation would help draw the player’s attention and add a little drama to the reveal. Open GuessBoxView.swift and add the following code as the last modifier after cornerRadius(_:antialiased:):

// 1
.rotation3DEffect(
  // 2
  .degrees(letter.status == .unknown ? 0 : 180),
  // 3
  axis: (x: 0.0, y: 1.0, z: 0.0)
)
// 4
.animation(
  .linear(duration: 1.0).delay(0.1 * Double(index)),
  value: letter.status
)

This code will give the guessed letters a flip animation that cascades from left to right across the view. This is what the code does:

  1. You use rotation3DEffect(_:axis:anchor:anchorZ:perspective:), a more flexible version of the more common rotationEffect(_:anchor:) modifier, letting you specify the axis of rotation.
  2. The rotation angle depends on the letter’s status. The letter will rotate 180 degrees when its status changes to something other than unknown.
  3. You specify a rotation axis using unit coordinates. This specifies the rotation should occur around the y axis running vertically down the center of the view.
  4. The animation modifier tells SwiftUI to animate any state change caused when letter.status changes. You use a linear animation that moves at constant speed between the two states and takes one second to complete. You also add a delay before the animation, which increases as index does. This delay makes the animation for the first guess occur before the second, which occurs before the third, etc.

Build and run the app, then enter a guess. Notice how the letters look backwards when the animation completes. The rotation affects the entire view, including the text. To fix the problem, add the following modifier directly after the Text view, before any other modifiers:

.rotation3DEffect(
  .degrees(letter.status == .unknown ? 0 : -180),
  axis: (x: 0.0, y: 1.0, z: 0.0)
)

This initial rotation balances the one applied later so the letters appear to flip but end up facing the correct direction. Build and run to try it out.

Wordle clone screen showing flipping letter tile animation