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.BestScoreinCoreKit, a best score kept across launches, with tests.Apps/Catcher, an iOS app whose SpriteKit scene is hosted in SwiftUI bySpriteView, 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-appsfolder from Version, Archive and Tag Each App in a Swift Monorepo, with its Git history and theTodoList/v1.0.1andShopping/v1.1.1tags, and theTodoList-iPhonesimulator. - 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
@Stateand@Observablefrom 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.CatcherKitdepends on no other package, so it is a leaf, and it needs no SpriteKit: nothing in it draws. CatcherGameis a struct, and its counters areprivate(set). Code outside the package can readscoreandlivesbut can change them only throughcaught()andmissed(), so the rules cannot be bypassed. A new game is justCatcherGame().Sendablesays the value is safe to pass between concurrent tasks, which a struct of integers is.isOveris computed fromlives, so it can never disagree with them, andcaught()andmissed()do nothing once it is true. Balls still on the screen when the game ends cannot change the final score.spawnIntervalis 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 * 16comes out as0.3999999999999999, not0.4. Themaxhides 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
defaultsis a parameter, with.standardas 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, sovalueneeds no special case.record(_:)writes only a higher score and says whether it did. A caller can ignore the result, which@discardableResultallows 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
- Click an empty area of the Project navigator so nothing is selected, and choose File → Add Files to “MyApps”….
- Select the
Packages/CatcherKitfolder, 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:
- Choose File → New → Project…, then Choose Template…, the iOS tab, App, and Next.
- Set Product Name to
Catcher, leave Team without an account, check that Organization Identifier readscom.example, and set Interface to SwiftUI, Language to Swift, Testing System to None and Storage to None. Click Next. - 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.
- 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.)
- With the Catcher target selected, expand Link Binary With Libraries and click +.
- 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:
- Choose Product → Scheme → Catcher, then Product → Scheme → Edit Scheme….
- 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 withCatcher/v1.0.0.
Attach the file and clear the target values, as the previous article’s Steps 3 and 4 did:
- 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. - Select Catcher under PROJECT, open Info, and set the project row of both Debug and Release to Catcher.xcconfig.
- 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, andCFBundleDisplayNameif 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()andmissed()call theCatcherGamemethods 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.
bestkeeps a copy for SwiftUI to observe, becauseBestScorereadsUserDefaults, which@Observablecannot watch. restart()replaces the whole game with a newCatcherGame(), 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.
Categorygives each kind of body a bit. A physics body has three 32-bit masks:categoryBitMasksays what it is,collisionBitMasksays what it bounces off, andcontactTestBitMasksays 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 = .resizeFillmakes the scene the size of its view, so on any iPhone the scene’s coordinates are the screen’s points. The size passed tosuper.initis 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 waitsspawnIntervalseconds, drops a ball, and calls itself. AnSKAction.repeatForeverwould read the interval once, when the action is created; re-scheduling reads it every time, so the game speeds up asCatcherGamesays.[weak self]keeps the action, which the scene owns, from also owning the scene.- Each ball is an
SKShapeNodecircle in a random color, placed just above the top edge at a random x, with a circular dynamic body that gravity pulls down.collisionBitMask = 0means 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’scontactTestBitMaskincludes the other’s category. SpriteKit does not say which ofbodyAandbodyBis 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()orsession.missed()and renames the ballcaughtormissed. The scene decides what happened;CatcherGamedecides the score. - The new name stops a ball from counting twice. The
guardaccepts only a ball still namedball, 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.
touchesBeganandtouchesMovedrecord where your finger is, as an x position in scene coordinates, intargetX. A touch while the game is over restarts it instead and removes the balls still falling, found by theirname.update(_:)moves the paddle a fifth of the way totargetXeach frame. At 60 frames a second it arrives in a fraction of a second without jumping, and themin/maxclamp keeps its 110-point width on screen (55 is half of it).autoplaycomes from the-autoplay YESlaunch argument. iOS puts launch arguments of the form-name valueintoUserDefaults, sobool(forKey: "autoplay")reads it with no argument parsing. When it is on, each frame aims the paddle at the lowest ball still namedball, 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 ininit. SwiftUI may createContentViewvalues many times, and eachinitbuilds a new session and scene, but@Statekeeps 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.bestandsession.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 CatcherKitis needed here and inGameScene.swift, although onlyGameSession.swiftnamesCatcherGame. The template turns on member import visibility, so a file that reads a member such asscoremust import its module. Without the import, the build fails with errors such asproperty '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'. Addimport CatcherKitto the file that reads the member.APP must be one of TodoList Shopping. Step 4’sseddid not match. Check theAPPSline withgrep '^APPS' Makefile; the spacing must be as shown.- No balls appear. The scene never started dropping balls: check that
didMove(to:)ends withdropBallsForever(). - Balls pass the paddle without scoring. A contact is reported only when
one body’s
contactTestBitMaskincludes the other’s category. Check that the ball’s mask includesCategory.paddleand that the paddle’s body hascategoryBitMask = Category.paddle. - A caught ball also costs a life. The ball was counted twice. The
guardindidBegin(_:)must accept only a ball namedball, and the ball must be renamed beforedidBegin(_:)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 YESand waits for the score to pass 5, and add it toUI_TESTED. - Show the best score on a start screen, so a game begins with a tap instead of at launch.