```html How to Build a Word Game App in SwiftUI (2026)

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.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Intermediate Estimated time: 1 week Updated: May 12, 2026

Prerequisites

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

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.

```