Build a Swift Match Game — SpriteKit and SwiftUI Game Development
In this tutorial, you will learn about Build a Swift Match Game. We cover key concepts, practical examples, and best practices to help you master this topic.
Build a complete memory match card game in Swift using SpriteKit for animations and SwiftUI for menus, implementing a state machine for game flow, scoring system, timer, and optional Game Center integration.
What You'll Build
- A memory match card game with SpriteKit
- Card flip animations with Core Animation
- Game state machine (menu, playing, paused, won)
- Scoring system with time and move tracking
- High scores persisted with UserDefaults
- Multiple difficulty levels
Why It Matters
Game Development exercises every iOS skill: animation, state management, gesture handling, timers, audio, and persistence. The memory match game is simple enough to build in a few hours but deep enough to teach real game architecture.
Real-World Use
The patterns used here — state machines, animation pipelines, score tracking, difficulty scaling — are the same patterns used in professional mobile games from Candy Crush to Monument Valley. The SpriteKit animation techniques apply to any 2D game.
Learning Path
flowchart LR A[Project: Networking Library
Lesson 35] --> B[Project: Game
You are here] B --> C[Project: CLI Tool
Lesson 37] B --> D[Server-Side Swift
Lesson 38] style B fill:#f90,color:#fff
Project Setup
Create a new iOS app in Xcode. Add import SpriteKit and import GameplayKit as needed.
Game Model
import Foundation
enum Difficulty: Int, CaseIterable {
case easy = 8
case medium = 16
case hard = 24
var cardCount: Int { rawValue }
var columns: Int {
switch self {
case .easy: return 4
case .medium: return 4
case .hard: return 6
}
}
var rows: Int { cardCount / columns }
var timeLimit: TimeInterval {
switch self {
case .easy: return 60
case .medium: return 120
case .hard: return 180
}
}
}
struct Card: Identifiable, Equatable {
let id: Int
let emoji: String
var isFlipped = false
var isMatched = false
static func == (lhs: Card, rhs: Card) -> Bool {
lhs.id == rhs.id
}
}
struct GameState {
var cards: [Card]
var firstSelectedIndex: Int?
var secondSelectedIndex: Int?
var moves = 0
var matchedPairs = 0
var totalPairs: Int
var score = 0
var timeRemaining: TimeInterval
var isPlaying = false
var isGameOver = false
var didWin = false
var allCardsMatched: Bool {
matchedPairs == totalPairs
}
static func create(difficulty: Difficulty) -> GameState {
let emojis = ["🍎", "🍊", "🍋", "🍇", "🍓", "🍑", "🍒", "🥝",
"🌮", "🍕", "🍔", "🌭", "🧁", "🍩", "🍪", "🎂",
"⚽️", "🏀", "🏈", "🎾", "🎱", "🏓", "🥊", "🚀"]
let selectedEmojis = Array(emojis.shuffled().prefix(difficulty.cardCount / 2))
var cards: [Card] = []
for (index, emoji) in selectedEmojis.enumerated() {
cards.append(Card(id: index * 2, emoji: emoji))
cards.append(Card(id: index * 2 + 1, emoji: emoji))
}
cards.shuffle()
return GameState(
cards: cards,
totalPairs: difficulty.cardCount / 2,
timeRemaining: difficulty.timeLimit
)
}
}
Game Scene
import SpriteKit
import SwiftUI
class GameScene: SKScene {
private var gameState: GameState
private var cardNodes: [SKSpriteNode] = []
private var scoreLabel: SKLabelNode!
private var timeLabel: SKLabelNode!
private var timer: Timer?
private var isProcessing = false
var onGameOver: ((GameState) -> Void)?
init(size: CGSize, difficulty: Difficulty) {
self.gameState = GameState.create(difficulty: difficulty)
super.init(size: size)
}
required init?(coder aDecoder: NSCoder) { nil }
override func didMove(to view: SKView) {
backgroundColor = .systemBackground
createUI()
createCards()
startTimer()
}
private func createUI() {
scoreLabel = SKLabelNode(text: "Moves: 0")
scoreLabel.position = CGPoint(x: size.width - 100, y: size.height - 60)
scoreLabel.fontSize = 18
addChild(scoreLabel)
timeLabel = SKLabelNode(text: formatTime(gameState.timeRemaining))
timeLabel.position = CGPoint(x: 100, y: size.height - 60)
timeLabel.fontSize = 18
addChild(timeLabel)
}
private func createCards() {
let margin: CGFloat = 8
let totalSpacing = margin * CGFloat(gameState.cards.count / 2 + 1)
let cardWidth = (size.width - totalSpacing) / CGFloat(gameState.cards.count / 2)
let cardHeight: CGFloat = cardWidth * 1.3
let rows = gameState.cards.count / (gameState.cards.count / 2)
let startY = (size.height - CGFloat(rows) * (cardHeight + margin)) / 2
for (index, card) in gameState.cards.enumerated() {
let column = index % (gameState.cards.count / 2)
let row = index / (gameState.cards.count / 2)
let x = margin + CGFloat(column) * (cardWidth + margin) + cardWidth / 2
let y = startY + CGFloat(rows - 1 - row) * (cardHeight + margin) + cardHeight / 2
let node = createCardNode(card: card, size: CGSize(width: cardWidth, height: cardHeight))
node.position = CGPoint(x: x, y: y)
node.name = "card_\(index)"
addChild(node)
cardNodes.append(node)
}
}
private func createCardNode(card: Card, size: CGSize) -> SKSpriteNode {
let node = SKSpriteNode(color: .systemBlue, size: size)
node.cornerRadius = 8
let label = SKLabelNode(text: card.isFlipped ? card.emoji : "")
label.fontSize = size.width * 0.5
label.verticalAlignmentMode = .center
label.name = "label"
node.addChild(label)
return node
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard !isProcessing, gameState.isPlaying, !gameState.isGameOver else { return }
guard let touch = touches.first else { return }
let location = touch.location(in: self)
guard let node = atPoint(location) as? SKSpriteNode,
let name = node.name,
let index = Int(name.replacingOccurrences(of: "card_", with: "")) else { return }
handleCardTap(at: index)
}
private func handleCardTap(at index: Int) {
guard !gameState.cards[index].isFlipped,
!gameState.cards[index].isMatched else { return }
if let first = gameState.firstSelectedIndex {
gameState.secondSelectedIndex = index
flipCard(at: index)
isProcessing = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
self.checkMatch()
}
} else {
gameState.firstSelectedIndex = index
flipCard(at: index)
}
}
private func flipCard(at index: Int) {
gameState.cards[index].isFlipped = true
let node = cardNodes[index]
let label = node.childNode(withName: "label") as? SKLabelNode
let flipAction = SKAction.sequence([
SKAction.scaleX(to: 0, duration: 0.15),
SKAction.run { label?.text = self.gameState.cards[index].emoji },
SKAction.scaleX(to: 1, duration: 0.15)
])
node.run(flipAction)
}
private func checkMatch() {
guard let first = gameState.firstSelectedIndex,
let second = gameState.secondSelectedIndex else { return }
gameState.moves += 1
updateScoreLabel()
let card1 = gameState.cards[first]
let card2 = gameState.cards[second]
if card1.emoji == card2.emoji {
gameState.cards[first].isMatched = true
gameState.cards[second].isMatched = true
gameState.matchedPairs += 1
gameState.score += 100
matchAnimation(at: first)
matchAnimation(at: second)
if gameState.allCardsMatched {
gameWon()
}
} else {
flipBack(card1, at: first)
flipBack(card2, at: second)
}
gameState.firstSelectedIndex = nil
gameState.secondSelectedIndex = nil
isProcessing = false
}
private func flipBack(_ card: Card, at index: Int) {
let node = cardNodes[index]
let label = node.childNode(withName: "label") as? SKLabelNode
let flipBack = SKAction.sequence([
SKAction.scaleX(to: 0, duration: 0.15),
SKAction.run { label?.text = "" },
SKAction.scaleX(to: 1, duration: 0.15)
])
node.run(flipBack)
gameState.cards[index].isFlipped = false
}
private func matchAnimation(at index: Int) {
let node = cardNodes[index]
let pulse = SKAction.sequence([
SKAction.scale(to: 1.1, duration: 0.1),
SKAction.scale(to: 1.0, duration: 0.1),
SKAction.fadeOut(withDuration: 0.3)
])
node.run(pulse)
}
private func gameWon() {
gameState.isGameOver = true
gameState.didWin = true
timer?.invalidate()
onGameOver?(gameState)
}
private func startTimer() {
gameState.isPlaying = true
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
guard let self = self else { return }
self.gameState.timeRemaining -= 1
self.timeLabel.text = self.formatTime(self.gameState.timeRemaining)
if self.gameState.timeRemaining <= 0 {
self.timer?.invalidate()
self.gameState.isGameOver = true
self.onGameOver?(self.gameState)
}
}
}
private func updateScoreLabel() {
scoreLabel.text = "Moves: \(gameState.moves)"
}
private func formatTime(_ time: TimeInterval) -> String {
let minutes = Int(time) / 60
let seconds = Int(time) % 60
return String(format: "%d:%02d", minutes, seconds)
}
}
SwiftUI Menu and Integration
import SwiftUI
import SpriteKit
struct GameMenuView: View {
@State private var selectedDifficulty: Difficulty = .easy
var body: some View {
NavigationStack {
VStack(spacing: 30) {
Image(systemName: "brain.head.profile")
.font(.system(size: 60))
.foregroundColor(.blue)
Text("Memory Match")
.font(.largeTitle)
.fontWeight(.bold)
Text("Match all pairs to win!")
.foregroundColor(.secondary)
Picker("Difficulty", selection: $selectedDifficulty) {
ForEach(Difficulty.allCases, id: \.self) { d in
Text("\(d.rawValue) cards").tag(d)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
NavigationLink {
GameView(difficulty: selectedDifficulty)
} label: {
Label("Start Game", systemImage: "play.fill")
.font(.title2)
.padding(.horizontal, 40)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
NavigationLink {
HighScoresView()
} label: {
Label("High Scores", systemImage: "trophy")
}
.buttonStyle(.bordered)
}
.padding()
.navigationTitle("Memory Match")
}
}
}
struct GameView: View {
let difficulty: Difficulty
@Environment(\.dismiss) private var dismiss
@State private var showResult = false
@State private var finalState: GameState?
var body: some View {
SpriteView(scene: createScene())
.ignoresSafeArea()
.navigationBarBackButtonHidden()
.sheet(isPresented: $showResult) {
if let state = finalState {
GameResultView(state: state, difficulty: difficulty)
}
}
}
func createScene() -> GameScene {
let scene = GameScene(size: CGSize(width: 390, height: 844), difficulty: difficulty)
scene.onGameOver = { state in
finalState = state
showResult = true
}
scene.scaleMode = .aspectFill
return scene
}
}
struct GameResultView: View {
let state: GameState
let difficulty: Difficulty
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 16) {
Image(systemName: state.didWin ? "star.fill" : "clock.badge.exclamationmark")
.font(.system(size: 60))
.foregroundColor(state.didWin ? .yellow : .orange)
Text(state.didWin ? "Congratulations!" : "Time's Up!")
.font(.largeTitle)
if state.didWin {
Text("You matched all pairs!")
.foregroundColor(.secondary)
}
VStack(spacing: 8) {
Label("Score: \(state.score)", systemImage: "star")
Label("Moves: \(state.moves)", systemImage: "hand.tap")
Label("Difficulty: \(difficulty.rawValue) cards", systemImage: "grid")
}
.padding()
HStack(spacing: 16) {
Button("Play Again") {
dismiss()
}
.buttonStyle(.borderedProminent)
Button("Menu") {
dismiss()
}
.buttonStyle(.bordered)
}
}
.padding()
.onAppear {
saveHighScore()
}
}
func saveHighScore() {
var scores = UserDefaults.standard.array(forKey: "highScores_\(difficulty.rawValue)") as? [Int] ?? []
scores.append(state.score)
scores.sort(by: >)
scores = Array(scores.prefix(10))
UserDefaults.standard.set(scores, forKey: "highScores_\(difficulty.rawValue)")
}
}
struct HighScoresView: View {
var body: some View {
List {
ForEach(Difficulty.allCases, id: \.self) { difficulty in
Section("\(difficulty.rawValue) Cards") {
let scores = UserDefaults.standard.array(forKey: "highScores_\(difficulty.rawValue)") as? [Int] ?? []
if scores.isEmpty {
Text("No scores yet").foregroundColor(.secondary)
}
ForEach(Array(scores.enumerated()), id: \.offset) { index, score in
HStack {
Text("#\(index + 1)").foregroundColor(.secondary)
Text("\(score) points")
Spacer()
if index == 0 { Image(systemName: "trophy.fill").foregroundColor(.yellow) }
}
}
}
}
}
.navigationTitle("High Scores")
}
}
Key Takeaways
- SpriteKit handles smooth card flip animations
- State machine pattern keeps game logic organized
- Timer and scoring create engaging gameplay
- SwiftUI and SpriteKit integrate seamlessly
- Difficulty scaling makes the game replayable
Common Mistakes
Blocking the main thread with game logic: Game updates must be fast. Offload heavy computation to background queues.
Not handling rapid taps: Players can tap faster than the animation completes. Use
isProcessingflag to ignore taps during match checking.Ignoring memory management in SpriteKit: Remove textures and nodes when no longer needed. Use SKTextureAtlas for sprite sheets.
Hardcoding screen sizes: Use
size.widthandsize.heightto calculate card positions dynamically.Not saving state on app background: Pause the timer and save game state when the app goes to the background.
Practice Questions
- How does the
isProcessingflag prevent rapid-tap bugs? - Why use SpriteKit instead of SwiftUI animations for the cards?
- How would you add sound effects using SKAction?
- What is the purpose of the
didSetObserver on cards? - Challenge: Add a two-player mode where players take turns matching cards on the same device, with separate scores and a turn indicator.
FAQ
What's Next
Build a Project: CLI Tool using Swift Argument Parser, or explore Server-Side Swift with Vapor.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro