Skip to content

Build a Swift Match Game — SpriteKit and SwiftUI Game Development

DodaTech Updated 2026-06-28 9 min read

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

  1. Blocking the main thread with game logic: Game updates must be fast. Offload heavy computation to background queues.

  2. Not handling rapid taps: Players can tap faster than the animation completes. Use isProcessing flag to ignore taps during match checking.

  3. Ignoring memory management in SpriteKit: Remove textures and nodes when no longer needed. Use SKTextureAtlas for sprite sheets.

  4. Hardcoding screen sizes: Use size.width and size.height to calculate card positions dynamically.

  5. Not saving state on app background: Pause the timer and save game state when the app goes to the background.

Practice Questions

  1. How does the isProcessing flag prevent rapid-tap bugs?
  2. Why use SpriteKit instead of SwiftUI animations for the cards?
  3. How would you add sound effects using SKAction?
  4. What is the purpose of the didSet Observer on cards?
  5. Challenge: Add a two-player mode where players take turns matching cards on the same device, with separate scores and a turn indicator.

FAQ

Should I use SpriteKit or SceneKit for a 2D game?

SpriteKit is designed for 2D games. SceneKit is for 3D. For the memory match game, SpriteKit is the right choice.

How do I add sound effects?

Use SKAction.playSoundFileNamed('tap.caf', waitForCompletion: false). Add CAF or WAV files to your app bundle.

Can I use SwiftUI for the game board instead of SpriteKit?

Yes, but flip animations and particle effects are smoother with SpriteKit. SwiftUI works well for menus and score screens.

How do I support Game Center leaderboards?

Import GameKit, authenticate the local player, and report scores using GKLeaderboard.submitScore().

How do I add accessibility support?

Add accessibility labels to each card node describing its position and state (face down or showing the emoji).

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