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!
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
.
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.
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:
- Setting the foreground color to the
UIColor.systemBackground
. This makes the text the same color as the current background color of the view. - Setting the width and height of the view to the passed value.
- Setting the background color for the view to the color determined by the
statusColor
computed property you added in the previous step. - 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.
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.
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:
- You wrap the view with a
GeometryReader
for access to the size through theproxy
parameter passed to the closure. - 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.
- 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 theid
parameter. - 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. - 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.
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:
- First, you create a
Dictionary
object, passing the desired word length. - 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. - 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. - To finish setup, you initialize the
guesses
property with an empty array and add a single emptyGuess
object. Finally, you mark thestatus
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.
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:
- The game starts in the
new
state. As soon as the player taps any key, you change the state toinprogress
. - If the game isn't in the
inprogress
state, you ignore the input. - 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. - 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 newGuessedLetter
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:
- Verify the guess is complete and valid.
- Check the guess for letters that are in the correct position.
- Check if the remaining letters are not in the word or in the wrong position.
- 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:
- You ensure the guess has exactly five characters. If not, you'll return immediately, which ignores the player's action.
- 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 toinvalidWord
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:
- First, you mark the guess as complete.
- 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 asnotInWord
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. - You loop through all indexes in the
word
property of the current guess using theindices
property on that object. - 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 aString.Index
value as the subscript. The first line gets theString.Index
that corresponds to theindex
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 aString
in the process. If you think this seems more complicated than it should be, you're right. - You compare the letter you carefully extracted in the previous step to the guessed letter at the current index.
- If they match, you set the status of the guessed letter of the current guess to
inPosition
. - 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 asletterIndex
. If the value exists, which it always should, you then remove the letter from thetargetLettersRemaining
array.
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:
- 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 anunknown
status. You don't want to check the ones you found to be in the correct position in the previous section of code again. - For each index position, you get the letter for that position of the guess.
- You set a variable of
LetterStatus
tonotInWord
. - 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. - As in the previous code block, you get the index of
letterAtIndex
in thetargetLettersRemaining
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 theletterStatus
variable tonotInPosition
and remove the element from thetargetLettersRemaining
array. Removing the letter fromtargetLettersRemaining
means you will only mark the same numbers of letters as eitherinPosition
ornotInPosition
as in the target word if more are guessed. - You set the status of this
GuessedLetter
to the value of theletterStatus
variable, which will still benotInWord
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:
- 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.
- 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. - 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 emptyGuess
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.
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.
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.
Comments