How to Build a Word Game App in SwiftUI
A word game app lets players unscramble anagrams, build words from a random set of tiles, and race against a timer — making it a natural fit for quick mobile sessions. This guide is aimed at iOS developers who know basic SwiftUI and want a polished, App Store-ready puzzle game with animations and persistent high scores.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge — you should be comfortable with
@State,List, and basic navigation - A plain-text word list (e.g. the MIT 10,000-word list) to bundle as a
.txtresource in your app target - Familiarity with SwiftData is helpful but not required — we cover the key concepts inline
Architecture overview
The app is structured around three layers. The data layer uses SwiftData to persist GameSession and HighScore records locally — no server required. The engine layer is a pure Swift PuzzleEngine that loads a dictionary at startup, generates daily or random anagram sets, validates guesses in O(1) with a Set<String>, and emits GameEvent values the UI observes. The view layer is pure SwiftUI: a LetterBoardView with tile animations, a guessed-words list, a timer bar, and a summary sheet — all driven by a single @Observable GameViewModel.
WordGame/ ├── App/ │ └── WordGameApp.swift # @main, modelContainer setup ├── Engine/ │ ├── PuzzleEngine.swift # Dictionary load, anagram gen, validate │ ├── PuzzleGenerator.swift # Seed-based daily puzzle picker │ └── WordList.txt # Bundled dictionary resource ├── Models/ │ ├── GameSession.swift # @Model — session record │ └── HighScore.swift # @Model — top scores ├── ViewModels/ │ └── GameViewModel.swift # @Observable game state ├── Views/ │ ├── HomeView.swift # Mode selector + leaderboard │ ├── GameView.swift # Main puzzle screen │ ├── LetterBoardView.swift # Shuffleable tile grid │ ├── GuessedWordsView.swift # Scrollable found-words list │ └── ResultSheetView.swift # End-of-round summary ├── Components/ │ └── TileView.swift # Single animated letter tile └── PrivacyInfo.xcprivacy # Required for App Store
Step-by-step
1. Project setup
Create a new Xcode project using the "App" template, select SwiftUI as the interface, and enable SwiftData as the storage option. Then add your WordList.txt to the app target so Bundle.main can load it at runtime.
// WordGameApp.swift
import SwiftUI
import SwiftData
@main
struct WordGameApp: App {
var body: some Scene {
WindowGroup {
HomeView()
.modelContainer(for: [GameSession.self, HighScore.self])
}
}
}
2. Data model with SwiftData
Define two @Model classes: GameSession captures the letters used, score, and duration for every round; HighScore stores the all-time top entries for the leaderboard.
// Models/GameSession.swift
import SwiftData
import Foundation
@Model
final class GameSession {
var id: UUID
var letters: String // e.g. "RAENTIG"
var foundWords: [String]
var score: Int
var duration: TimeInterval
var date: Date
init(letters: String) {
self.id = UUID()
self.letters = letters
self.foundWords = []
self.score = 0
self.duration = 0
self.date = .now
}
}
// Models/HighScore.swift
import SwiftData
@Model
final class HighScore {
var playerName: String
var score: Int
var letters: String
var date: Date
init(playerName: String, score: Int, letters: String) {
self.playerName = playerName
self.score = score
self.letters = letters
self.date = .now
}
}
3. Core UI — puzzle board
The board displays up to 8 shuffleable letter tiles in a wrapping LazyVGrid. Each TileView is a rounded rectangle with the letter centered; tapping one appends it to the current guess. A submit button and a shake-on-wrong-guess text field sit below the grid.
// Views/LetterBoardView.swift
import SwiftUI
struct LetterBoardView: View {
let tiles: [LetterTile]
let onTap: (LetterTile) -> Void
private let columns = Array(repeating: GridItem(.flexible(), spacing: 10), count: 4)
var body: some View {
LazyVGrid(columns: columns, spacing: 10) {
ForEach(tiles) { tile in
TileView(tile: tile)
.onTapGesture { onTap(tile) }
}
}
.padding()
.animation(.spring(duration: 0.35), value: tiles.map(\.id))
}
}
// Components/TileView.swift
struct LetterTile: Identifiable, Equatable {
let id: UUID
let letter: Character
var isUsed: Bool
}
struct TileView: View {
let tile: LetterTile
var body: some View {
Text(String(tile.letter))
.font(.title2.bold())
.frame(width: 68, height: 68)
.background(tile.isUsed ? Color.gray.opacity(0.3) : Color.indigo)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
.opacity(tile.isUsed ? 0.45 : 1)
.scaleEffect(tile.isUsed ? 0.92 : 1)
.animation(.spring(duration: 0.2), value: tile.isUsed)
}
}
#Preview {
LetterBoardView(
tiles: "ANAGRAM".map { LetterTile(id: UUID(), letter: $0, isUsed: false) },
onTap: { _ in }
)
}
4. Core feature — anagram engine
The PuzzleEngine loads the bundled word list once into a Set<String> for O(1) lookup, picks a source word of 7–8 letters to generate the puzzle, then validates guesses by checking that every character exists in the source letters and that the word is in the dictionary.
// Engine/PuzzleEngine.swift
import Foundation
struct PuzzleEngine {
private let dictionary: Set<String>
private let sourcePool: [String] // words 7-8 chars long
init() {
guard let url = Bundle.main.url(forResource: "WordList", withExtension: "txt"),
let raw = try? String(contentsOf: url, encoding: .utf8) else {
dictionary = []
sourcePool = []
return
}
let words = raw
.components(separatedBy: .newlines)
.map { $0.uppercased().trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
dictionary = Set(words)
sourcePool = words.filter { (7...8).contains($0.count) }
}
/// Pick a source word. Pass a seed for daily-puzzle determinism.
func pickSource(seed: Int? = nil) -> String {
guard !sourcePool.isEmpty else { return "STRANGE" }
let index = seed.map { abs($0) % sourcePool.count }
?? Int.random(in: 0.. Bool {
let upper = guess.uppercased()
guard dictionary.contains(upper), upper.count >= 3 else { return false }
var pool = availableLetters
for char in upper {
guard let idx = pool.firstIndex(of: char) else { return false }
pool.remove(at: idx)
}
return true
}
/// Score: length² × bonus for using all letters.
func score(for word: String, totalLetters: Int) -> Int {
let base = word.count * word.count
return word.count == totalLetters ? base * 3 : base
}
}
5. Animations and visual feedback
Use PhaseAnimator for the correct-word celebration and a custom ShakeEffect GeometryEffect for wrong guesses. These run entirely on the main thread with SwiftUI's animation engine — no UIKit needed.
// Components/ShakeEffect.swift
import SwiftUI
struct ShakeEffect: GeometryEffect {
var amount: CGFloat = 8
var shakesPerUnit: CGFloat = 3
var animatableData: CGFloat
func effectValue(size: CGSize) -> ProjectionTransform {
let translation = amount * sin(animatableData * .pi * shakesPerUnit)
return ProjectionTransform(CGAffineTransform(translationX: translation, y: 0))
}
}
// Usage in GameView.swift (excerpt)
struct GuessFieldView: View {
@Binding var currentGuess: String
@Binding var shakeCount: CGFloat
let onSubmit: () -> Void
var body: some View {
HStack(spacing: 12) {
Text(currentGuess.isEmpty ? "Tap letters…" : currentGuess)
.font(.title3.bold())
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
.modifier(ShakeEffect(animatableData: shakeCount))
Button("Submit", action: onSubmit)
.buttonStyle(.borderedProminent)
.disabled(currentGuess.count < 3)
}
.padding(.horizontal)
}
}
// Correct-word celebration with PhaseAnimator
struct CorrectWordBanner: View {
let word: String
var body: some View {
PhaseAnimator([0.0, 1.0, 0.0]) { phase in
Text("✓ \(word)")
.font(.headline.bold())
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.green.opacity(0.9), in: Capsule())
.scaleEffect(0.8 + phase * 0.2)
.opacity(phase == 0 ? 0 : 1)
} animation: { _ in .spring(duration: 0.4) }
}
}
#Preview {
CorrectWordBanner(word: "TANGLE")
}
6. Score tracking and persistence
The GameViewModel owns the timer, current session state, and SwiftData context. When the round ends it writes a GameSession and — if the score is a new personal best — a HighScore record. The leaderboard @Query in HomeView always reflects the latest stored data.
// ViewModels/GameViewModel.swift
import SwiftUI
import SwiftData
@Observable
final class GameViewModel {
var tiles: [LetterTile] = []
var currentGuess: String = ""
var foundWords: [String] = []
var score: Int = 0
var timeRemaining: Double = 90
var isGameOver: Bool = false
private let engine = PuzzleEngine()
private var sourceLetters: [Character] = []
private var timer: Timer?
func startGame(modelContext: ModelContext) {
let source = engine.pickSource()
sourceLetters = Array(source)
tiles = sourceLetters.enumerated().map { idx, ch in
LetterTile(id: UUID(), letter: ch, isUsed: false)
}
foundWords = []
score = 0
timeRemaining = 90
isGameOver = false
startTimer(modelContext: modelContext)
}
func submitGuess(modelContext: ModelContext) -> Bool {
let available = tiles.filter { !$0.isUsed }.map(\.letter)
guard !foundWords.contains(currentGuess),
engine.validate(guess: currentGuess, availableLetters: available) else {
currentGuess = ""
return false
}
foundWords.append(currentGuess)
score += engine.score(for: currentGuess, totalLetters: sourceLetters.count)
currentGuess = ""
return true
}
func shuffle() {
let unused = tiles.filter { !$0.isUsed }
let used = tiles.filter { $0.isUsed }
tiles = unused.shuffled() + used
}
private func startTimer(modelContext: ModelContext) {
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
guard let self else { return }
if self.timeRemaining > 0 {
self.timeRemaining -= 1
} else {
self.endGame(modelContext: modelContext)
}
}
}
private func endGame(modelContext: ModelContext) {
timer?.invalidate()
isGameOver = true
let session = GameSession(letters: String(sourceLetters))
session.foundWords = foundWords
session.score = score
session.duration = 90 - timeRemaining
modelContext.insert(session)
try? modelContext.save()
}
}
// HomeView leaderboard query (excerpt)
struct LeaderboardSection: View {
@Query(sort: \HighScore.score, order: .reverse) var scores: [HighScore]
var body: some View {
List(scores.prefix(10)) { entry in
HStack {
Text(entry.playerName)
Spacer()
Text("\(entry.score) pts").bold()
}
}
}
}
#Preview {
LeaderboardSection()
.modelContainer(for: HighScore.self, inMemory: true)
}
7. Privacy Manifest — required for App Store
Apple requires a PrivacyInfo.xcprivacy file in every new app submission. Add it via File › New › File › App Privacy in Xcode. For a local-only word game with no analytics SDK you typically only need to declare NSPrivacyAccessedAPICategoryUserDefaults if you use UserDefaults.
<!-- PrivacyInfo.xcprivacy (Property List source view) -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
</array>
</dict>
</plist>
// If you add an ad SDK (e.g. Google AdMob), that SDK ships its
// own PrivacyInfo.xcprivacy — Xcode merges them automatically.
Common pitfalls
- Dictionary loaded on the main thread. Parsing a 10 k-word file synchronously blocks your first frame. Load it on a background actor or wrap the init in a
Task { await ... }and show a loading state until it resolves. - Mutable
@Modelinside aForEach. SwiftData model objects are reference types butForEachneeds stable identity. Always use.idon the persistent identifier, not on a computed property that can change mid-animation. - PhaseAnimator triggering on every view update. Drive it with a dedicated
Booltrigger that you toggle only on correct guesses — not on the general@Observablestate that changes every keystroke. - App Store review: missing privacy nutrition labels for ad SDK. If you add an ad network after your initial submission, you must update your data-collection declarations in App Store Connect before resubmitting, or review will reject the binary for "missing privacy manifest entries."
- Word list licensing. Some word lists (e.g. ENABLE, SOWPODS) have redistribution restrictions. Use a clearly open-licensed source (MIT word list, Moby Words) or your own curated list and document it in your app's legal notices.
Adding monetization: Ad-supported
The most straightforward path for a free word game is banner or interstitial ads via Google AdMob or Apple's SKAdNetwork-compliant alternatives. Integrate the AdMob iOS SDK via Swift Package Manager, then show a rewarded interstitial between rounds using GADRewardedInterstitialAd — this lets players earn an extra-hint power-up in exchange for watching an ad, which improves eCPM compared to forced interstitials. Wrap the ad presentation in a thin AdCoordinator class so your SwiftUI views never import the SDK directly; this makes it trivial to swap networks later. Remember to set GADApplicationIdentifier in Info.plist and add your AdMob app ID before first launch, otherwise the SDK crashes on startup. As a complement, consider a $1.99 "Remove Ads" one-time purchase via StoreKit 2's Product.purchase() API — it converts well on puzzle games and gives power users a clean experience.
Shipping this faster with Soarias
Soarias automates the most tedious parts of this build: it scaffolds the full SwiftData model layer from your schema description, generates a ready-to-sign PrivacyInfo.xcprivacy tailored to the APIs your code actually uses, configures fastlane with gym and deliver lanes pre-wired to your App Store Connect team, and produces the 6.7-inch and 5.5-inch screenshots App Store Connect requires — all from a single prompt in Claude Code.
For an intermediate-complexity app like this one, the scaffolding and submission plumbing typically consume two to three days of the estimated one-week timeline. With Soarias handling that overhead, most developers finish their first TestFlight build in an afternoon and submit to App Store review by day three — leaving the remaining time for polish, playtesting, and tuning the ad placement strategy.
Related guides
FAQ
Does this work on iOS 16?
Not as written. SwiftData, @Observable, and PhaseAnimator all require iOS 17+. If you need iOS 16 support, replace SwiftData with Core Data, swap @Observable for @ObservableObject/@Published, and replace PhaseAnimator with a manual @State-driven animation. That said, as of 2026 iOS 17+ represents the vast majority of active devices — targeting iOS 17 minimum is the pragmatic choice.
Do I need a paid Apple Developer account to test?
No — you can run the app on a personal device for free using a personal team signing certificate in Xcode. However, free certificates expire every 7 days and must be re-signed. For TestFlight distribution and App Store submission you need the $99/year Apple Developer Program membership. SwiftData and all other frameworks used here work without a paid account during development.
How do I add this to the App Store?
Archive the app in Xcode (Product › Archive), then upload via the Organizer window or xcrun altool. In App Store Connect create a new app record, fill in the name, bundle ID, category (Games › Word), age rating, and upload your screenshots. Submit for review — Apple's first review typically takes 24–48 hours for a new app. Soarias can drive the entire submission flow from the command line using its fastlane integration.
How do I generate a new daily puzzle without a server?
Use a deterministic seed derived from the current date. Convert today's date to an integer (e.g. Int(Date.now.timeIntervalSince1970 / 86400)) and pass it to PuzzleEngine.pickSource(seed:). Every player on the same day gets the same letters, enabling social sharing ("Today's puzzle: STRANGE — score 148") with zero backend infrastructure.
Last reviewed: 2026-05-12 by the Soarias team.