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.
Prerequisites
- Mac with Xcode 16 or later installed
- Apple Developer Program ($99/year) — required for TestFlight and App Store distribution
- Basic Swift and SwiftUI knowledge (structs, views, @State)
- No special hardware needed — the Simulator handles everything for a trivia game
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
- Missing PrivacyInfo.xcprivacy causes an immediate rejection. App Store Connect now auto-scans your binary for API usage and rejects builds that lack the manifest. Add it before your first TestFlight upload, not after.
- Storing answers as a
[String]in SwiftData requires iOS 17+. Transformable array properties were unreliable before iOS 17. If you test on iOS 16 devices, the app will crash on launch. State iOS 17+ as your minimum deployment target from day one. - Seeding the question bank only once. If you call
modelContext.insertevery launch, you'll end up with duplicate questions. Check for an existing record before inserting, or use a@AppStorageflag likedidSeedQuestions. - Ad SDK inflates binary size above 50 MB. Some ad SDKs (especially those with mediation adapters) push the download size past Apple's cellular download limit. Use only the adapters you actually need, or integrate ads post-launch once the core app is approved.
- Forgetting
NSUserTrackingUsageDescriptionwhen using AdMob. If your ad SDK calls ATT, the Info.plist key must be present and the string must describe usage clearly or your app will be rejected under guideline 5.1.2.
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.