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

Learn how to create your own Wordle word-game clone in SwiftUI. Understand game logic as you build an onscreen keyboard and letter tile game board. By Bill Morefield.

5 (5) · 4 Reviews

Download materials
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

Showing the Guess

Open CurrentGuessView.swift in the GameBoardViews group. This view displays the letters for each guess by the player. Add the following properties at the top of the struct:

@Binding var guess: Guess
var wordLength: Int

var unguessedLetters: Int {
  wordLength - guess.word.count
}

These properties hold the guess to display and the number of letters in each guess, wordLength. The unguessedLetters computed property provides the current number of letters in the target word that the user has not yet correctly guessed.

Next, replace the body of the view with:

// 1
GeometryReader { proxy in
  HStack {
    Spacer()
    // 2
    let width = (proxy.size.width - 40) / 5 * 0.8
    // 3
    ForEach(guess.word.indices, id: \.self) { index in
      // 4
      let letter = guess.word[index]
      GuessBoxView(letter: letter, size: width, index: index)
    }
    // 5
    ForEach(0..<unguessedLetters, id: \.self) { _ in
      EmptyBoxView(size: width)
    }
    Spacer()
  }
  .padding(5.0)
}

Here's what you're doing in this view:

  1. You wrap the view with a GeometryReader for access to the size through the proxy parameter passed to the closure.
  2. You calculate a width for each letter in the guess based off the width of the view. These values were calculated by eye to work across the range of iOS devices.
  3. Next, you loop through each letter in the guess. Note that because the number of elements in the guess.word array will change as the user adds more guessed letters, you must explicitly specify the id parameter.
  4. For each letter, you extract the GuessedLetter object and then pass that to the view along with the width calculated in step two and the current index.
  5. For any letters not guessed, you use the EmptyBoxView to show an empty box to the player.

Next, add the following code after the padding modifier:

.overlay(
  Group {
    if guess.status == .invalidWord {
      Text("Word not in dictionary.")
        .foregroundColor(.red)
        .background(Color(UIColor.systemBackground).opacity(0.8))
        .onAppear {
          DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
            guess.status = .pending
          }
        }
    }
  }
)

If the guess status becomes invalidWord, this code will overlay a view over the guess informing the player of this fact. You use the onAppear modifier on this overlaid view to set the status back to pending after two seconds. The user can then delete and change their guess. Now, replace the preview body with:

let guessedLetter = GuessedLetter(letter: "S", status: .inPosition)
let guessedLetter2 = GuessedLetter(letter: "A", status: .notInPosition)
let guess = Guess(
  word: [guessedLetter, guessedLetter2],
  status: .pending
)
CurrentGuessView(
  guess: .constant(guess),
  wordLength: 5
)

This contrives a first guess-in-progress showing a correctly placed S followed by an A that's in the word but not in the right position.

Activate the preview to have a look.

Game preview showing one correctly placed letter and one incorrectly placed letter

With the Guess and GuessedLetter complete, you can turn your attention to the game logic.

Building the Game Logic

Open GuessingGame.swift in the Models group. You see an empty class that implements ObservableObject along with a commented-out extension you'll look at later. Again, you'll start with the set of states for the game. Add the following code before the class declaration:

enum GameState {
  case initializing
  case new
  case inprogress
  case won
  case lost
}

You again use an enum to define the possible states of the game. Now, add the following code to the class:

// 1
let wordLength = 5
let maxGuesses = 6
// 2
var dictionary: Dictionary
// 3
var status: GameState = .initializing
// 4
@Published var targetWord: String
@Published var currentGuess = 0
@Published var guesses: [Guess]

Here's what's going on above:
1. The wordLength and maxGuesses properties build on the previous sections to form the game's core. You define the length of the word and the maximum number of guesses as constants. Doing so allows you to change the values more easily and better document the meaning behind these numbers when used in code.
2. You create a Dictionary object to pick target words and validate guesses.
3. The status property tracks the state of the game with the type GameState, which you just declared above.
4. These three properties are marked with the @Published property wrapper. This property wrapper combined with the class implementing ObservableObject means SwiftUI automatically reloads any related views when one of these properties changes. The targetWord property keeps track of the word the player needs to guess. You use currentGuess to track which guess the player is on. The app stores those individual guesses in guesses, an array of the Guess struct you implemented earlier.

Now, add the following custom initializer for the class after these properties.

init() {
  // 1
  dictionary = Dictionary(length: wordLength)
  // 2
  let totalWords = dictionary.commonWords.count
  let randomWord = Int.random(in: 0..<totalWords)
  let word = dictionary.commonWords[randomWord]
  // 3
  targetWord = word
  #if DEBUG
  print("selected word: \(word)")
  #endif
  // 4
  guesses = .init()
  guesses.append(Guess())
  status = .new
}

This code sets up a new game by doing the following:

  1. First, you create a Dictionary object, passing the desired word length.
  2. Next, you count the number of words in the common words list of the dictionary. Then, you select a random integer between zero and that number. You store the word at that position in the word variable.
  3. You set the targetWord property for the class to the word picked in step three. If debugging, you print the word to the console to ease testing and debugging.
  4. To finish setup, you initialize the guesses property with an empty array and add a single empty Guess object. Finally, you mark the status to reflect a "new" game is ready to play.

You now have a model of the game state. In the next section, you'll add the connection between the model and the outside world of your user interface.

Connecting the Model and App

The primary data entry mechanism for the player comes from the KeyboardView. Open KeyboardView.swift in the KeyboardViews group. When you look at the view, you see the keyboard consists of the 26 letters in the English alphabet along with two special keys: the Backspace key, represented by < in this keyboard, which lets the player correct a mistaken tap; and the Return key, represented by >, which the player taps to submit a guess. In this article, tapping one of these buttons will be referred to as tapping a key.

The keyboard property of the KeyboardView defines the order and layout of the keyboard, with each row separated by the pipe (|) character. You can change the layout of the keyboard by changing this string. Just make sure not to lose any letters along the way.

Onscreen keyboard

You need to update the game whenever the player taps a key. Ideally, the keyboard and game should assume nothing about each other except for this interface. This concept, known as loose coupling, makes it easier to change, test and modify your code. Here, you'll implement a method in the GuessingGame class that each button in the keyboard then calls, passing its letter. The keyboard only knows to call a method, and the model only knows it should handle the new letter.

Open GuessingGame.swift and add the following method to the end of the GuessingGame class:

func addKey(letter: String) {
  // 1
  if status == .new {
    status = .inprogress
  }
  // 2
  guard status == .inprogress else {
    return
  }

  // 3
  switch letter {
  default:
    // 4
    if guesses[currentGuess].word.count < wordLength {
      let newLetter = GuessedLetter(letter: letter)
      guesses[currentGuess].word.append(newLetter)
    }
  }
}

Here's what each step does:

  1. The game starts in the new state. As soon as the player taps any key, you change the state to inprogress.
  2. If the game isn't in the inprogress state, you ignore the input.
  3. You'll use a switch statement with a case for each letter and handle letter characters under the default case. For now, you'll temporarily ignore the special cases of the < and > characters.
  4. For a letter, first check that the current number of letters in the guess is less than the wordLength defined earlier. If so, then you create a new GuessedLetter object for the tapped letter and then append it to the current guess.

Now, you can address the two special keys. First, add the following method to handle the delete key after the addKey(letter:) method:

func deleteLetter() {
  let currentLetters = guesses[currentGuess].word.count
  guard currentLetters > 0 else { return }
  guesses[currentGuess].word.remove(at: currentLetters - 1)
}

This method gets the number of letters in the current guess. If there are zero letters, it returns without doing anything. Otherwise, it deletes the last letter in the guess. You remove the guess at currentLetters - 1 because arrays are zero-based (the first element is zero) whereas currentLetters returns a count that starts at one.

Add the following code above the default case in addKey(letter:):

  case "<":
  deleteLetter()

When the user taps the delete key, represented by <, you call the new method. In the next section, you'll deal with the player submitting a guess.