Home iOS & Swift Tutorials

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.

Version

  • Swift 5.5, iOS 15, Xcode 13

Though it’s been around since the fall of 2021, the web game Wordle became popular in early 2022. For a time, you could barely scroll through social media without seeing your friend’s daily puzzle results. The simple rules make the game easy to pick up, and the variety of English words keeps the game challenging.

In case you missed or chose to ignore the game, the rules of Wordle are simple. Each day, the game presents a random five-letter English word for you to guess. You get six guesses to find the word, and all guesses must be valid English words. After each guess, the game evaluates each letter. It then tells you whether each letter isn’t in the word at all, is in the word but incorrectly placed or is correctly placed in position. If you get the word right in six guesses, you win. If not, Wordle tells you the correct word.

In this tutorial, you’ll design and implement a version of Wordle in SwiftUI called Guess the Word.

Getting Started

Download the project by clicking Download Materials at the top or bottom of this page. Open the project in the starter folder in Xcode. Have a look at the views, models and resources, then build and run. You’ll interleave building the model for the game with views that show the player these elements. Time to get to work!

Starting project with just the keyboard view

Guessing a Letter

The game consists of a game board and a keyboard used to enter letters. It also provides “Delete” and “Return” keys the player uses to confirm their guess. You can find the keyboard implemented in the KeyboardView.swift and KeyButtonView.swift files in the KeyboardViews group.

Each game shows the current and past guesses. After the user confirms a guess by tapping Enter, the game checks the guess against the target word. In this app, the Dictionary class loads a dictionary of five-letter words and provides access to it for the program.

First, you’ll implement the basic element of the game, the guessed letter. Open GuessedLetter.swift in the Models group. From the description, you can see you need a way to reflect the status of each letter after the player submits a guess. Above the declaration of the GuessedLetter struct, add the following code:

enum LetterStatus: String {
  case unknown = "Unknown"
  case notInWord = "Not in Word"
  case notInPosition = "In Word, But Not This Position"
  case inPosition = "Correct and In Position"
}

This enumeration contains a state for each possible outcome for a letter, including the initial unknown state before the player submits the guess. You provide a string for each state to describe the result.

Now, replace the contents of the GuessedLetter struct with:

var id = UUID()
var letter: String
var status: LetterStatus = .unknown

This struct contains the letter the player guesses and the status of the guess, which defaults to unknown.

Diagram of the items contained in the struct

Now that you have a struct to hold a guessed letter, you can update the view to show this letter, which you’ll do in the next section.

Displaying a Guessed Letter

Just like you started with the data model for the simplest element of the game, a GuessedLetter, you’ll now work on its related view. Open GuessBoxView.swift in the GameBoardViews group. This view shows the letters that make up each guess. It will show the player the letter and its status in an overall guess. Add the following properties at the top of the struct:

var letter: GuessedLetter
var size: Double
var index: Int

These hold the GuessedLetter to show along with a size for the view and the zero-based index of which guess you show. Now, replace the current placeholder view with:

Text(letter.letter)
  .font(.title)
  .frame(width: size, height: size)
  .cornerRadius(size / 5.0)

Next, replace the preview with:

let guess = GuessedLetter(letter: "S", status: .inPosition)
GuessBoxView(letter: guess, size: 50, index: 1)

Activate the preview and see the result.

Screen showing the letter S

This letter is from a checked guess so it should provide feedback to the player on the status of this guess. You’ll do that by adding color to the tile. First, open GuessedLetter.swift under the Models group and add the following computed property to the GuessedLetter struct:

var statusColor: Color {
  switch status {
  case .unknown:
    return .primary
  case .notInWord:
    return .gray
  case .notInPosition:
    return .yellow
  case .inPosition:
    return .green
  }
}

This method returns a Color based on the current status. An unknown letter uses the current primary color. A letter not in the word appears in gray, a letter in the wrong position appears in yellow and a letter in the correct position appears in green. Now, go back to GuessBoxView.swift and replace the body of the view with:

Text(letter.letter)
  .font(.title)
  // 1
  .foregroundColor(Color(UIColor.systemBackground))
  // 2
  .frame(width: size, height: size)
  // 3
  .background(letter.statusColor)
  // 4
  .cornerRadius(size / 5.0)

You show the letter for the guess in a Text view with the title font style. Then, you modify the Text view by:

  1. Setting the foreground color to the UIColor.systemBackground. This makes the text the same color as the current background color of the view.
  2. Setting the width and height of the view to the passed value.
  3. Setting the background color for the view to the color determined by the statusColor computed property you added in the previous step.
  4. Adding a corner radius to give the letter tile a nice, rounded appearance.

After being checked, the result clearly shows the letter and status of the guessed letter.

Screen showing the letter S in a green box

With the view to show the letters of each guess implemented, you can now move on to the player’s guess. You’ll do that in the next section.

Making a Guess

Just like each letter has a status, the guess as a whole also has a status. Open Guess.swift in the Models group and add the following above the Guess struct:

enum GuessStatus {
  case pending
  case complete
  case invalidWord
}

Until the player submits the guess, it will be pending. If the player doesn’t submit a valid word, it will be invalidWord. Once a valid word is checked, the guess is complete. Now, add the following code to the Guess struct:

var word: [GuessedLetter] = []
var status: GuessStatus = .pending

var letters: String {
  return word.reduce("") { partialResult, nextLetter in
    partialResult.appending(nextLetter.letter)
  }
}

A guess consists of an array of GuessedLetter structs that hold the guessed letters. You also store a status that defaults to pending. The letters computed property provides access to the letters in the guess as a string. The property uses the reduce method on the word array to concatenate each letter into a single string and return this value.

Diagram showing string of guessed letters

With the Guess implemented, you can now turn your attention to the view that displays it.

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.

Checking a Guess

Checking a guess will be more complicated, so you'll create the method in several steps. This method will:

  1. Verify the guess is complete and valid.
  2. Check the guess for letters that are in the correct position.
  3. Check if the remaining letters are not in the word or in the wrong position.
  4. Update the game status based on the results.

Add the following new method after deleteLetter():

func checkGuess() {
  // 1
  guard guesses[currentGuess].word.count == wordLength  else { return }

  // 2
  if !dictionary.isValidWord(guesses[currentGuess].letters) {
    guesses[currentGuess].status = .invalidWord
    return
  }
}

In this initial code:

  1. You ensure the guess has exactly five characters. If not, you'll return immediately, which ignores the player's action.
  2. You then use the Dictionary object to check the word against a longer list of words. If it's not present, you set the status of the guess to invalidWord and return.

Checking For In-Position Letters

At this point, you know you have a legitimate guess to validate.

To process the result of each letter of the guess, add the following code to the end of the checkGuess() method:

// 1
guesses[currentGuess].status = .complete
// 2
var targetLettersRemaining = Array(targetWord)
// 3
for index in guesses[currentGuess].word.indices {
  // 4
  let stringIndex = targetWord.index(targetWord.startIndex, offsetBy: index)
  let letterAtIndex = String(targetWord[stringIndex])
  // 5
  if letterAtIndex == guesses[currentGuess].word[index].letter {
    // 6
    guesses[currentGuess].word[index].status = .inPosition
    // 7
    if let letterIndex = 
      targetLettersRemaining.firstIndex(of: Character(letterAtIndex)) {
      targetLettersRemaining.remove(at: letterIndex)
    }
  }
}

There's a lot here, but each step isn't complicated:

  1. First, you mark the guess as complete.
  2. Next, you create an array from the characters that make up the target word. You use this array to better handle situations where a target word contains the same letter multiple times. Take the target word THEME, for example. The E appears twice. How then should you evaluate a word with three Es like EERIE? The convention you'll use is to show the final E as green because it is in the correct position, the first E as notInPosition and the second E as notInWord because there are only two Es and you've accounted for both when you reach that position. This shows the player had one E in the correct position and the word contains only one more E.
  3. You loop through all indexes in the word property of the current guess using the indices property on that object.
  4. For each letter, you get the letter in the target word at the same index position. You might think to pass the Integer index as a subscript to the string, but that won't work. Instead, you need a String.Index value as the subscript. The first line gets the String.Index that corresponds to the index integer offset in the string. You can then use this as a subscript to the string to get the letter you desire, casting it to a String in the process. If you think this seems more complicated than it should be, you're right.
  5. You compare the letter you carefully extracted in the previous step to the guessed letter at the current index.
  6. If they match, you set the status of the guessed letter of the current guess to inPosition.
  7. When the letters match, you also get the first index of letterAtIndex, after casting it to a Character, in the array of characters and unwrap it as letterIndex. If the value exists, which it always should, you then remove the letter from the targetLettersRemaining array.

Example showing the words THEME and EERIE

Checking for in-position letters first ensures they have priority over not-in-position letters in the guess.

Checking Remaining Letters

Now, you can check for letters that are in the word but not in the correct position. Add the following code to the end of the checkGuess() method:

// 1
for index in guesses[currentGuess].word.indices
  .filter({ guesses[currentGuess].word[$0].status == .unknown }) {
  // 2
  let letterAtIndex = guesses[currentGuess].word[index].letter
  // 3
  var letterStatus = LetterStatus.notInWord
  // 4
  if targetWord.contains(letterAtIndex) {
    // 5
    if let guessedLetterIndex =
      targetLettersRemaining.firstIndex(of: Character(letterAtIndex)) {
      letterStatus = .notInPosition
      targetLettersRemaining.remove(at: guessedLetterIndex)
    }
  }
  // 6
  guesses[currentGuess].word[index].status = letterStatus
}

There's a lot here:

  1. Again, you loop through the indices for the word, but you use the filter method on the array to get only the ones still in an unknown status. You don't want to check the ones you found to be in the correct position in the previous section of code again.
  2. For each index position, you get the letter for that position of the guess.
  3. You set a variable of LetterStatus to notInWord.
  4. Because you only care if the guessed letter appears in the target word, you can use the contains() method of the string to see if the letter appears anywhere in the word.
  5. As in the previous code block, you get the index of letterAtIndex in the targetLettersRemaining array. This time, there's no guarantee the letter will be there because you've removed some letters from the target word. If a value is found, you change the letterStatus variable to notInPosition and remove the element from the targetLettersRemaining array. Removing the letter from targetLettersRemaining means you will only mark the same numbers of letters as either inPosition or notInPosition as in the target word if more are guessed.
  6. You set the status of this GuessedLetter to the value of the letterStatus variable, which will still be notInWord unless changed in step five.

Updating the Game Status

After evaluating each letter in the guess, you can now check if the user guessed the word. Add the following code to the end of the method:

if targetWord == guesses[currentGuess].letters {
  status = .won
  return
}

If the guess is the same as the target word, you set the game status to won and return. If not, you now handle the cases where the guess was wrong. Add the following code to the method:

if currentGuess < maxGuesses - 1 {
  guesses.append(Guess())
  currentGuess += 1
} else {
  status = .lost
}

If the current guess is less than the number of allowed guesses, you append a new blank guess to the guesses array and add one to the current guess. If not, the player did not guess the word in time, so you set the game status to lost.

To use this new logic, add the following code above the default case in addKey(letter:):

case ">":
  checkGuess()

This calls the checkGuess() method when the player taps the Enter button. To connect the keyboard view and model, open KeyButtonView.swift in the KeyboardViews group. Look for the line // Button action and replace it with:

game.addKey(letter: key)

A separate KeyButtonView is created for each key on the virtual keyboard. When the player taps the button, the view calls addKey(letter:) in the game object, passing in the letter for that key.

Now, you can start integrating this game logic with your UI. First, though, select the entire extension method at the bottom of GuessingGame.swift. Now, use the Editor ▸ Structure ▸ Comment Selection menu command to uncomment it in a single step.

The extension contains a convenience initializer that allows you to provide the target word and several static methods that will produce a game in a partial, won and lost state. Looking at these, you see they set a known target word using the convenience initializer and then use the addKey(letter:) method to simulate a player. You'll use this for the SwiftUI previews. Now, get ready to play in the next section!

Building the Gameboard View

Open GameBoardView.swift in the GameBoardViews group and add the following computed property above the body of the view:

var unusedGuesses: Int {
  let remainingGuesses = game.maxGuesses - game.guesses.count
  if remainingGuesses < 0 {
    return 0
  }
  return remainingGuesses
}

This property returns the number of guesses remaining for the current game. It subtracts the current guesses from the maximum number of guesses. It also checks to ensure this number isn't negative.

Now, replace the placeholder Text view in the body (don't remove the sheet(isPresented:onDismiss:content:) or padding(_:)) with:

VStack {
  // 1
  ForEach($game.guesses) { guess in
    // 2
    CurrentGuessView(guess: guess, wordLength: game.wordLength)
  }
  // 3
  ForEach(0..<unusedGuesses, id: \.self) { _ in
    // 4
    CurrentGuessView(guess: .constant(Guess()), wordLength: game.wordLength)
  }
}

You'll create a VStack view that:

  1. First loops through the existing guesses. You use the ability added in SwiftUI with iOS 15 to loop through a binding. Doing so provides a binding to the closure.
  2. Displays each guess using the CurrentGuessView you completed in the last section, passing in the binding to the current guess and the length of the word.
  3. Afterward, displays empty guesses to fill the game board and visually inform the player how many guesses remain. You use the unusedGuesses property you added earlier to determine the number needed, then display an empty Guess object for each one.

Update the preview to:

GameBoardView(game: GuessingGame.inProgressGame())

Look at the live preview for this view to see how the finished game board comes together.

Finished game board preview

Run your app and try playing a game. You'll see all the tiles show green when you guess the word. You'll also notice there's no way to start a new game. You'll add that in the next section.

Starting a New Game

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

func newGame() {
  let totalWords = dictionary.commonWords.count
  let randomWord = Int.random(in: 0..<totalWords)
  targetWord = dictionary.commonWords[randomWord]
  print("Selected word: \(targetWord)")

  currentGuess = 0
  guesses = []
  guesses.append(Guess())
  status = .new
}

It should look familiar because this performs the same tasks you did in the init() method you created earlier in this tutorial. Now, open ActionBarView.swift in the GameBoardViews and add a reference to the game after the existing property:

@ObservedObject var game: GuessingGame

Then, go down to the action of the button and replace the comment // New game action with a call to the newGame() method:

game.newGame()

Also, change the disabled() modifier to:

.disabled(game.status == .inprogress || game.status == .new)

Finally, update the preview body to:

ActionBarView(
  showStats: .constant(false),
  game: GuessingGame.inProgressGame()
)

Now, go back to ContentView.swift and update ActionBarView to add the new game parameter:

ActionBarView(
  showStats: $showStats,
  game: game
)

Run the app to see the final version. When you finish a game, you can click the button to start a new one.

Finished game screen

Where to Go From Here

You can click the Download Materials button at the top and bottom of this tutorial to download the starter and final projects.

You have a functioning Wordle clone, but it lacks the polish of a good app. That's why in part two, you'll expand the app to include animation, better accessibility, statistics and sharing. You'll also update the app to maintain the game state when entering the background.

If you'd like to see more Wordle-related projects, check out WORDLES OF THE WORLD UNITE.

For some perspective on creating a more graphically oriented game, see Unity Tutorial: How to Make a Game Like Space Invaders.

See you in part two.

Contributors

Comments

Reviews

More like this