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

How to Build a Trivia Game App in SwiftUI

A Trivia Game app presents categorised multiple-choice questions, tracks scores, and rewards streaks — perfect for indie developers who want a quick, shippable game without a game engine. This guide targets beginners who know basic Swift and want a finished product on the App Store within a couple of weekends.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Beginner Estimated time: 1–2 weekends Updated: May 12, 2026

Prerequisites

Architecture overview

The app uses a thin MVVM structure. SwiftData persists Question and Category models and stores the player's high scores. A single GameViewModel (marked @Observable) owns the active game state: current question index, selected answer, score, and streak. Views are purely presentational — CategoryPickerView, QuestionCardView, and ResultsView — with no business logic. There are no network calls in the core loop; questions ship bundled in a JSON file that SwiftData imports on first launch. Ad integration (Google AdMob or Apple's SKAdNetwork-compatible banner) sits in a wrapper view injected at the root.

TriviaApp/
├── TriviaApp.swift            # @main, modelContainer setup
├── Models/
│   ├── Category.swift         # @Model — id, name, icon
│   └── Question.swift         # @Model — text, answers, correctIndex, categoryId
├── ViewModels/
│   └── GameViewModel.swift    # @Observable — game state, scoring
├── Views/
│   ├── ContentView.swift      # Root navigation
│   ├── CategoryPickerView.swift
│   ├── QuestionCardView.swift # flip animation lives here
│   └── ResultsView.swift
├── Resources/
│   └── questions.json         # Bundled question bank
└── PrivacyInfo.xcprivacy      # Required for App Store
      

Step-by-step

1. Project setup

Create a new Xcode project using the App template, set the interface to SwiftUI, and enable SwiftData storage. Name it TriviaApp with a bundle ID you own (e.g. com.yourname.triviaapp).

// TriviaApp.swift
import SwiftUI
import SwiftData

@main
struct TriviaApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Category.self, Question.self, HighScore.self])
    }
}

2. Data model with SwiftData

Define the three @Model classes SwiftData will persist. Keep Question flat — store answers as a [String] and point to a category ID rather than a relationship, which keeps queries simple for a beginner project.

// Models/Category.swift
import SwiftData

@Model
final class Category {
    var id: String
    var name: String
    var icon: String          // SF Symbol name

    init(id: String, name: String, icon: String) {
        self.id = id; self.name = name; self.icon = icon
    }
}

// Models/Question.swift
@Model
final class Question {
    var id: String
    var text: String
    var answers: [String]     // 4 choices
    var correctIndex: Int
    var categoryId: String

    init(id: String, text: String, answers: [String],
         correctIndex: Int, categoryId: String) {
        self.id = id; self.text = text; self.answers = answers
        self.correctIndex = correctIndex; self.categoryId = categoryId
    }
}

// Models/HighScore.swift
@Model
final class HighScore {
    var categoryId: String
    var score: Int
    var date: Date

    init(categoryId: String, score: Int, date: Date = .now) {
        self.categoryId = categoryId; self.score = score; self.date = date
    }
}

3. Core UI — category picker

The entry screen shows a grid of category cards. Tapping one starts a game session. Use LazyVGrid so it scales gracefully to any screen size.

// Views/CategoryPickerView.swift
import SwiftUI
import SwiftData

struct CategoryPickerView: View {
    @Query private var categories: [Category]
    @State private var selectedCategory: Category?

    private let columns = [GridItem(.adaptive(minimum: 150), spacing: 16)]

    var body: some View {
        NavigationStack {
            ScrollView {
                LazyVGrid(columns: columns, spacing: 16) {
                    ForEach(categories) { cat in
                        Button {
                            selectedCategory = cat
                        } label: {
                            VStack(spacing: 10) {
                                Image(systemName: cat.icon)
                                    .font(.system(size: 36))
                                Text(cat.name)
                                    .font(.headline)
                            }
                            .frame(maxWidth: .infinity, minHeight: 110)
                            .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
                        }
                        .buttonStyle(.plain)
                    }
                }
                .padding()
            }
            .navigationTitle("Choose a Category")
            .navigationDestination(item: $selectedCategory) { cat in
                QuestionCardView(categoryId: cat.id)
            }
        }
    }
}

#Preview {
    CategoryPickerView()
        .modelContainer(for: [Category.self, Question.self, HighScore.self],
                        inMemory: true)
}

4. Core feature — question bank with categories and answer animations

This is the heart of the app: fetch questions for the selected category, present them one at a time, validate the answer with a colour-flash animation, and advance automatically after a short delay. The card flip uses a 3D rotation3DEffect.

// ViewModels/GameViewModel.swift
import SwiftUI
import Observation

@Observable
final class GameViewModel {
    private(set) var questions: [Question] = []
    private(set) var currentIndex: Int = 0
    private(set) var score: Int = 0
    private(set) var selectedAnswerIndex: Int? = nil
    private(set) var isFinished: Bool = false

    var currentQuestion: Question? { questions[safe: currentIndex] }
    var progress: Double {
        questions.isEmpty ? 0 : Double(currentIndex) / Double(questions.count)
    }

    func load(questions: [Question]) {
        self.questions = questions.shuffled()
        currentIndex = 0; score = 0; isFinished = false; selectedAnswerIndex = nil
    }

    func select(answerIndex: Int) {
        guard selectedAnswerIndex == nil, let q = currentQuestion else { return }
        selectedAnswerIndex = answerIndex
        if answerIndex == q.correctIndex { score += 1 }
    }

    func advance() {
        if currentIndex + 1 >= questions.count {
            isFinished = true
        } else {
            currentIndex += 1
            selectedAnswerIndex = nil
        }
    }
}

private extension Array {
    subscript(safe index: Int) -> Element? {
        indices.contains(index) ? self[index] : nil
    }
}

// Views/QuestionCardView.swift
import SwiftUI
import SwiftData

struct QuestionCardView: View {
    let categoryId: String

    @Query private var allQuestions: [Question]
    @State private var vm = GameViewModel()
    @State private var cardRotation: Double = 0
    @Environment(\.modelContext) private var ctx

    private var categoryQuestions: [Question] {
        allQuestions.filter { $0.categoryId == categoryId }
    }

    var body: some View {
        Group {
            if vm.isFinished {
                ResultsView(score: vm.score, total: categoryQuestions.count,
                            categoryId: categoryId)
            } else if let question = vm.currentQuestion {
                VStack(spacing: 24) {
                    ProgressView(value: vm.progress)
                        .padding(.horizontal)

                    Text("Q\(vm.currentIndex + 1) of \(categoryQuestions.count)")
                        .font(.caption).foregroundStyle(.secondary)

                    // Card with flip animation
                    Text(question.text)
                        .font(.title3.weight(.semibold))
                        .multilineTextAlignment(.center)
                        .padding()
                        .frame(maxWidth: .infinity, minHeight: 120)
                        .background(.ultraThinMaterial,
                                    in: RoundedRectangle(cornerRadius: 20))
                        .rotation3DEffect(.degrees(cardRotation), axis: (0, 1, 0))
                        .padding(.horizontal)

                    VStack(spacing: 12) {
                        ForEach(Array(question.answers.enumerated()), id: \.offset) { idx, answer in
                            AnswerButton(
                                text: answer,
                                state: buttonState(for: idx, correct: question.correctIndex)
                            ) {
                                vm.select(answerIndex: idx)
                                withAnimation(.easeInOut(duration: 0.4)) {
                                    cardRotation = 360
                                }
                                DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
                                    cardRotation = 0
                                    vm.advance()
                                }
                            }
                        }
                    }
                    .padding(.horizontal)
                }
            }
        }
        .navigationTitle("Trivia")
        .navigationBarTitleDisplayMode(.inline)
        .onAppear { vm.load(questions: categoryQuestions) }
    }

    private func buttonState(for index: Int, correct: Int) -> AnswerButton.State {
        guard let selected = vm.selectedAnswerIndex else { return .idle }
        if index == correct { return .correct }
        if index == selected { return .wrong }
        return .idle
    }
}

struct AnswerButton: View {
    enum State { case idle, correct, wrong }
    let text: String
    let state: State
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            Text(text)
                .font(.body.weight(.medium))
                .frame(maxWidth: .infinity, minHeight: 52)
                .background(backgroundColor, in: RoundedRectangle(cornerRadius: 14))
                .foregroundStyle(state == .idle ? .primary : .white)
        }
        .buttonStyle(.plain)
        .disabled(state != .idle)
        .animation(.easeInOut(duration: 0.25), value: state)
    }

    private var backgroundColor: Color {
        switch state {
        case .idle:    return Color(.systemGray6)
        case .correct: return .green
        case .wrong:   return .red
        }
    }
}

#Preview {
    NavigationStack {
        QuestionCardView(categoryId: "science")
    }
    .modelContainer(for: [Question.self, HighScore.self], inMemory: true)
}

5. Persistence, results screen, and Privacy Manifest

Persist the high score when a game finishes, show results with a confetti-style animation, and add the PrivacyInfo.xcprivacy manifest — this file is mandatory for App Store submissions. Without it, App Store Connect will reject your binary.

// Views/ResultsView.swift
import SwiftUI
import SwiftData

struct ResultsView: View {
    let score: Int
    let total: Int
    let categoryId: String

    @Environment(\.modelContext) private var ctx
    @State private var saved = false

    var body: some View {
        VStack(spacing: 28) {
            Image(systemName: score > total / 2 ? "star.fill" : "hand.thumbsup")
                .font(.system(size: 64))
                .foregroundStyle(.yellow)
                .symbolEffect(.bounce, value: saved)

            Text("\(score) / \(total)")
                .font(.system(size: 52, weight: .black, design: .rounded))

            Text(score == total ? "Perfect score!" :
                 score > total / 2 ? "Well done!" : "Keep practising!")
                .font(.title2)
                .foregroundStyle(.secondary)
        }
        .navigationTitle("Results")
        .onAppear { saveHighScore() }
    }

    private func saveHighScore() {
        guard !saved else { return }
        ctx.insert(HighScore(categoryId: categoryId, score: score))
        try? ctx.save()
        saved = true
    }
}

// PrivacyInfo.xcprivacy  (add via File > New > File… > Privacy Manifest)
// Set NSPrivacyTracking = NO
// NSPrivacyTrackingDomains = [] (add AdMob domain if using ads)
// NSPrivacyCollectedDataTypes = [] if no data collected
// NSPrivacyAccessedAPITypes — add if you use UserDefaults, FileTimestamp, etc.
// Xcode 16 will warn you about missing entries at build time.

Common pitfalls

Adding monetization: Ad-supported

The simplest ad integration for a trivia game is a banner ad shown on the ResultsView and an interstitial between category sessions. Use Google AdMob via Swift Package Manager (https://github.com/googleads/swift-package-manager-google-mobile-ads) — add GADBannerView wrapped in a UIViewRepresentable to the bottom of ResultsView. Before showing any personalised ads on iOS 14.5+, request App Tracking Transparency permission with ATTrackingManager.requestTrackingAuthorization; AdMob will automatically serve non-personalised ads to users who decline. Add GADApplicationIdentifier to Info.plist and register your AdMob app ID before submitting to App Store Connect — missing this key causes a runtime crash on first launch that the App Store review team will flag immediately.

Shipping this faster with Soarias

Soarias automates the tedious parts of this project that have nothing to do with writing trivia questions. When you describe your app concept, Soarias scaffolds the full Xcode project — SwiftData models, folder structure, and the PrivacyInfo.xcprivacy manifest pre-filled based on the APIs your code uses. It also configures fastlane Matchfile and Fastfile for code signing, generates App Store screenshots in every required device size, and handles the App Store Connect metadata upload so you're not copy-pasting descriptions into a web form.

For a beginner project at this complexity level, the scaffolding and submission setup typically takes 2–4 hours of manual work. With Soarias, those steps compress to a few minutes of answering prompts — leaving your weekend free to write better questions and tune the animations rather than wrestling with provisioning profiles.

Related guides

FAQ

Does this work on iOS 16?

No. The guide uses SwiftData, which requires iOS 17 as a minimum deployment target. If you need iOS 16 support you would need to replace SwiftData with Core Data or a simple JSON file persisted to the Documents directory — a significant extra step that isn't worth it given iOS 16's declining install base in 2026.

Do I need a paid Apple Developer account to test?

No — you can run the app on your own iPhone via Xcode with a free Apple ID for personal testing. However, the $99/year Apple Developer Program membership is required the moment you want to distribute via TestFlight or submit to the App Store. Sign up at developer.apple.com before you're ready to ship so the account activation doesn't delay your launch.

How do I add this to the App Store?

Archive the app in Xcode (Product → Archive), then use the Organizer to upload to App Store Connect. In App Store Connect, fill in the app's name, description, keywords, and upload at least one screenshot per required device size (iPhone 6.9", iPhone 6.5", and iPad 12.9" if you support iPad). Submit for review — beginner apps typically clear review in 24–48 hours. Soarias handles the upload and metadata steps for you if you want to skip the manual form-filling.

How do I add more questions without a full app update?

Bundle your questions in a JSON file hosted on a server and fetch them at launch using URLSession, caching the result with SwiftData. This way you can add new categories or correct mistakes without going through App Store review. For a beginner project, ship with bundled questions first and add remote fetching once the app is live and you understand what your players want.

Last reviewed: 2026-05-12 by the Soarias team.

```