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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
How to Make a Game Like Wordle in SwiftUI: Part One
35 mins
- Getting Started
- Guessing a Letter
- Displaying a Guessed Letter
- Making a Guess
- Showing the Guess
- Building the Game Logic
- Connecting the Model and App
- Checking a Guess
- Checking For In-Position Letters
- Checking Remaining Letters
- Updating the Game Status
- Building the Gameboard View
- Starting a New Game
- Where to Go From Here
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.