The monorepo from Version, Archive and Tag Each App in a Swift Monorepo ships two list apps that share packages, a Makefile, and one release process. A third app shows whether that structure holds up for something quite different. SpriteKit, Apple’s framework for 2D games, draws a scene of shapes and images many times a second, moves them with a physics engine, and reports when two of them touch. It comes with iOS and Xcode; there is nothing to install.

This article adds Catcher to the monorepo: a one-screen arcade game where balls fall from the top of the screen and you drag a paddle to catch them. Each catch scores a point, each ball that reaches the bottom costs one of three lives, balls arrive faster as the score rises, and after the third miss the game ends until you tap to play again. You finish by playing it on the simulator. The rules live in a new CatcherKit package with Swift Testing tests that run on the Mac without a scene, and the best score is kept by a type added to the shared CoreKit package. Catcher gets its own .xcconfig file, icon, shared scheme and Makefile entry, so make test covers it and make release APP=Catcher archives and tags it as Catcher/v1.0.0. A launch argument makes the paddle play by itself, which is how a screenshot can show the game scoring without a finger on the screen.

What you end up with

  • Packages/CatcherKit, the game’s rules (score, lives, game over, how often balls appear) and their tests.
  • BestScore in CoreKit, a best score kept across launches, with tests.
  • Apps/Catcher, an iOS app whose SpriteKit scene is hosted in SwiftUI by SpriteView, with a score overlay drawn in SwiftUI.
  • Catcher in the Makefile’s app and package lists, with its own version, display name, icon, shared scheme and first release tag.

Prerequisites

  • The finished my-apps folder from Version, Archive and Tag Each App in a Swift Monorepo, with its Git history and the TodoList/v1.0.1 and Shopping/v1.1.1 tags, and the TodoList-iPhone simulator.
  • Xcode 27 or later on a Mac with Apple silicon. Validated on Xcode 27.1 (build 27A9275) with Swift 6.4, on macOS 27.0.1.
  • No SpriteKit experience; each part is explained where it is used. The Swift in this article assumes you have followed the series, including @State and @Observable from Build Your First SwiftUI App on macOS.
  • No Apple Developer account.
  • Steps 3, 4 and 5 are partly done in Xcode; a command after each part checks the result.

Run every command from inside my-apps.

Step 1: Write the game’s rules in a package

A game mixes two kinds of code: rules (what a catch is worth, when the game ends) and drawing (where the paddle is this frame). Rules written into the scene can only be checked by playing. Kept in a package of their own, they are plain Swift values that swift test checks on the Mac in milliseconds, the same split the series made for the to-do model in TodoKit. Create the package and delete the two template files:

mkdir Packages/CatcherKit
cd Packages/CatcherKit
swift package init --type library
cd ../..
rm Packages/CatcherKit/Sources/CatcherKit/CatcherKit.swift Packages/CatcherKit/Tests/CatcherKitTests/CatcherKitTests.swift

Create the file

touch Packages/CatcherKit/Package.swift

The file exists already. Replace its contents with the listing below.

Add the code: Packages/CatcherKit/Package.swift

// swift-tools-version: 6.4
import PackageDescription

let package = Package(
    name: "CatcherKit",
    platforms: [
        .iOS(.v17),
        .macOS(.v14),
    ],
    products: [
        .library(name: "CatcherKit", targets: ["CatcherKit"]),
    ],
    targets: [
        .target(
            name: "CatcherKit",
            swiftSettings: [
                .enableUpcomingFeature("ApproachableConcurrency"),
            ]
        ),
        .testTarget(
            name: "CatcherKitTests",
            dependencies: ["CatcherKit"],
            swiftSettings: [
                .enableUpcomingFeature("ApproachableConcurrency"),
            ]
        ),
    ]
)

Create the file

touch Packages/CatcherKit/Sources/CatcherKit/CatcherGame.swift

Add the code: Packages/CatcherKit/Sources/CatcherKit/CatcherGame.swift

/// The rules of Catcher, kept apart from SpriteKit so they can be tested
/// without a scene: the score, the lives left, and how often balls appear.
public struct CatcherGame: Equatable, Sendable {
    public static let startingLives = 3

    public private(set) var score = 0
    public private(set) var lives = CatcherGame.startingLives

    public init() {}

    public var isOver: Bool { lives == 0 }

    /// A ball reached the paddle.
    public mutating func caught() {
        guard !isOver else { return }
        score += 1
    }

    /// A ball reached the bottom of the screen.
    public mutating func missed() {
        guard !isOver else { return }
        lives -= 1
    }

    /// Seconds between new balls: 1.2 at the start, 0.05 less for each
    /// point scored, and never less than 0.4.
    public var spawnInterval: Double {
        max(0.4, 1.2 - 0.05 * Double(score))
    }
}

Create the file

touch Packages/CatcherKit/Tests/CatcherKitTests/CatcherGameTests.swift

Add the code: Packages/CatcherKit/Tests/CatcherKitTests/CatcherGameTests.swift

import Testing
import CatcherKit

struct CatcherGameTests {
    @Test func catchingScores() {
        var game = CatcherGame()
        game.caught()
        game.caught()
        #expect(game.score == 2)
        #expect(game.lives == CatcherGame.startingLives)
    }

    @Test func missingCostsALifeAndEndsTheGameAtZero() {
        var game = CatcherGame()
        for _ in 1...CatcherGame.startingLives { game.missed() }
        #expect(game.lives == 0)
        #expect(game.isOver)
    }

    @Test func nothingCountsAfterTheGameIsOver() {
        var game = CatcherGame()
        for _ in 1...CatcherGame.startingLives { game.missed() }
        game.caught()
        game.missed()
        #expect(game.score == 0)
        #expect(game.lives == 0)
    }

    // Floating-point arithmetic is not exact, so compare within a tolerance.
    @Test(arguments: [(0, 1.2), (10, 0.7), (16, 0.4), (30, 0.4)])
    func ballsComeFasterAsTheScoreRises(score: Int, expected: Double) {
        var game = CatcherGame()
        for _ in 0..<score { game.caught() }
        #expect(abs(game.spawnInterval - expected) < 0.000_001)
    }
}

Detailed breakdown

  • The manifest is CoreKit’s with the name changed. CatcherKit depends on no other package, so it is a leaf, and it needs no SpriteKit: nothing in it draws.
  • CatcherGame is a struct, and its counters are private(set). Code outside the package can read score and lives but can change them only through caught() and missed(), so the rules cannot be bypassed. A new game is just CatcherGame(). Sendable says the value is safe to pass between concurrent tasks, which a struct of integers is.
  • isOver is computed from lives, so it can never disagree with them, and caught() and missed() do nothing once it is true. Balls still on the screen when the game ends cannot change the final score.
  • spawnInterval is the difficulty curve: 1.2 seconds between balls at first, 0.05 seconds less for each catch, and never under 0.4 seconds, which it reaches at a score of 16.
  • The interval test compares within a tolerance. Binary floating point rounds: at a score of 16, 1.2 - 0.05 * 16 comes out as 0.3999999999999999, not 0.4. The max hides that case, but other scores could land just off the expected value, so the test checks that the difference is tiny instead of using ==.

Run the tests:

SWT_SF_SYMBOLS_ENABLED=0 swift test --package-path Packages/CatcherKit 2>&1 | tail -1
✔ Test run with 4 tests in 1 suite passed after 0.001 seconds.

Step 2: Add a best score to CoreKit

The game shows the best score ever reached on this device, so it has to survive the app quitting. Keeping a number between launches is not specific to Catcher, so it goes into the shared CoreKit package, where any app in the monorepo can use it. It stores the number in UserDefaults, the small key-value store each app gets for settings and similar values; iOS keeps it on disk between launches.

Create the file

touch Packages/CoreKit/Sources/CoreKit/BestScore.swift

Add the code: Packages/CoreKit/Sources/CoreKit/BestScore.swift

import Foundation

/// The highest score an app has seen, kept in `UserDefaults` so it survives
/// a relaunch.
public struct BestScore {
    private let defaults: UserDefaults
    private let key: String

    /// `key` names the value in `defaults`; give each game its own.
    public init(key: String, defaults: UserDefaults = .standard) {
        self.key = key
        self.defaults = defaults
    }

    /// The best score so far, or 0 before any score is recorded.
    public var value: Int { defaults.integer(forKey: key) }

    /// Keeps `score` if it beats the best so far, and returns whether it did.
    @discardableResult
    public func record(_ score: Int) -> Bool {
        guard score > value else { return false }
        defaults.set(score, forKey: key)
        return true
    }
}

Create the file

touch Packages/CoreKit/Tests/CoreKitTests/BestScoreTests.swift

Add the code: Packages/CoreKit/Tests/CoreKitTests/BestScoreTests.swift

import Foundation
import Testing
import CoreKit

struct BestScoreTests {
    // One separate store for these tests, and a new key in it for each test,
    // removed afterwards. Tests run in parallel, so they must not share a key,
    // and none of them touches a real app's saved values.
    private let defaults = UserDefaults(suiteName: "com.example.CoreKitTests")!

    private func withFreshKey(_ body: (String) -> Void) {
        let key = "best-\(UUID().uuidString)"
        defer { defaults.removeObject(forKey: key) }
        body(key)
    }

    @Test func startsAtZero() {
        withFreshKey { key in
            #expect(BestScore(key: key, defaults: defaults).value == 0)
        }
    }

    @Test func keepsOnlyAHigherScore() {
        withFreshKey { key in
            let best = BestScore(key: key, defaults: defaults)
            #expect(best.record(5))
            #expect(!best.record(3))
            #expect(!best.record(5))
            #expect(best.value == 5)
        }
    }

    @Test func survivesANewInstance() {
        withFreshKey { key in
            BestScore(key: key, defaults: defaults).record(7)
            #expect(BestScore(key: key, defaults: defaults).value == 7)
        }
    }
}

Detailed breakdown

  • defaults is a parameter, with .standard as its default. The app uses its own standard store; the tests pass a separate one, so running them cannot overwrite a real best score.
  • integer(forKey:) returns 0 for a key that was never set, which is the right starting best score, so value needs no special case.
  • record(_:) writes only a higher score and says whether it did. A caller can ignore the result, which @discardableResult allows without a warning.
  • The tests share one store, com.example.CoreKitTests, with a new key each. UserDefaults(suiteName:) names that store; “suite” here has nothing to do with a test suite. Swift Testing runs tests in parallel, so a shared key would let one test see another’s score. Each test removes its key when it finishes. On the Mac the store is a file, ~/Library/Preferences/com.example.CoreKitTests.plist, left empty after a run. A new store per test would leave new files behind on every run.

Run the tests:

SWT_SF_SYMBOLS_ENABLED=0 swift test --package-path Packages/CoreKit 2>&1 | tail -1
✔ Test run with 5 tests in 2 suites passed after 0.003 seconds.

Two suites now: TextInputTests and BestScoreTests. Commit the shared package change on its own, since it touches code every app links:

git add Packages/CoreKit
git commit -q -m "CoreKit: add BestScore"

Step 3: Create the Catcher app inside the workspace

Catcher is a SwiftUI app like Shopping, made from the same template; SpriteKit comes in through a view, not through Xcode’s separate Game template, which builds on UIKit and storyboards instead of SwiftUI. Open the workspace and add the new package to it first, so the app can link it:

open MyApps.xcworkspace
  1. Click an empty area of the Project navigator so nothing is selected, and choose File → Add Files to “MyApps”….
  2. Select the Packages/CatcherKit folder, click Add, and in the options sheet click Finish.

Then create the app, as A Swift Monorepo with an Xcode Workspace and Local Packages did for Shopping:

  1. Choose File → New → Project…, then Choose Template…, the iOS tab, App, and Next.
  2. Set Product Name to Catcher, leave Team without an account, check that Organization Identifier reads com.example, and set Interface to SwiftUI, Language to Swift, Testing System to None and Storage to None. Click Next.
  3. In the save panel, open my-apps/Apps, leave Create Git repository on my Mac unchecked, set Add to and Group to MyApps, and click Create.
grep CatcherKit MyApps.xcworkspace/contents.xcworkspacedata
grep -c Catcher MyApps.xcworkspace/contents.xcworkspacedata
      location = "group:Packages/CatcherKit">
2

Two lines mention Catcher: the package and the new app project.

Link the two packages the game uses. Use Build Phases, never the General tab: Version, Archive and Tag Each App in a Swift Monorepo found that opening General can write the display name into the target.

  1. Click the Catcher project in the Project navigator. If the editor opens on the target’s General tab, switch to Build Phases at once. (Right now no display name resolves, so nothing is written yet.)
  2. With the Catcher target selected, expand Link Binary With Libraries and click +.
  3. Under Workspace, expand CatcherKit, select its library, and click Add. Do the same for CoreKit.
grep -oE 'productName = [A-Za-z]+;|relativePath' Apps/Catcher/Catcher.xcodeproj/project.pbxproj
productName = Catcher;
productName = CatcherKit;
productName = CoreKit;

The target’s own name and the two linked products, with no relativePath: the workspace supplies both packages, as it does for the other apps.

Step 4: Add Catcher to the Makefile and share its scheme

The Makefile refuses any APP it does not list, so every later check of Catcher depends on this step. Four lines change: Catcher joins APPS, CatcherKit joins PACKAGES (it depends on nothing and nothing depends on it, so its position does not matter), Catcher gets a DEPS_ line for make changes, and run passes an optional LAUNCH_ARGS to the app. Edit the file with sed, then check the result:

sed -i '' \
  -e 's/^APPS        := TodoList Shopping$/APPS        := TodoList Shopping Catcher/' \
  -e 's/^PACKAGES    := CoreKit DesignSystem TodoKit$/PACKAGES    := CoreKit DesignSystem CatcherKit TodoKit/' \
  -e 's/^DEPS_Shopping := CoreKit DesignSystem$/&\
DEPS_Catcher  := CatcherKit CoreKit/' \
  -e 's/ \$(BUNDLE_ID)$/ $(BUNDLE_ID) $(LAUNCH_ARGS)/' Makefile
grep -E '^(APPS|PACKAGES|DEPS_)|simctl launch' Makefile
APPS        := TodoList Shopping Catcher
PACKAGES    := CoreKit DesignSystem CatcherKit TodoKit
DEPS_TodoList := TodoKit CoreKit DesignSystem
DEPS_Shopping := CoreKit DesignSystem
DEPS_Catcher  := CatcherKit CoreKit
	xcrun simctl launch "$(SIM_NAME)" $(BUNDLE_ID) $(LAUNCH_ARGS)

The last expression matches the one line that ends in $(BUNDLE_ID), the simctl launch line in run, so the command needs no tab character. LAUNCH_ARGS is not set anywhere, so it is empty unless you pass it, as Step 9 does: make run APP=Catcher LAUNCH_ARGS="-autoplay YES". Anything after the bundle identifier in simctl launch is passed to the app as its launch arguments.

make test builds each app through its scheme, and a fresh clone only has the schemes saved in the repository. Share Catcher’s as A Swift Monorepo with an Xcode Workspace and Local Packages did:

  1. Choose Product → Scheme → Catcher, then Product → Scheme → Edit Scheme….
  2. Check that Shared is ticked, and click Close.
find Apps -name '*.xcscheme' | sort
Apps/Catcher/Catcher.xcodeproj/xcshareddata/xcschemes/Catcher.xcscheme
Apps/Shopping/Shopping.xcodeproj/xcshareddata/xcschemes/Shopping.xcscheme
Apps/TodoList/TodoList.xcodeproj/xcshareddata/xcschemes/TodoList.xcscheme

Step 5: Give Catcher its own version, name and icon

Catcher needs what the other two apps got in the previous article: its identity and version in a file of its own, target values that do not override it, and an icon, without which the home screen shows the template’s blank one. Without the file, make release has no version to read, and the template’s target values would put version 1.0 on the archive.

Create the file

touch Apps/Catcher/Catcher.xcconfig

Add the code: Apps/Catcher/Catcher.xcconfig

// Catcher's identity and version, for Debug and Release.
#include "../Config/Shared.xcconfig"

PRODUCT_BUNDLE_IDENTIFIER = $(BUNDLE_ID_PREFIX).Catcher
INFOPLIST_KEY_CFBundleDisplayName = Catcher
INFOPLIST_KEY_CFBundleDisplayName[config=Debug] = Catcher Dev

// The version people see. Raise it for each release.
MARKETING_VERSION = 1.0.0
// The build number. `make archive` replaces it with the commit count.
CURRENT_PROJECT_VERSION = 1

Detailed breakdown

  • The same shape as Shopping’s file, with Catcher’s names. It includes Shared.xcconfig, so Catcher also treats warnings as errors and takes its bundle identifier prefix from there.
  • The version starts at 1.0.0. Catcher has its own series of release tags, starting with Catcher/v1.0.0.

Attach the file and clear the target values, as the previous article’s Steps 3 and 4 did:

  1. Select the Catcher project, choose File → Add Files to “Catcher”…, select Apps/Catcher/Catcher.xcconfig, click Add, clear the Catcher checkbox under Targets, and click Finish.
  2. Select Catcher under PROJECT, open Info, and set the project row of both Debug and Release to Catcher.xcconfig.
  3. Still on the project, open Build Settings, then select the Catcher target. With All and Levels shown, delete the target’s values for MARKETING_VERSION, CURRENT_PROJECT_VERSION (the Current Project Version row), PRODUCT_BUNDLE_IDENTIFIER, and CFBundleDisplayName if the target column has one.
grep -o 'baseConfigurationReference.*' Apps/Catcher/Catcher.xcodeproj/project.pbxproj
grep -cE 'MARKETING_VERSION|CURRENT_PROJECT_VERSION|PRODUCT_BUNDLE_IDENTIFIER|CFBundleDisplayName' Apps/Catcher/Catcher.xcodeproj/project.pbxproj
make settings APP=Catcher CONFIG=Release
baseConfigurationReference = 307088AB308F8A4200758D3B /* Catcher.xcconfig */;
baseConfigurationReference = 307088AB308F8A4200758D3B /* Catcher.xcconfig */;
0
CONFIGURATION = Release
CURRENT_PROJECT_VERSION = 1
INFOPLIST_KEY_CFBundleDisplayName = Catcher
MARKETING_VERSION = 1.0.0
PRODUCT_BUNDLE_IDENTIFIER = com.example.Catcher
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES

Both configurations use the file, the target sets none of the four values, and every value shown comes from Catcher.xcconfig or Shared.xcconfig. The identifier on each baseConfigurationReference line differs in your project.

Draw the icon, an orange C, and give the image set the same Contents.json as the other apps:

swift Tools/make-icon.swift C 0.95 0.50 0.10 Apps/Catcher/Catcher/Assets.xcassets/AppIcon.appiconset/AppIcon.png
cp Apps/Shopping/Shopping/Assets.xcassets/AppIcon.appiconset/Contents.json Apps/Catcher/Catcher/Assets.xcassets/AppIcon.appiconset/

Step 6: Keep the game’s state where SwiftUI can see it

The scene reacts to catches and misses, and SwiftUI draws the score above it, so both need the same state. GameSession holds it: the current CatcherGame and the best score. Marked @Observable, it lets SwiftUI redraw the overlay whenever the scene changes a value, with no code that pushes updates. The file goes into Apps/Catcher/Catcher/, the app’s synchronized folder, so Xcode adds it to the target with no extra step.

Create the file

touch Apps/Catcher/Catcher/GameSession.swift

Add the code: Apps/Catcher/Catcher/GameSession.swift

import CatcherKit
import CoreKit
import Observation

/// The state the scene changes and the SwiftUI overlay shows.
@Observable
final class GameSession {
    private(set) var game = CatcherGame()
    private(set) var best: Int
    private let bestScore = BestScore(key: "bestScore")

    init() {
        best = bestScore.value
    }

    func caught() {
        game.caught()
        if bestScore.record(game.score) {
            best = game.score
        }
    }

    func missed() {
        game.missed()
    }

    func restart() {
        game = CatcherGame()
    }
}

Detailed breakdown

  • The session forwards to the rules. caught() and missed() call the CatcherGame methods of the same names, so the scene never changes the score directly; the tested rules set it.
  • The best score is recorded on every catch, not only at game over. If the app is closed mid-game, a new best is already saved. best keeps a copy for SwiftUI to observe, because BestScore reads UserDefaults, which @Observable cannot watch.
  • restart() replaces the whole game with a new CatcherGame(), which starts at 0 points and 3 lives.
  • Every type in the app target runs on the main actor, which runs code on the main thread, because the template sets SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor. SpriteKit calls the scene on the main thread too, so the scene and SwiftUI can share this object without locks.

Step 7: Build the SpriteKit scene

An SKScene is the root of everything SpriteKit draws. Things on screen are nodes (SKNode and its subclasses) added to the scene as children; each has a position, measured in points (the screen’s layout unit, not score) from the scene’s bottom-left corner with y pointing up, unlike UIKit and SwiftUI, where y points down. A node can carry a physics body (SKPhysicsBody), a shape the physics engine moves under gravity and checks for touching other bodies, and it can run actions (SKAction), reusable steps such as “wait one second” or “fade out” that SpriteKit plays over time. For every frame, one redraw of the screen, SpriteKit calls the scene’s update(_:), then runs actions, then simulates physics, then draws. The scene below uses each of these.

Create the file

touch Apps/Catcher/Catcher/GameScene.swift

Add the code: Apps/Catcher/Catcher/GameScene.swift

import CatcherKit
import SpriteKit

/// What each physics body is. A contact is reported when either body's
/// `contactTestBitMask` includes the other body's category.
enum Category {
    static let ball: UInt32 = 1 << 0
    static let paddle: UInt32 = 1 << 1
    static let floor: UInt32 = 1 << 2
}

final class GameScene: SKScene, SKPhysicsContactDelegate {
    private let session: GameSession
    private let paddle = SKShapeNode(rectOf: CGSize(width: 110, height: 18), cornerRadius: 9)
    private let ballRadius: CGFloat = 16
    private let colors: [SKColor] = [.systemOrange, .systemPink, .systemYellow, .systemTeal]
    /// Where the paddle is heading: the last touch, or the lowest ball in autoplay.
    private var targetX: CGFloat?
    /// Launch with `-autoplay YES` to let the paddle play by itself.
    private let autoplay = UserDefaults.standard.bool(forKey: "autoplay")

    init(session: GameSession) {
        self.session = session
        // A placeholder size: `.resizeFill` makes the scene match its view.
        super.init(size: CGSize(width: 400, height: 800))
        scaleMode = .resizeFill
    }

    required init?(coder: NSCoder) {
        fatalError("GameScene is created in code")
    }

    override func didMove(to view: SKView) {
        backgroundColor = SKColor(red: 0.10, green: 0.12, blue: 0.25, alpha: 1)
        physicsWorld.gravity = CGVector(dx: 0, dy: -2)
        physicsWorld.contactDelegate = self

        paddle.fillColor = .white
        paddle.strokeColor = .clear
        let body = SKPhysicsBody(rectangleOf: CGSize(width: 110, height: 18))
        body.isDynamic = false
        body.categoryBitMask = Category.paddle
        paddle.physicsBody = body
        paddle.position = CGPoint(x: size.width / 2, y: 100)
        addChild(paddle)

        layOut()
        dropBallsForever()
    }

    override func didChangeSize(_ oldSize: CGSize) {
        layOut()
    }

    /// Keeps the paddle on screen and puts the floor, an edge the balls
    /// cross after leaving the screen, along the bottom.
    private func layOut() {
        paddle.position.x = min(max(paddle.position.x, 55), size.width - 55)
        let floor = SKPhysicsBody(edgeFrom: CGPoint(x: 0, y: -2 * ballRadius),
                                  to: CGPoint(x: size.width, y: -2 * ballRadius))
        floor.categoryBitMask = Category.floor
        physicsBody = floor
    }

    /// Drops a ball, waits, and repeats. The wait is read again each time,
    /// so balls come faster as the score rises.
    private func dropBallsForever() {
        run(.sequence([
            .wait(forDuration: session.game.spawnInterval),
            .run { [weak self] in
                self?.dropBall()
                self?.dropBallsForever()
            },
        ]))
    }

    private func dropBall() {
        guard !session.game.isOver else { return }
        let ball = SKShapeNode(circleOfRadius: ballRadius)
        ball.name = "ball"
        ball.fillColor = colors.randomElement()!
        ball.strokeColor = .clear
        ball.position = CGPoint(x: .random(in: ballRadius...(size.width - ballRadius)),
                                y: size.height + ballRadius)
        let body = SKPhysicsBody(circleOfRadius: ballRadius)
        body.categoryBitMask = Category.ball
        body.contactTestBitMask = Category.paddle | Category.floor
        body.collisionBitMask = 0
        ball.physicsBody = body
        addChild(ball)
    }

    /// Called during the physics step, when two bodies start touching. The
    /// physics world must not be changed here, so this only scores the
    /// contact and renames the ball; `didSimulatePhysics()` does the rest.
    func didBegin(_ contact: SKPhysicsContact) {
        let (ballBody, other) = contact.bodyA.categoryBitMask == Category.ball
            ? (contact.bodyA, contact.bodyB) : (contact.bodyB, contact.bodyA)
        // A renamed ball has been counted already.
        guard let ball = ballBody.node, ball.name == "ball" else { return }
        if other.categoryBitMask == Category.paddle {
            session.caught()
            ball.name = "caught"
        } else {
            session.missed()
            ball.name = "missed"
        }
    }

    /// Called after the physics step, when bodies may be changed again.
    override func didSimulatePhysics() {
        enumerateChildNodes(withName: "caught") { ball, _ in
            ball.name = nil
            ball.physicsBody = nil
            ball.run(.sequence([
                .group([.scale(to: 1.8, duration: 0.15), .fadeOut(withDuration: 0.15)]),
                .removeFromParent(),
            ]))
        }
        enumerateChildNodes(withName: "missed") { ball, _ in
            ball.removeFromParent()
        }
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        if session.game.isOver {
            session.restart()
            enumerateChildNodes(withName: "ball") { node, _ in node.removeFromParent() }
            return
        }
        targetX = touches.first?.location(in: self).x
    }

    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        targetX = touches.first?.location(in: self).x
    }

    /// Runs once per frame, before SpriteKit simulates physics.
    override func update(_ currentTime: TimeInterval) {
        if autoplay {
            let lowest = children
                .filter { $0.name == "ball" }
                .min { $0.position.y < $1.position.y }
            targetX = lowest?.position.x ?? targetX
        }
        guard let targetX else { return }
        // Cover a fifth of the remaining distance each frame: fast, but smooth.
        let x = paddle.position.x + (targetX - paddle.position.x) * 0.2
        paddle.position.x = min(max(x, 55), size.width - 55)
    }
}

Detailed breakdown

Setup.

  • Category gives each kind of body a bit. A physics body has three 32-bit masks: categoryBitMask says what it is, collisionBitMask says what it bounces off, and contactTestBitMask says which categories it wants to be told about. Using one bit per kind (1 << 0, 1 << 1, 1 << 2) lets a mask name several at once with |.
  • scaleMode = .resizeFill makes the scene the size of its view, so on any iPhone the scene’s coordinates are the screen’s points. The size passed to super.init is only a placeholder until SwiftUI lays out the view.
  • didMove(to:) runs when the scene appears. It sets a dark background, makes gravity weaker than the default of -9.8 so balls fall at a catchable speed, makes the scene the contact delegate (the object SpriteKit calls when two bodies touch), and adds the paddle.
  • The paddle’s body is not dynamic (isDynamic = false): gravity and collisions never move it. The code moves it by setting its position, and the physics engine still reports balls that touch it.
  • layOut() runs at start and on every size change (didChangeSize, for example on rotation). It keeps the paddle on screen and gives the scene itself a body: an edge, a line with no area, across the bottom at -2 * ballRadius, below the visible screen. That edge is the floor: a ball that touches it has left the screen uncaught.

Falling balls.

  • dropBallsForever() runs an action that calls it again. The action waits spawnInterval seconds, drops a ball, and calls itself. An SKAction.repeatForever would read the interval once, when the action is created; re-scheduling reads it every time, so the game speeds up as CatcherGame says. [weak self] keeps the action, which the scene owns, from also owning the scene.
  • Each ball is an SKShapeNode circle in a random color, placed just above the top edge at a random x, with a circular dynamic body that gravity pulls down. collisionBitMask = 0 means it bounces off nothing and passes straight through the paddle and the floor; the contact mask still reports touching either. dropBall() does nothing once the game is over.

Contacts.

  • didBegin(_:) is called when two bodies start touching and either body’s contactTestBitMask includes the other’s category. SpriteKit does not say which of bodyA and bodyB is the ball, so the first line sorts them.
  • It changes no physics body. SpriteKit calls it in the middle of the physics step, and Apple’s documentation says changing bodies there gives undefined behavior. So it only does what touches no body: it calls session.caught() or session.missed() and renames the ball caught or missed. The scene decides what happened; CatcherGame decides the score.
  • The new name stops a ball from counting twice. The guard accepts only a ball still named ball, so a caught ball that went on to touch the floor before it was removed would be ignored.
  • didSimulatePhysics() runs right after the physics step, when bodies may be changed. It removes each caught ball’s body, so it reports no more contacts, and plays a short grow-and-fade action that ends by removing the ball. Missed balls are removed at once.

The paddle.

  • touchesBegan and touchesMoved record where your finger is, as an x position in scene coordinates, in targetX. A touch while the game is over restarts it instead and removes the balls still falling, found by their name.
  • update(_:) moves the paddle a fifth of the way to targetX each frame. At 60 frames a second it arrives in a fraction of a second without jumping, and the min/max clamp keeps its 110-point width on screen (55 is half of it).
  • autoplay comes from the -autoplay YES launch argument. iOS puts launch arguments of the form -name value into UserDefaults, so bool(forKey: "autoplay") reads it with no argument parsing. When it is on, each frame aims the paddle at the lowest ball still named ball, the one that will land first. Autoplay scores are real scores, so they also set the best score.

Step 8: Show the scene in SwiftUI

SpriteView is the SwiftUI view that shows an SKScene, and it passes touches to it. The template’s ContentView becomes the game screen: the scene filling the display, and SwiftUI text on top for the score, the best score, the lives left, and the game-over message.

Create the file

touch Apps/Catcher/Catcher/ContentView.swift

The file exists already. Replace its contents with the listing below. The template’s CatcherApp.swift already shows ContentView, so it does not change.

Add the code: Apps/Catcher/Catcher/ContentView.swift

import CatcherKit
import SpriteKit
import SwiftUI

struct ContentView: View {
    @State private var session: GameSession
    @State private var scene: GameScene

    init() {
        let session = GameSession()
        _session = State(initialValue: session)
        _scene = State(initialValue: GameScene(session: session))
    }

    var body: some View {
        ZStack {
            SpriteView(scene: scene)
                .ignoresSafeArea()
            VStack(spacing: 8) {
                HStack {
                    Text("Score \(session.game.score)")
                    Spacer()
                    Text("Best \(session.best)")
                    Spacer()
                    Text("Lives \(session.game.lives)")
                }
                Spacer()
                if session.game.isOver {
                    Text("Game over\nTap to play again")
                        .font(.title2.bold())
                        .multilineTextAlignment(.center)
                    Spacer()
                }
            }
            .font(.headline.monospacedDigit())
            .foregroundStyle(.white)
            .padding()
            // Touches pass through the overlay to the scene.
            .allowsHitTesting(false)
        }
    }
}

#Preview {
    ContentView()
}

Detailed breakdown

  • The session and the scene are @State, set up in init. SwiftUI may create ContentView values many times, and each init builds a new session and scene, but @State keeps the first ones for the life of the view and discards the rest, so a redraw does not restart the game. The scene receives the same session the overlay reads.
  • The overlay reads session.game.score, session.best and session.game.lives, so SwiftUI redraws it whenever the scene changes one of them. monospacedDigit() keeps the numbers from shifting sideways as they change.
  • .allowsHitTesting(false) lets touches pass through the text to the scene underneath; without it, a finger on the score label would not move the paddle.
  • import CatcherKit is needed here and in GameScene.swift, although only GameSession.swift names CatcherGame. The template turns on member import visibility, so a file that reads a member such as score must import its module. Without the import, the build fails with errors such as property 'spawnInterval' is not available due to missing import of defining module 'CatcherKit'.

Build every app:

make build
--- TodoList
--- Shopping
--- Catcher

Step 9: Play Catcher

First let the paddle play by itself, so the run shows the scene, the physics and the scoring working without input. Give the game a few seconds to score, then take a screenshot:

make run APP=Catcher LAUNCH_ARGS="-autoplay YES"
sleep 12
make screenshot APP=Catcher
xcrun simctl launch "TodoList-iPhone" com.example.Catcher -autoplay YES
com.example.Catcher: 21360
xcrun simctl io "TodoList-iPhone" screenshot screenshots/Catcher.png
Detected file type from extension: PNG
Note: No display specified. Defaulting to display: 8AFB279A-37BC-421B-856A-86EA4F1A54E9 (screenID: 1, name: LCD)

The first two lines are the end of make run’s output; the process ID and display identifier differ on your Mac, and make screenshot’s last line (the full path) is left out. In the validation run, screenshots/Catcher.png showed Score 8, Best 8 and Lives 3 across the top, balls on their way down near the right edge, and the paddle at the bottom right, under them. Your score will differ: ball positions are random.

Now run it again without autoplay and keep your hands off. Every ball that misses the paddle costs a life:

xcrun simctl terminate TodoList-iPhone com.example.Catcher
make run APP=Catcher
sleep 15
make screenshot APP=Catcher

The screenshot shows Lives 0 and Game over / Tap to play again. The score is usually a few points, from balls that happened to land on the paddle where it sits. Best still shows the highest score of the autoplay run, which kept scoring until it was terminated, so its value depends on how long that was (20 in one validation run, 161 in another): BestScore kept it across the relaunch.

Then play. In the simulator window, click the game to play again, and drag the paddle with the mouse to catch the balls; a click-and-drag is a touch. The score climbs with each catch, the balls come faster as it does, and the game ends at the third miss.

Finally, run the whole test suite. make test now tests CatcherKit and builds Catcher along with the other apps:

make test 2>&1 | grep -E '^---|Test run|TEST'
--- CoreKit
✔ Test run with 5 tests in 2 suites passed after 0.004 seconds.
--- DesignSystem
✔ Test run with 2 tests in 1 suite passed after 0.036 seconds.
--- CatcherKit
✔ Test run with 4 tests in 1 suite passed after 0.001 seconds.
--- TodoKit
✔ Test run with 5 tests in 1 suite passed after 0.010 seconds.
--- TodoList
--- Shopping
--- Catcher
--- TodoList UI tests
** TEST SUCCEEDED **

Timings vary from run to run.

Step 10: Release Catcher

Catcher is an app in the monorepo like the others, so it ships the same way: commit, then make release. The CoreKit change was committed in Step 2; commit the rest:

git add -A
git commit -q -m "Add the Catcher game"
make release APP=Catcher
xcodebuild -workspace MyApps.xcworkspace -derivedDataPath build -scheme Catcher -destination 'generic/platform=iOS' -archivePath build/Catcher.xcarchive CURRENT_PROJECT_VERSION=10 CODE_SIGNING_ALLOWED=NO -quiet archive
{"CFBundleShortVersionString":"1.0.0","CFBundleIdentifier":"com.example.Catcher","Team":"","ApplicationPath":"Applications\/Catcher.app","SigningIdentity":"","Architectures":["arm64"],"CFBundleVersion":"10"}
git tag -a Catcher/v1.0.0 -m "Catcher 1.0.0 (build 10)"

Build 10: the eight commits from the previous article, plus the two made here. Now ask what the other apps have waiting:

make changes
make changes APP=Shopping
make changes APP=Catcher
TodoList since TodoList/v1.0.1:
f48f9e4 CoreKit: add BestScore
Shopping since Shopping/v1.1.1:
f48f9e4 CoreKit: add BestScore
Catcher since Catcher/v1.0.0:
(no changes)

The commit hash differs on your Mac. TodoList and Shopping both list the CoreKit commit, because both are built from CoreKit and the commit changed it. Neither app calls BestScore, so neither behaves differently, and nothing in them needs a release for it. That is the limit of a check based on paths: make changes reports what changed in an app’s inputs, and you decide whether that change matters. Committing a shared-package change on its own, as Step 2 did, keeps that decision easy: the commit message says what changed.

Troubleshooting

  • property '…' is not available due to missing import of defining module 'CatcherKit'. Add import CatcherKit to the file that reads the member.
  • APP must be one of TodoList Shopping. Step 4’s sed did not match. Check the APPS line with grep '^APPS' Makefile; the spacing must be as shown.
  • No balls appear. The scene never started dropping balls: check that didMove(to:) ends with dropBallsForever().
  • Balls pass the paddle without scoring. A contact is reported only when one body’s contactTestBitMask includes the other’s category. Check that the ball’s mask includes Category.paddle and that the paddle’s body has categoryBitMask = Category.paddle.
  • A caught ball also costs a life. The ball was counted twice. The guard in didBegin(_:) must accept only a ball named ball, and the ball must be renamed before didBegin(_:) returns.
  • Dragging does nothing. The SwiftUI overlay is taking the touches. It needs .allowsHitTesting(false).
  • The screenshot shows a white card instead of the game. It was taken during the launch animation; wait a few seconds after make run.

Recap

The monorepo now holds a third app that has little in common with the other two except the structure around it. Catcher’s rules are a CatcherGame value in the CatcherKit package, tested on the Mac with no scene, and its best score comes from BestScore, a new type in the shared CoreKit package. The SpriteKit scene adds nodes for the paddle and the balls, gives them physics bodies with category and contact masks, schedules balls with a self-repeating action, turns contacts into catches and misses outside the physics step, and moves the paddle toward your finger in update(_:). SpriteView puts the scene in SwiftUI, and an @Observable session lets the SwiftUI overlay follow the score. Catcher has its own .xcconfig file, icon and shared scheme, make test covers it, and make release APP=Catcher produced Catcher/v1.0.0 at build 10.

Next improvements:

  • Add sound with SKAction.playSoundFileNamed(_:waitForCompletion:) on a catch and a miss.
  • Give Catcher a UI test that launches it with -autoplay YES and waits for the score to pass 5, and add it to UI_TESTED.
  • Show the best score on a start screen, so a game begins with a tap instead of at launch.