Build a macOS Endless Runner with SpriteKit
Build a Chrome Dino-style macOS game using SpriteKit physics and SwiftUI.
Introduction
30 Second Summary
You know that little dinosaur game that pops up when your internet goes down? It's addictively simple. A character runs, you press a button to jump, and obstacles keep coming faster.
In this project, you will build your own version of that game as a native macOS app. You'll create a character selection menu, implement jump physics, spawn scrolling obstacles, and add progressive difficulty that keeps players on edge.
What You'll Build
A macOS endless runner game built with SwiftUI for the menu and SpriteKit for the game engine, all running inside a single native app.
By the end of this project, you'll have:
- A SwiftUI character selection screen with four clickable animal cards.
- A SpriteKit game scene with gravity-based jump physics, scrolling obstacles, and collision detection.
- A scoring system with progressive difficulty that increases speed and obstacle frequency over time.
- Secret Mission: A day/night visual cycle that inverts colors at score milestones, just like the real Chrome Dino game.
Are there any prerequisites?
This project assumes you have Swift programming experience and Xcode already installed on your Mac. No additional tools or accounts are needed.
Set Up the Xcode Project
Every SpriteKit game needs a home. Before writing any game logic, you need an Xcode project configured with the right lifecycle, language, and target platform.
In this step, you'll create a new macOS App project using the SwiftUI app lifecycle. You'll also add an empty file for your game scene and verify the project builds.
In this step, get ready to:
- Create a new macOS App project named EndlessRunner.
- Add a GameScene.swift file for the SpriteKit game logic.
- Build and run to verify the template app launches.
Create a new macOS App project
Xcode's project wizard creates the file structure, build settings, and app entry point for you. You'll configure it for a macOS app using SwiftUI.
- Press Cmd+Space to open Spotlight search.
- Type Xcode and press Enter to open it.
- In the menu bar, click File.
- Click New, then click Project.
- In the template chooser, select macOS at the top.
- Select App and click Next.
- Enter EndlessRunner in the Product Name field.
- Set Interface to SwiftUI.
- Set Language to Swift.
- Uncheck Include Tests.
- Click Next.
- Choose your Desktop as the save location and click Create.
What did you just configure?
The SwiftUI interface option uses the modern app lifecycle. This gives you access to SpriteView for embedding SpriteKit scenes directly inside SwiftUI views later.
Unchecking Include Tests keeps the project minimal. For a game project like this, you won't need unit test targets.
- In the project navigator on the left, click the EndlessRunner project icon (the blue icon at the top of the file list).
- Select the EndlessRunner target under Targets.
- In the General tab, scroll to the Minimum Deployments section.
- Confirm the macOS version is set to 14.0 or later.
Why macOS 14.0?
macOS 14.0 (Sonoma) ensures access to the latest SwiftUI and SpriteKit APIs you'll use in this project. If your Mac is running a recent macOS version, this should already be set correctly by default.
Now clean up the default template code so your project matches a clean starting point.
- In the project navigator, click ContentView.swift to open it.
- Replace everything in the file with this code:
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello, world!")
}
}
What does this code do?
This is the simplest possible SwiftUI view. It displays a text label in the window. You'll replace this entirely in the next step with the character selection menu.
- Save ContentView.swift with Cmd+S.
Add a game scene file
Your SpriteKit game logic will live in a separate file. Adding it now keeps the project organized from the start.
- In the menu bar, click File.
- Click New, then click File.
- Select Swift File and click Next.
- Name the file GameScene and click Create.
- Delete all the auto-generated code in the file (the comment header and import Foundation line) so the file is completely empty.
- Save GameScene.swift with Cmd+S.
You should now see GameScene.swift in the project navigator alongside ContentView.swift and EndlessRunnerApp.swift.
Don't see GameScene.swift in the navigator?
Make sure you selected the EndlessRunner group when saving. If the file ended up outside the project group, drag it from the Finder into the project navigator in Xcode.
help me find where my new Swift file went in my Xcode project
Build and run the project
A quick build confirms that the project is configured correctly and has no errors before you start adding game code.
- Press Cmd+R to build and run the app.
A window should appear with the text "Hello, world!" displayed in the center. This confirms your SwiftUI app lifecycle and window configuration are working.
Build failed or no window appeared?
If you see a build error, check that GameScene.swift is completely empty. Any leftover code (like a stray import without a valid module) can cause issues.
If the window doesn't appear, look at the top of the Xcode toolbar for the active scheme. Make sure it says EndlessRunner and targets My Mac.
help me fix my Xcode project build error
✔️ Awesome, I've got everything!
Great. Make sure you've saved all files before moving on.
ⓧ I'd like to double check the full code
Here's what each file should contain at this point:
import SwiftUI
@main
struct EndlessRunnerApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello, world!")
}
}
GameScene.swift should be completely empty with no code at all.
Your project structure is ready. Next up, you'll build the character selection menu where players pick their animal before jumping into the game.
Build the Character Selection Menu
Your Xcode project is set up and running. Now it's time to build the first thing players see: a character selection screen.
The Chrome Dino game drops you straight into gameplay, but our version lets players pick from four animals first. You'll build this menu entirely in SwiftUI, then later connect it to the SpriteKit game scene.
In this step, get ready to:
- Define the four animal character types and build a visual menu with clickable cards.
- Wire up navigation state so the app switches between menu and game views.
- Create the game view wrapper that will host your SpriteKit scene.
Define the animal types and build the menu
Every character in the game needs a type the code can reference. An enum is perfect for this because it limits choices to exactly the animals you support.
- In Xcode, open ContentView.swift from the file navigator on the left.
- Select all the existing code (Cmd+A) and delete it.
- Replace it with the following imports and enum definition:
import SwiftUI
import SpriteKit
enum AnimalType: String, CaseIterable {
case dino = "Dino"
case cat = "Cat"
case bird = "Bird"
case frog = "Frog"
}
What does this code do?
The import SpriteKit line brings in Apple's 2D game framework. You'll use it later for the SpriteView that hosts the game scene.
The AnimalType enum conforms to CaseIterable, which gives you an allCases array. This makes it easy to loop through all animals and generate a card for each one.
- Below the enum, add the MenuView struct that displays the character selection screen:
struct MenuView: View {
let onSelect: (AnimalType) -> Void
var body: some View {
VStack(spacing: 24) {
Text("Endless Runner")
.font(.system(size: 32, weight: .bold))
Text("Choose your character")
.font(.title3)
.foregroundColor(.secondary)
HStack(spacing: 16) {
ForEach(AnimalType.allCases, id: \.self) { animal in
AnimalCard(animal: animal) { onSelect(animal) }
}
}
Text("Space or Up Arrow to jump | Esc for menu")
.font(.caption)
.foregroundColor(.gray)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(white: 0.95))
}
}
What's happening here?
MenuView takes an onSelect closure as its only dependency. When a player taps a character card, this closure fires with the chosen AnimalType. This keeps the menu view decoupled from navigation logic.
The ForEach loops through AnimalType.allCases to generate one AnimalCard per animal. Adding a new animal later means just adding a case to the enum.
- Below MenuView, add the AnimalCard struct that renders each individual character button:
struct AnimalCard: View {
let animal: AnimalType
let action: () -> Void
private var color: Color {
switch animal {
case .dino: return .green
case .cat: return .orange
case .bird: return .blue
case .frog: return .mint
}
}
private var emoji: String {
switch animal {
case .dino: return "\u{1F996}"
case .cat: return "\u{1F431}"
case .bird: return "\u{1F426}"
case .frog: return "\u{1F438}"
}
}
var body: some View {
Button(action: action) {
VStack(spacing: 8) {
Text(emoji).font(.system(size: 36))
Text(animal.rawValue).font(.headline)
}
.frame(width: 100, height: 100)
.background(color.opacity(0.15))
.cornerRadius(12)
.overlay(RoundedRectangle(cornerRadius: 12).stroke(color, lineWidth: 2))
}
.buttonStyle(.plain)
}
}
What does this code do?
Each AnimalCard uses computed properties to map the animal type to a unique color and emoji. The color property drives both the tinted background and the border stroke.
The .buttonStyle(.plain) modifier removes macOS's default button chrome so the card's custom styling shows through cleanly.
You won't see changes yet. The next section adds the navigation logic that displays this menu.
Set up ContentView with navigation state
SwiftUI uses state variables to drive what's on screen. A single optional value is all you need to toggle between the menu and the game.
- Below AnimalCard, add the new ContentView struct that manages which screen is visible:
struct ContentView: View {
@State private var selectedAnimal: AnimalType? = nil
var body: some View {
Group {
if let animal = selectedAnimal {
GameView(animal: animal, onBack: { selectedAnimal = nil })
} else {
MenuView(onSelect: { selectedAnimal = $0 })
}
}
.frame(width: 800, height: 300)
}
}
What's happening here?
When selectedAnimal is nil, SwiftUI renders the MenuView. The moment a player picks a character, selectedAnimal gets set and SwiftUI swaps in GameView automatically.
Setting selectedAnimal back to nil (via the onBack closure) returns the player to the menu. The fixed .frame(width: 800, height: 300) gives the game a consistent window size.
One more piece to go. The GameView referenced above doesn't exist yet.
Create the game view wrapper
SpriteKit scenes live inside a SpriteView container that bridges SwiftUI and SpriteKit. This wrapper initializes the scene with the right size and passes in the selected character.
- At the bottom of ContentView.swift, add the GameView struct:
struct GameView: View {
@State private var scene: GameScene
init(animal: AnimalType, onBack: @escaping () -> Void) {
let s = GameScene(size: CGSize(width: 800, height: 300))
s.scaleMode = .aspectFill
s.selectedAnimal = animal
s.onBackToMenu = onBack
_scene = State(initialValue: s)
}
var body: some View {
SpriteView(scene: scene)
.frame(width: 800, height: 300)
}
}
What does this code do?
The initializer creates a GameScene sized to 800x300 points and configures it with the player's chosen animal. The _scene = State(initialValue: s) pattern stores the scene in a @State wrapper so SwiftUI doesn't recreate it on every view update.
The onBackToMenu closure lets the game scene tell SwiftUI to return to the character selection screen when the player presses Escape.
- Save ContentView.swift.
Xcode will show compiler errors because GameScene.swift is still empty. The GameScene class doesn't exist yet, so references to selectedAnimal and onBackToMenu can't resolve. This is expected. You'll define the full class in the next step.
Seeing more errors than expected?
If you see errors beyond the GameScene-related ones, check that your AnimalType enum is defined outside of any struct. It must be a top-level type so all views in the file can access it.
Also verify that import SpriteKit is at the top of the file. Without it, SpriteView won't be recognized.
help me fix compiler errors in ContentView.swift
✔️ Awesome, I've got everything!
Great. Make sure you've saved ContentView.swift. The only errors should reference GameScene properties that don't exist yet.
ⓧ I'd like to double check the full code
Here's the complete ContentView.swift file. Make sure yours matches:
import SwiftUI
import SpriteKit
enum AnimalType: String, CaseIterable {
case dino = "Dino"
case cat = "Cat"
case bird = "Bird"
case frog = "Frog"
}
struct ContentView: View {
@State private var selectedAnimal: AnimalType? = nil
var body: some View {
Group {
if let animal = selectedAnimal {
GameView(animal: animal, onBack: { selectedAnimal = nil })
} else {
MenuView(onSelect: { selectedAnimal = $0 })
}
}
.frame(width: 800, height: 300)
}
}
struct MenuView: View {
let onSelect: (AnimalType) -> Void
var body: some View {
VStack(spacing: 24) {
Text("Endless Runner")
.font(.system(size: 32, weight: .bold))
Text("Choose your character")
.font(.title3)
.foregroundColor(.secondary)
HStack(spacing: 16) {
ForEach(AnimalType.allCases, id: \.self) { animal in
AnimalCard(animal: animal) { onSelect(animal) }
}
}
Text("Space or Up Arrow to jump | Esc for menu")
.font(.caption)
.foregroundColor(.gray)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(white: 0.95))
}
}
struct AnimalCard: View {
let animal: AnimalType
let action: () -> Void
private var color: Color {
switch animal {
case .dino: return .green
case .cat: return .orange
case .bird: return .blue
case .frog: return .mint
}
}
private var emoji: String {
switch animal {
case .dino: return "\u{1F996}"
case .cat: return "\u{1F431}"
case .bird: return "\u{1F426}"
case .frog: return "\u{1F438}"
}
}
var body: some View {
Button(action: action) {
VStack(spacing: 8) {
Text(emoji).font(.system(size: 36))
Text(animal.rawValue).font(.headline)
}
.frame(width: 100, height: 100)
.background(color.opacity(0.15))
.cornerRadius(12)
.overlay(RoundedRectangle(cornerRadius: 12).stroke(color, lineWidth: 2))
}
.buttonStyle(.plain)
}
}
struct GameView: View {
@State private var scene: GameScene
init(animal: AnimalType, onBack: @escaping () -> Void) {
let s = GameScene(size: CGSize(width: 800, height: 300))
s.scaleMode = .aspectFill
s.selectedAnimal = animal
s.onBackToMenu = onBack
_scene = State(initialValue: s)
}
var body: some View {
SpriteView(scene: scene)
.frame(width: 800, height: 300)
}
}
Your menu UI is fully defined. Next up, you'll build the GameScene class that brings the game to life with a ground platform, a player character, and jump physics.
Create the Game Scene with Ground and Player
Your menu looks great and passes the selected character to a GameScene. The problem is that the scene is completely empty. Selecting a character transitions to a blank screen with nothing to see or interact with.
In this step, you will build the game scene from the ground up. You will configure SpriteKit's physics engine, create a ground platform, render the player character as a shape node, and wire up keyboard and mouse input so the character can jump.
In this step, get ready to:
- Set up the SpriteKit scene with physics gravity and a ground platform.
- Create the player character with a shape determined by the selected animal.
- Handle keyboard and mouse input so the character can jump.
Set up the scene with physics and ground
Every SpriteKit game starts with a scene that defines the world. Your scene needs a physics simulation (so things fall and collide), a ground for the player to stand on, and a score label to track progress.
- Switch back to GameScene.swift in Xcode.
- Replace the empty file contents with the following code:
import SpriteKit
import AppKit
class GameScene: SKScene, SKPhysicsContactDelegate {
// Public properties set by GameView
var selectedAnimal: AnimalType = .dino
var onBackToMenu: (() -> Void)?
// Physics category bitmasks for collision detection
private let playerCategory: UInt32 = 0x1 << 0
private let groundCategory: UInt32 = 0x1 << 1
private let obstacleCategory: UInt32 = 0x1 << 2
// Game state properties
private var player: SKShapeNode!
private var ground: SKShapeNode!
private var isGameOver = false
private var score: Int = 0
private var gameSpeed: CGFloat = 5.0
private var lastUpdateTime: TimeInterval = 0
private var timeSinceLastObstacle: TimeInterval = 0
private var obstacleInterval: TimeInterval = 1.5
// UI labels
private var scoreLabel: SKLabelNode!
private var gameOverLabel: SKLabelNode?
private var restartLabel: SKLabelNode?
// Keyboard event monitor reference
private var keyMonitor: Any?
// Called when the scene is presented by SpriteView
override func didMove(to view: SKView) {
setupScene()
setupKeyboardMonitor()
}
// Called when the scene is removed - clean up the event monitor
override func willMove(from view: SKView) {
if let monitor = keyMonitor {
NSEvent.removeMonitor(monitor)
keyMonitor = nil
}
}
// Configure physics world and spawn initial elements
private func setupScene() {
backgroundColor = .white
physicsWorld.contactDelegate = self
physicsWorld.gravity = CGVector(dx: 0, dy: -20)
setupGround()
setupPlayer()
setupScoreLabel()
}
// Create a static ground platform across the bottom
private func setupGround() {
let groundSize = CGSize(width: size.width * 2, height: 40)
ground = SKShapeNode(rectOf: groundSize)
ground.fillColor = .darkGray
ground.strokeColor = .darkGray
ground.position = CGPoint(x: size.width / 2, y: 20)
ground.physicsBody = SKPhysicsBody(rectangleOf: groundSize)
ground.physicsBody?.isDynamic = false
ground.physicsBody?.categoryBitMask = groundCategory
ground.physicsBody?.collisionBitMask = playerCategory
addChild(ground)
}
// Score display in the top-right corner
private func setupScoreLabel() {
scoreLabel = SKLabelNode(fontNamed: "Menlo-Bold")
scoreLabel.fontSize = 20
scoreLabel.fontColor = .black
scoreLabel.horizontalAlignmentMode = .right
scoreLabel.position = CGPoint(x: size.width - 20, y: size.height - 40)
scoreLabel.text = "Score: 0"
addChild(scoreLabel)
}
}
What does this code do?
This creates the foundation of your game scene. The class conforms to SKPhysicsContactDelegate so it can respond when objects collide.
- The three categoryBitMask constants identify which physics body is which (player, ground, obstacle). SpriteKit uses these bit patterns to decide which objects should collide or trigger contact events.
- Gravity is set to CGVector(dx: 0, dy: -20) which is stronger than Earth's default (-9.8). This gives jumps a snappy, arcade feel.
- The ground's physics body is isDynamic = false so gravity doesn't pull it down. It stays locked in place as a platform.
- The willMove(from:) method removes the keyboard monitor to prevent memory leaks when navigating back to the menu.
Xcode will show errors for setupPlayer() and setupKeyboardMonitor() because those methods don't exist yet. You will add them next.
Seeing other errors beyond the missing methods?
- Make sure your file starts with import SpriteKit and import AppKit. AppKit is needed for NSEvent keyboard handling on macOS.
- Check that the class declaration includes SKPhysicsContactDelegate after SKScene. Without it, the contact delegate assignment will fail.
Help me fix compile errors in my GameScene class
Create the player character with animal shapes
Each animal type gets its own SKShapeNode shape. The dino is a triangle, the cat is a rounded rectangle, the bird is a circle, and the frog is an ellipse. Each also gets a physics body so gravity and collisions apply to it.
- In GameScene.swift, find the closing brace of setupScoreLabel().
- Add the following two methods below it (but still inside the class):
// Create and position the player with a physics body
private func setupPlayer() {
player = createAnimalNode(for: selectedAnimal)
player.position = CGPoint(x: 100, y: 80)
let playerSize = CGSize(width: 40, height: 40)
player.physicsBody = SKPhysicsBody(rectangleOf: playerSize)
player.physicsBody?.allowsRotation = false
player.physicsBody?.categoryBitMask = playerCategory
player.physicsBody?.collisionBitMask = groundCategory
player.physicsBody?.contactTestBitMask = obstacleCategory
player.physicsBody?.restitution = 0
addChild(player)
}
// Return a shape node styled for the given animal type
private func createAnimalNode(for animal: AnimalType) -> SKShapeNode {
let node: SKShapeNode
switch animal {
case .dino:
let path = CGMutablePath()
path.move(to: CGPoint(x: 0, y: 20))
path.addLine(to: CGPoint(x: -15, y: -15))
path.addLine(to: CGPoint(x: 15, y: -15))
path.closeSubpath()
node = SKShapeNode(path: path)
node.fillColor = .systemGreen
case .cat:
node = SKShapeNode(rectOf: CGSize(width: 30, height: 25), cornerRadius: 8)
node.fillColor = .systemOrange
case .bird:
node = SKShapeNode(circleOfRadius: 15)
node.fillColor = .systemBlue
case .frog:
node = SKShapeNode(ellipseOf: CGSize(width: 35, height: 25))
node.fillColor = .systemMint
}
node.strokeColor = node.fillColor
node.name = "player"
return node
}
What does this code do?
- setupPlayer() positions the character at x: 100 (left side of the screen) and y: 80 (above the ground). The physics body uses contactTestBitMask = obstacleCategory so SpriteKit fires a delegate event when the player touches an obstacle.
- restitution = 0 means no bounce. Without this, the character would spring back up after landing.
- createAnimalNode(for:) uses a Swift switch to build a unique shape per animal. The dino uses a CGMutablePath triangle. The cat, bird, and frog each use built-in shape initializers.
Still seeing compile errors?
- Make sure both methods are inside the class braces, not outside them. The closing } of the class should be the very last line in the file.
- One error for setupKeyboardMonitor() is still expected. You will add that method in the next substep.
Help me debug my player setup code
Set up keyboard and mouse input for jumping
SpriteKit scenes hosted in SpriteView don't reliably receive keyDown events through the normal responder chain. Instead, you will use NSEvent.addLocalMonitorForEvents to capture keyboard events at the application level.
- In GameScene.swift, find the closing brace of createAnimalNode(for:).
- Add the following methods below it (still inside the class):
// Monitor keyboard events at the app level
private func setupKeyboardMonitor() {
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
self?.handleKeyDown(event)
return event
}
}
// Route key presses to the appropriate action
private func handleKeyDown(_ event: NSEvent) {
if isGameOver {
if event.keyCode == 49 {
restartGame()
} else if event.keyCode == 53 {
DispatchQueue.main.async { [weak self] in
self?.onBackToMenu?()
}
}
return
}
// Space (49) or Up Arrow (126) triggers a jump
if event.keyCode == 49 || event.keyCode == 126 {
jump()
}
// Escape (53) returns to the menu
if event.keyCode == 53 {
DispatchQueue.main.async { [weak self] in
self?.onBackToMenu?()
}
}
}
// Mouse click as an alternative jump input
override func mouseDown(with event: NSEvent) {
if isGameOver {
restartGame()
} else {
jump()
}
}
// Apply an upward impulse if the player is on the ground
private func jump() {
guard let body = player.physicsBody else { return }
if abs(body.velocity.dy) < 5 {
body.applyImpulse(CGVector(dx: 0, dy: 35))
}
}
private func restartGame() {
// placeholder - full implementation in Step 5
}
What does this code do?
- setupKeyboardMonitor() registers a closure that runs on every key press in the app. The [weak self] capture prevents a retain cycle that would leak memory.
- Key codes 49 (Space), 126 (Up Arrow), and 53 (Escape) are macOS virtual key codes. They stay the same regardless of keyboard layout.
- The jump() method checks abs(body.velocity.dy) < 5 before applying the impulse. This prevents double-jumping. If the player is mid-air, their vertical velocity is large and the check fails.
- The onBackToMenu closure is dispatched asynchronously to avoid modifying SwiftUI state from inside an NSEvent callback.
Why use NSEvent.addLocalMonitorForEvents instead of keyDown?
When a SpriteKit scene is hosted inside SwiftUI's SpriteView, keyboard events don't reliably reach the scene's keyDown(with:) override. The first responder status gets tangled between SwiftUI's view hierarchy and the underlying SpriteKit view.
The local event monitor intercepts events at the application level before they reach the responder chain. It always works, regardless of which view has focus.
- Save GameScene.swift.
- Press Cmd+R to build and run the app.
- Click any character on the menu to enter the game scene.
- You should see a white background, a dark gray ground bar across the bottom, a "Score: 0" label in the top-right, and your animal shape sitting on the ground.
- Press Space or Up Arrow. The character should jump up and fall back down to the ground.
- Press Escape to return to the character selection menu.
Character not jumping or falling through the ground?
- If the character falls off screen, check that the ground's categoryBitMask is groundCategory and the player's collisionBitMask is groundCategory. Both must match for the player to land on the ground.
- If jumping does nothing, make sure the keyboard monitor is set up. Check that setupKeyboardMonitor() is called from didMove(to:).
- If the character bounces when it lands, verify that restitution is set to 0 on the player's physics body.
Help me debug my SpriteKit jump physics
✔️ Awesome, I've got everything!
Great. Double check you have saved GameScene.swift and your character jumps when you press Space.
ⓧ I'd like to double check the full code
Here is the complete GameScene.swift at this point:
import SpriteKit
import AppKit
class GameScene: SKScene, SKPhysicsContactDelegate {
var selectedAnimal: AnimalType = .dino
var onBackToMenu: (() -> Void)?
private let playerCategory: UInt32 = 0x1 << 0
private let groundCategory: UInt32 = 0x1 << 1
private let obstacleCategory: UInt32 = 0x1 << 2
private var player: SKShapeNode!
private var ground: SKShapeNode!
private var isGameOver = false
private var score: Int = 0
private var gameSpeed: CGFloat = 5.0
private var lastUpdateTime: TimeInterval = 0
private var timeSinceLastObstacle: TimeInterval = 0
private var obstacleInterval: TimeInterval = 1.5
private var scoreLabel: SKLabelNode!
private var gameOverLabel: SKLabelNode?
private var restartLabel: SKLabelNode?
private var keyMonitor: Any?
override func didMove(to view: SKView) {
setupScene()
setupKeyboardMonitor()
}
override func willMove(from view: SKView) {
if let monitor = keyMonitor {
NSEvent.removeMonitor(monitor)
keyMonitor = nil
}
}
private func setupScene() {
backgroundColor = .white
physicsWorld.contactDelegate = self
physicsWorld.gravity = CGVector(dx: 0, dy: -20)
setupGround()
setupPlayer()
setupScoreLabel()
}
private func setupGround() {
let groundSize = CGSize(width: size.width * 2, height: 40)
ground = SKShapeNode(rectOf: groundSize)
ground.fillColor = .darkGray
ground.strokeColor = .darkGray
ground.position = CGPoint(x: size.width / 2, y: 20)
ground.physicsBody = SKPhysicsBody(rectangleOf: groundSize)
ground.physicsBody?.isDynamic = false
ground.physicsBody?.categoryBitMask = groundCategory
ground.physicsBody?.collisionBitMask = playerCategory
addChild(ground)
}
private func setupPlayer() {
player = createAnimalNode(for: selectedAnimal)
player.position = CGPoint(x: 100, y: 80)
let playerSize = CGSize(width: 40, height: 40)
player.physicsBody = SKPhysicsBody(rectangleOf: playerSize)
player.physicsBody?.allowsRotation = false
player.physicsBody?.categoryBitMask = playerCategory
player.physicsBody?.collisionBitMask = groundCategory
player.physicsBody?.contactTestBitMask = obstacleCategory
player.physicsBody?.restitution = 0
addChild(player)
}
private func createAnimalNode(for animal: AnimalType) -> SKShapeNode {
let node: SKShapeNode
switch animal {
case .dino:
let path = CGMutablePath()
path.move(to: CGPoint(x: 0, y: 20))
path.addLine(to: CGPoint(x: -15, y: -15))
path.addLine(to: CGPoint(x: 15, y: -15))
path.closeSubpath()
node = SKShapeNode(path: path)
node.fillColor = .systemGreen
case .cat:
node = SKShapeNode(rectOf: CGSize(width: 30, height: 25), cornerRadius: 8)
node.fillColor = .systemOrange
case .bird:
node = SKShapeNode(circleOfRadius: 15)
node.fillColor = .systemBlue
case .frog:
node = SKShapeNode(ellipseOf: CGSize(width: 35, height: 25))
node.fillColor = .systemMint
}
node.strokeColor = node.fillColor
node.name = "player"
return node
}
private func setupScoreLabel() {
scoreLabel = SKLabelNode(fontNamed: "Menlo-Bold")
scoreLabel.fontSize = 20
scoreLabel.fontColor = .black
scoreLabel.horizontalAlignmentMode = .right
scoreLabel.position = CGPoint(x: size.width - 20, y: size.height - 40)
scoreLabel.text = "Score: 0"
addChild(scoreLabel)
}
private func setupKeyboardMonitor() {
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
self?.handleKeyDown(event)
return event
}
}
private func handleKeyDown(_ event: NSEvent) {
if isGameOver {
if event.keyCode == 49 {
restartGame()
} else if event.keyCode == 53 {
DispatchQueue.main.async { [weak self] in
self?.onBackToMenu?()
}
}
return
}
if event.keyCode == 49 || event.keyCode == 126 {
jump()
}
if event.keyCode == 53 {
DispatchQueue.main.async { [weak self] in
self?.onBackToMenu?()
}
}
}
override func mouseDown(with event: NSEvent) {
if isGameOver {
restartGame()
} else {
jump()
}
}
private func jump() {
guard let body = player.physicsBody else { return }
if abs(body.velocity.dy) < 5 {
body.applyImpulse(CGVector(dx: 0, dy: 35))
}
}
private func restartGame() {
// placeholder - full implementation in Step 5
}
}
Your character can jump, and gravity pulls it back down. Next up, you will spawn obstacles that scroll toward the player and trigger a game over on collision.
Add Scrolling Obstacles and Collision Detection
Your character can jump, but there is nothing to jump over. An endless runner needs obstacles that scroll toward the player at increasing speed, creating the challenge that makes the game engaging.
In this step, you will build the game loop that drives obstacle spawning and movement. You will also wire up SpriteKit's physics contact system so that hitting an obstacle triggers game over.
In this step, get ready to:
- Implement the game loop that spawns and moves obstacles.
- Create an obstacle factory that generates red rectangles with random heights.
- Add collision detection that ends the game when the player hits an obstacle.
Implement the game loop
SpriteKit calls update(_:) once per frame. This is where you drive all per-frame logic: incrementing the score, increasing game speed, deciding when to spawn obstacles, and moving existing obstacles to the left.
- In GameScene.swift, find the jump() method. Add the following update(_:) method directly below it:
override func update(_ currentTime: TimeInterval) {
guard !isGameOver else { return }
// Calculate time elapsed since last frame
let deltaTime: TimeInterval
if lastUpdateTime == 0 {
deltaTime = 0
} else {
deltaTime = currentTime - lastUpdateTime
}
lastUpdateTime = currentTime
// Skip unusually large time gaps (e.g. app was backgrounded)
guard deltaTime < 1.0 else { return }
// Increment score and update the display
score += 1
scoreLabel.text = "Score: \(score / 10)"
// Gradually increase game speed based on score
gameSpeed = 5.0 + CGFloat(score) * 0.002
// Spawn obstacles at timed intervals
timeSinceLastObstacle += deltaTime
if timeSinceLastObstacle >= obstacleInterval {
spawnObstacle()
timeSinceLastObstacle = 0
obstacleInterval = max(0.8, 1.5 - Double(score) * 0.0005)
}
// Move all obstacles leftward and remove off-screen ones
enumerateChildNodes(withName: "obstacle") { node, _ in
node.position.x -= self.gameSpeed
if node.position.x < -50 {
node.removeFromParent()
}
}
}
What does this code do?
This method is the heart of the game. SpriteKit calls it every frame (roughly 60 times per second).
- Delta time calculation ensures smooth movement regardless of frame rate. If the app was backgrounded and a huge gap occurred, the guard skips that frame.
- The raw score increments every frame, but the displayed value divides by 10 so it ticks at a readable pace.
- gameSpeed increases linearly with the score, making obstacles scroll faster over time.
- enumerateChildNodes(withName:) finds every node named "obstacle" and shifts it left by the current speed. Once it scrolls off-screen (x < -50), it gets removed to free memory.
Why track deltaTime instead of moving a fixed amount?
Frame rates can fluctuate. By tracking elapsed time, the spawn timer stays accurate even if a few frames take longer than usual. The movement itself uses a fixed speed per frame for simplicity, which works well at a steady 60fps.
- Don't build yet. The code references a spawnObstacle() method that doesn't exist. You will add it next.
Create the obstacle spawner
Each obstacle is a red rectangle with a randomized height. Taller obstacles are harder to clear, adding variety to the challenge.
- Add the spawnObstacle() method directly below the update(_:) method you just added:
private func spawnObstacle() {
// Randomize obstacle height for variety
let height = CGFloat.random(in: 30...60)
let width: CGFloat = 20
let obstacle = SKShapeNode(rectOf: CGSize(width: width, height: height))
obstacle.fillColor = .systemRed
obstacle.strokeColor = .systemRed
obstacle.name = "obstacle"
// Position just off the right edge, sitting on the ground
obstacle.position = CGPoint(x: size.width + 30, y: 40 + height / 2)
// Static body that detects contact with the player
obstacle.physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: width, height: height))
obstacle.physicsBody?.isDynamic = false
obstacle.physicsBody?.categoryBitMask = obstacleCategory
obstacle.physicsBody?.contactTestBitMask = playerCategory
obstacle.physicsBody?.collisionBitMask = 0
addChild(obstacle)
}
What does this code do?
- The height randomizes between 30 and 60 points, so each obstacle looks different.
- name = "obstacle" is how the game loop finds these nodes with enumerateChildNodes(withName:).
- isDynamic = false means gravity does not affect the obstacle. It stays in place until the game loop moves it manually.
- contactTestBitMask = playerCategory tells SpriteKit to notify the delegate when this body touches the player.
- collisionBitMask = 0 means the obstacle does not physically push anything. It only detects overlap.
- Build and run with Cmd+R.
- Select any character from the menu.
- You should see red rectangles spawning on the right edge and scrolling left across the screen. The score counter in the top-right should be ticking upward.
Don't see obstacles appearing?
- Check that spawnObstacle() is placed outside the closing brace of update(_:). If it is nested inside, the compiler will not flag it but the spawn call will not resolve.
- Make sure the obstacle.name = "obstacle" line uses exactly that string. A typo here means enumerateChildNodes(withName: "obstacle") cannot find the nodes to move them.
- help me debug why obstacles aren't spawning in my SpriteKit game
Add collision detection and game over
The obstacles scroll and the player can jump, but nothing happens when they collide. SpriteKit's SKPhysicsContactDelegate notifies your scene whenever two physics bodies with matching bitmasks make contact.
- Add the didBegin(_:) and triggerGameOver() methods below spawnObstacle() (and above the existing restartGame() placeholder):
func didBegin(_ contact: SKPhysicsContact) {
// Combine both bodies' categories to identify the collision pair
let collision = contact.bodyA.categoryBitMask | contact.bodyB.categoryBitMask
if collision == playerCategory | obstacleCategory {
triggerGameOver()
}
}
private func triggerGameOver() {
isGameOver = true
player.physicsBody?.velocity = .zero
// Display game over text
gameOverLabel = SKLabelNode(fontNamed: "Menlo-Bold")
gameOverLabel?.fontSize = 28
gameOverLabel?.fontColor = .black
gameOverLabel?.text = "Game Over!"
gameOverLabel?.position = CGPoint(x: size.width / 2, y: size.height / 2 + 20)
addChild(gameOverLabel!)
// Display restart instructions
restartLabel = SKLabelNode(fontNamed: "Menlo")
restartLabel?.fontSize = 14
restartLabel?.fontColor = .gray
restartLabel?.text = "Click or Space to restart | Esc for menu"
restartLabel?.position = CGPoint(x: size.width / 2, y: size.height / 2 - 20)
addChild(restartLabel!)
}
What does this code do?
- didBegin(_:) is called by SpriteKit whenever two bodies with overlapping contactTestBitMask values touch. The bitwise OR of both categories identifies which pair collided.
- Setting isGameOver = true makes the update(_:) guard return immediately, freezing all movement.
- Zeroing the player's velocity stops it from continuing to fall or slide after game over.
- The two labels display feedback and instructions. The handleKeyDown(_:) method you wrote earlier already checks isGameOver and routes Space to restartGame().
How does the bitmask collision system work?
Each physics body has three bitmask properties. categoryBitMask identifies what the body IS. contactTestBitMask identifies what it wants to be NOTIFIED about. collisionBitMask identifies what it physically BOUNCES off of.
The player's contactTestBitMask includes obstacleCategory, and the obstacle's contactTestBitMask includes playerCategory. When they overlap, SpriteKit fires didBegin(_:).
- Save GameScene.swift and build with Cmd+R.
- Select a character and let your player run into an obstacle without jumping.
- You should see "Game Over!" appear in the center of the screen with restart instructions below it. The score and obstacles should freeze.
Player passes through obstacles without triggering game over?
- Make sure didBegin(_:) is declared as func (not private func). SpriteKit cannot call it if it is private.
- Verify that physicsWorld.contactDelegate = self is still in your setupScene() method. Without this line, the delegate method never fires.
- Check that both the player and obstacle have matching bitmasks. The player needs contactTestBitMask = obstacleCategory and the obstacle needs contactTestBitMask = playerCategory.
- help me fix collision detection in my SpriteKit game
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete GameScene.swift at this point:
import SpriteKit
import AppKit
class GameScene: SKScene, SKPhysicsContactDelegate {
var selectedAnimal: AnimalType = .dino
var onBackToMenu: (() -> Void)?
private let playerCategory: UInt32 = 0x1 << 0
private let groundCategory: UInt32 = 0x1 << 1
private let obstacleCategory: UInt32 = 0x1 << 2
private var player: SKShapeNode!
private var ground: SKShapeNode!
private var isGameOver = false
private var score: Int = 0
private var gameSpeed: CGFloat = 5.0
private var lastUpdateTime: TimeInterval = 0
private var timeSinceLastObstacle: TimeInterval = 0
private var obstacleInterval: TimeInterval = 1.5
private var scoreLabel: SKLabelNode!
private var gameOverLabel: SKLabelNode?
private var restartLabel: SKLabelNode?
private var keyMonitor: Any?
override func didMove(to view: SKView) {
setupScene()
setupKeyboardMonitor()
}
override func willMove(from view: SKView) {
if let monitor = keyMonitor {
NSEvent.removeMonitor(monitor)
keyMonitor = nil
}
}
private func setupScene() {
backgroundColor = .white
physicsWorld.contactDelegate = self
physicsWorld.gravity = CGVector(dx: 0, dy: -20)
setupGround()
setupPlayer()
setupScoreLabel()
}
private func setupGround() {
let groundSize = CGSize(width: size.width * 2, height: 40)
ground = SKShapeNode(rectOf: groundSize)
ground.fillColor = .darkGray
ground.strokeColor = .darkGray
ground.position = CGPoint(x: size.width / 2, y: 20)
ground.physicsBody = SKPhysicsBody(rectangleOf: groundSize)
ground.physicsBody?.isDynamic = false
ground.physicsBody?.categoryBitMask = groundCategory
ground.physicsBody?.collisionBitMask = playerCategory
addChild(ground)
}
private func setupPlayer() {
player = createAnimalNode(for: selectedAnimal)
player.position = CGPoint(x: 100, y: 80)
let playerSize = CGSize(width: 40, height: 40)
player.physicsBody = SKPhysicsBody(rectangleOf: playerSize)
player.physicsBody?.allowsRotation = false
player.physicsBody?.categoryBitMask = playerCategory
player.physicsBody?.collisionBitMask = groundCategory
player.physicsBody?.contactTestBitMask = obstacleCategory
player.physicsBody?.restitution = 0
addChild(player)
}
private func createAnimalNode(for animal: AnimalType) -> SKShapeNode {
let node: SKShapeNode
switch animal {
case .dino:
let path = CGMutablePath()
path.move(to: CGPoint(x: 0, y: 20))
path.addLine(to: CGPoint(x: -15, y: -15))
path.addLine(to: CGPoint(x: 15, y: -15))
path.closeSubpath()
node = SKShapeNode(path: path)
node.fillColor = .systemGreen
case .cat:
node = SKShapeNode(rectOf: CGSize(width: 30, height: 25), cornerRadius: 8)
node.fillColor = .systemOrange
case .bird:
node = SKShapeNode(circleOfRadius: 15)
node.fillColor = .systemBlue
case .frog:
node = SKShapeNode(ellipseOf: CGSize(width: 35, height: 25))
node.fillColor = .systemMint
}
node.strokeColor = node.fillColor
node.name = "player"
return node
}
private func setupScoreLabel() {
scoreLabel = SKLabelNode(fontNamed: "Menlo-Bold")
scoreLabel.fontSize = 20
scoreLabel.fontColor = .black
scoreLabel.horizontalAlignmentMode = .right
scoreLabel.position = CGPoint(x: size.width - 20, y: size.height - 40)
scoreLabel.text = "Score: 0"
addChild(scoreLabel)
}
private func setupKeyboardMonitor() {
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
self?.handleKeyDown(event)
return event
}
}
private func handleKeyDown(_ event: NSEvent) {
if isGameOver {
if event.keyCode == 49 {
restartGame()
} else if event.keyCode == 53 {
DispatchQueue.main.async { [weak self] in
self?.onBackToMenu?()
}
}
return
}
if event.keyCode == 49 || event.keyCode == 126 {
jump()
}
if event.keyCode == 53 {
DispatchQueue.main.async { [weak self] in
self?.onBackToMenu?()
}
}
}
override func mouseDown(with event: NSEvent) {
if isGameOver {
restartGame()
} else {
jump()
}
}
private func jump() {
guard let body = player.physicsBody else { return }
if abs(body.velocity.dy) < 5 {
body.applyImpulse(CGVector(dx: 0, dy: 35))
}
}
override func update(_ currentTime: TimeInterval) {
guard !isGameOver else { return }
let deltaTime: TimeInterval
if lastUpdateTime == 0 {
deltaTime = 0
} else {
deltaTime = currentTime - lastUpdateTime
}
lastUpdateTime = currentTime
guard deltaTime < 1.0 else { return }
score += 1
scoreLabel.text = "Score: \(score / 10)"
gameSpeed = 5.0 + CGFloat(score) * 0.002
timeSinceLastObstacle += deltaTime
if timeSinceLastObstacle >= obstacleInterval {
spawnObstacle()
timeSinceLastObstacle = 0
obstacleInterval = max(0.8, 1.5 - Double(score) * 0.0005)
}
enumerateChildNodes(withName: "obstacle") { node, _ in
node.position.x -= self.gameSpeed
if node.position.x < -50 {
node.removeFromParent()
}
}
}
private func spawnObstacle() {
let height = CGFloat.random(in: 30...60)
let width: CGFloat = 20
let obstacle = SKShapeNode(rectOf: CGSize(width: width, height: height))
obstacle.fillColor = .systemRed
obstacle.strokeColor = .systemRed
obstacle.name = "obstacle"
obstacle.position = CGPoint(x: size.width + 30, y: 40 + height / 2)
obstacle.physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: width, height: height))
obstacle.physicsBody?.isDynamic = false
obstacle.physicsBody?.categoryBitMask = obstacleCategory
obstacle.physicsBody?.contactTestBitMask = playerCategory
obstacle.physicsBody?.collisionBitMask = 0
addChild(obstacle)
}
func didBegin(_ contact: SKPhysicsContact) {
let collision = contact.bodyA.categoryBitMask | contact.bodyB.categoryBitMask
if collision == playerCategory | obstacleCategory {
triggerGameOver()
}
}
private func triggerGameOver() {
isGameOver = true
player.physicsBody?.velocity = .zero
gameOverLabel = SKLabelNode(fontNamed: "Menlo-Bold")
gameOverLabel?.fontSize = 28
gameOverLabel?.fontColor = .black
gameOverLabel?.text = "Game Over!"
gameOverLabel?.position = CGPoint(x: size.width / 2, y: size.height / 2 + 20)
addChild(gameOverLabel!)
restartLabel = SKLabelNode(fontNamed: "Menlo")
restartLabel?.fontSize = 14
restartLabel?.fontColor = .gray
restartLabel?.text = "Click or Space to restart | Esc for menu"
restartLabel?.position = CGPoint(x: size.width / 2, y: size.height / 2 - 20)
addChild(restartLabel!)
}
private func restartGame() {
// placeholder - full implementation in Step 5
}
}
Your game now has real challenge. Obstacles scroll, collisions end the game, and the score ticks upward. Next, you will add progressive difficulty and implement the restart flow so players can try again.
Implement Scoring and Progressive Difficulty
Your game can detect collisions and show a Game Over screen. The score ticks up and obstacles arrive faster as you play longer.
There is one critical problem. After a collision, the game freezes permanently. The restartGame() method is still a placeholder, so pressing Space or clicking does nothing. In this step, you will implement the full restart flow that wipes the scene and rebuilds it from scratch.
In this step, get ready to:
- Understand how the scoring and progressive difficulty system works.
- Implement the full restart method that resets all game state.
- Verify the complete game loop including restart and back-to-menu flows.
Review the scoring and difficulty system
Your update(_:) method already handles scoring and progressive difficulty. Let's walk through how these pieces create a game that gets harder over time.
- In GameScene.swift, scroll to the update(_:) method and review the scoring logic.
How does progressive difficulty work?
Three values work together to make the game harder the longer you survive:
- score += 1 increments a raw counter every frame. The displayed value is score / 10, which gives a human-readable pace (roughly 6 points per second at 60fps).
- gameSpeed = 5.0 + CGFloat(score) * 0.002 makes obstacles scroll faster the longer you play. The multiplier is small enough that difficulty feels gradual.
- obstacleInterval = max(0.8, 1.5 - Double(score) * 0.0005) spawns obstacles more frequently. The max(0.8, ...) floor prevents impossible-to-dodge spacing.
Why divide the score by 10?
The Chrome Dino game increments score based on distance traveled, not frames rendered. Dividing by 10 smooths out the display so it does not jump too fast. It also decouples the visual score from the frame rate.
Implement the restart flow
When the player dies, the game needs to completely reset. A proper restart must clear every node from the scene, zero out all timing and score state, then rebuild the ground, player, and score label from scratch.
- In GameScene.swift, scroll to the restartGame() method near the bottom of the file.
- Find this placeholder:
private func restartGame() {
// placeholder - full implementation in Step 5
}
- Replace the entire restartGame() method with this full implementation:
private func restartGame() {
// Remove all nodes (player, ground, obstacles, labels)
removeAllChildren()
// Reset all game state to starting values
isGameOver = false
score = 0
gameSpeed = 5.0
lastUpdateTime = 0
timeSinceLastObstacle = 0
obstacleInterval = 1.5
gameOverLabel = nil
restartLabel = nil
// Rebuild the scene from scratch
setupScene()
}
What does this code do?
removeAllChildren() wipes every SKNode from the scene. This includes the player, ground, obstacles, score label, and game over labels.
Setting gameOverLabel and restartLabel to nil releases the references after their nodes are removed. Finally, setupScene() rebuilds everything fresh, reusing the same setup methods from didMove(to:).
- Save GameScene.swift.
Why reset lastUpdateTime to 0?
If you skip resetting lastUpdateTime, the first frame after restart calculates a huge deltaTime (the entire duration of the previous game). This spawns a burst of obstacles instantly. Setting it to 0 triggers the if lastUpdateTime == 0 guard, which produces a zero delta for the first frame.
If you see a wall of obstacles appear right after restarting, double check that lastUpdateTime is reset to 0.
Verify the complete game loop
The game now has every piece connected. Let's test the full lifecycle: menu, gameplay, game over, restart, and back-to-menu.
- Build and run by pressing Cmd+R.
- Select any character from the menu.
- Watch the score counter in the top right. It should increment steadily.
- Let your character hit an obstacle. You should see "Game Over!" and restart instructions.
- Press Space or click the window to restart. The scene should reset with your score back at 0.
- Press Escape to return to the character selection menu.
Why does Escape use DispatchQueue.main.async?
The onBackToMenu closure modifies SwiftUI state (selectedAnimal = nil). The keyboard handler runs inside an NSEvent monitor callback, which is not guaranteed to be on the main thread's run loop at the right time for SwiftUI updates. Dispatching async ensures the state change happens safely.
What about memory leaks?
The willMove(from:) method calls NSEvent.removeMonitor(monitor) when the scene is removed from its view. Without this cleanup, the keyboard monitor keeps a strong reference to the scene and it is never deallocated. Every time you go back to the menu and select a character, a new scene is created. Without removal, old monitors stack up.
Game not restarting on Space?
- Make sure the if event.keyCode == 49 check is inside the if isGameOver block in handleKeyDown. If it is outside, Space triggers a jump instead of restart.
- Verify that restartGame() sets isGameOver = false. If this stays true, the update(_:) method's guard returns immediately and nothing moves.
- help me debug why restarting does not work after game over
Play a few rounds and notice how obstacles arrive faster and scroll quicker as your score climbs. The floor of 0.8 seconds between obstacles keeps the game challenging without becoming impossible.
✔️ Awesome, I've got everything!
Great. Double check you have saved GameScene.swift and the game restarts cleanly after game over.
ⓧ I'd like to double check the full code
Here is the complete GameScene.swift at this point:
import SpriteKit
import AppKit
class GameScene: SKScene, SKPhysicsContactDelegate {
var selectedAnimal: AnimalType = .dino
var onBackToMenu: (() -> Void)?
private let playerCategory: UInt32 = 0x1 << 0
private let groundCategory: UInt32 = 0x1 << 1
private let obstacleCategory: UInt32 = 0x1 << 2
private var player: SKShapeNode!
private var ground: SKShapeNode!
private var isGameOver = false
private var score: Int = 0
private var gameSpeed: CGFloat = 5.0
private var lastUpdateTime: TimeInterval = 0
private var timeSinceLastObstacle: TimeInterval = 0
private var obstacleInterval: TimeInterval = 1.5
private var scoreLabel: SKLabelNode!
private var gameOverLabel: SKLabelNode?
private var restartLabel: SKLabelNode?
private var keyMonitor: Any?
override func didMove(to view: SKView) {
setupScene()
setupKeyboardMonitor()
}
override func willMove(from view: SKView) {
if let monitor = keyMonitor {
NSEvent.removeMonitor(monitor)
keyMonitor = nil
}
}
private func setupScene() {
backgroundColor = .white
physicsWorld.contactDelegate = self
physicsWorld.gravity = CGVector(dx: 0, dy: -20)
setupGround()
setupPlayer()
setupScoreLabel()
}
private func setupGround() {
let groundSize = CGSize(width: size.width * 2, height: 40)
ground = SKShapeNode(rectOf: groundSize)
ground.fillColor = .darkGray
ground.strokeColor = .darkGray
ground.position = CGPoint(x: size.width / 2, y: 20)
ground.physicsBody = SKPhysicsBody(rectangleOf: groundSize)
ground.physicsBody?.isDynamic = false
ground.physicsBody?.categoryBitMask = groundCategory
ground.physicsBody?.collisionBitMask = playerCategory
addChild(ground)
}
private func setupPlayer() {
player = createAnimalNode(for: selectedAnimal)
player.position = CGPoint(x: 100, y: 80)
let playerSize = CGSize(width: 40, height: 40)
player.physicsBody = SKPhysicsBody(rectangleOf: playerSize)
player.physicsBody?.allowsRotation = false
player.physicsBody?.categoryBitMask = playerCategory
player.physicsBody?.collisionBitMask = groundCategory
player.physicsBody?.contactTestBitMask = obstacleCategory
player.physicsBody?.restitution = 0
addChild(player)
}
private func createAnimalNode(for animal: AnimalType) -> SKShapeNode {
let node: SKShapeNode
switch animal {
case .dino:
let path = CGMutablePath()
path.move(to: CGPoint(x: 0, y: 20))
path.addLine(to: CGPoint(x: -15, y: -15))
path.addLine(to: CGPoint(x: 15, y: -15))
path.closeSubpath()
node = SKShapeNode(path: path)
node.fillColor = .systemGreen
case .cat:
node = SKShapeNode(rectOf: CGSize(width: 30, height: 25), cornerRadius: 8)
node.fillColor = .systemOrange
case .bird:
node = SKShapeNode(circleOfRadius: 15)
node.fillColor = .systemBlue
case .frog:
node = SKShapeNode(ellipseOf: CGSize(width: 35, height: 25))
node.fillColor = .systemMint
}
node.strokeColor = node.fillColor
node.name = "player"
return node
}
private func setupScoreLabel() {
scoreLabel = SKLabelNode(fontNamed: "Menlo-Bold")
scoreLabel.fontSize = 20
scoreLabel.fontColor = .black
scoreLabel.horizontalAlignmentMode = .right
scoreLabel.position = CGPoint(x: size.width - 20, y: size.height - 40)
scoreLabel.text = "Score: 0"
addChild(scoreLabel)
}
private func setupKeyboardMonitor() {
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
self?.handleKeyDown(event)
return event
}
}
private func handleKeyDown(_ event: NSEvent) {
if isGameOver {
if event.keyCode == 49 {
restartGame()
} else if event.keyCode == 53 {
DispatchQueue.main.async { [weak self] in
self?.onBackToMenu?()
}
}
return
}
if event.keyCode == 49 || event.keyCode == 126 {
jump()
}
if event.keyCode == 53 {
DispatchQueue.main.async { [weak self] in
self?.onBackToMenu?()
}
}
}
override func mouseDown(with event: NSEvent) {
if isGameOver {
restartGame()
} else {
jump()
}
}
private func jump() {
guard let body = player.physicsBody else { return }
if abs(body.velocity.dy) < 5 {
body.applyImpulse(CGVector(dx: 0, dy: 35))
}
}
override func update(_ currentTime: TimeInterval) {
guard !isGameOver else { return }
let deltaTime: TimeInterval
if lastUpdateTime == 0 {
deltaTime = 0
} else {
deltaTime = currentTime - lastUpdateTime
}
lastUpdateTime = currentTime
guard deltaTime < 1.0 else { return }
score += 1
scoreLabel.text = "Score: \(score / 10)"
gameSpeed = 5.0 + CGFloat(score) * 0.002
timeSinceLastObstacle += deltaTime
if timeSinceLastObstacle >= obstacleInterval {
spawnObstacle()
timeSinceLastObstacle = 0
obstacleInterval = max(0.8, 1.5 - Double(score) * 0.0005)
}
enumerateChildNodes(withName: "obstacle") { node, _ in
node.position.x -= self.gameSpeed
if node.position.x < -50 {
node.removeFromParent()
}
}
}
private func spawnObstacle() {
let height = CGFloat.random(in: 30...60)
let width: CGFloat = 20
let obstacle = SKShapeNode(rectOf: CGSize(width: width, height: height))
obstacle.fillColor = .systemRed
obstacle.strokeColor = .systemRed
obstacle.name = "obstacle"
obstacle.position = CGPoint(x: size.width + 30, y: 40 + height / 2)
obstacle.physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: width, height: height))
obstacle.physicsBody?.isDynamic = false
obstacle.physicsBody?.categoryBitMask = obstacleCategory
obstacle.physicsBody?.contactTestBitMask = playerCategory
obstacle.physicsBody?.collisionBitMask = 0
addChild(obstacle)
}
func didBegin(_ contact: SKPhysicsContact) {
let collision = contact.bodyA.categoryBitMask | contact.bodyB.categoryBitMask
if collision == playerCategory | obstacleCategory {
triggerGameOver()
}
}
private func triggerGameOver() {
isGameOver = true
player.physicsBody?.velocity = .zero
gameOverLabel = SKLabelNode(fontNamed: "Menlo-Bold")
gameOverLabel?.fontSize = 28
gameOverLabel?.fontColor = .black
gameOverLabel?.text = "Game Over!"
gameOverLabel?.position = CGPoint(x: size.width / 2, y: size.height / 2 + 20)
addChild(gameOverLabel!)
restartLabel = SKLabelNode(fontNamed: "Menlo")
restartLabel?.fontSize = 14
restartLabel?.fontColor = .gray
restartLabel?.text = "Click or Space to restart | Esc for menu"
restartLabel?.position = CGPoint(x: size.width / 2, y: size.height / 2 - 20)
addChild(restartLabel!)
}
private func restartGame() {
removeAllChildren()
isGameOver = false
score = 0
gameSpeed = 5.0
lastUpdateTime = 0
timeSinceLastObstacle = 0
obstacleInterval = 1.5
gameOverLabel = nil
restartLabel = nil
setupScene()
}
}
Your endless runner is fully playable. You have a character selection menu, physics-based jumping, scrolling obstacles, progressive difficulty, and a complete restart loop. Next up, you will tackle the Secret Mission and add a day/night visual cycle.
Secret mission
Add Day/Night Mode at Score Milestones
The real Chrome Dino game inverts its colors at 700 points, creating a visual shift that rewards long runs. Can you add a day/night cycle that toggles your game's theme every 50 displayed points?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally on your Mac, so there are no ongoing costs.
Resources you used:
- The EndlessRunner Xcode project folder on your Mac.
Keep everything running
No action needed. Choose this if you're still actively building or want to keep experimenting with the game.
- Your EndlessRunner project stays in your Xcode recents for quick access.
- You can reopen it anytime from Xcode's welcome screen or by double-clicking the .xcodeproj file.
Pause - I'll come back to this later
Since this project runs locally with no background processes, there is nothing to pause. Your project files sit on disk and use no memory or CPU when Xcode is closed.
- Quit Xcode if it is still open.
- Your project files remain on disk and are ready whenever you want to return.
Delete - I don't want to use this again
Remove the project folder entirely to free up disk space.
- Quit Xcode if it is still open.
- Delete the EndlessRunner project folder by running this command in Terminal:
rm -rf ~/Desktop/EndlessRunner
- If you saved the project somewhere other than your Desktop, navigate to that location and delete the EndlessRunner folder.
- Xcode itself stays installed since it was a prerequisite for this project.
Nice Work!
Nice Work!
You just built a fully playable macOS endless runner game from scratch. Your app combines a SwiftUI character selection menu with a SpriteKit game scene powered by real physics.
You've learned how to:
- Built a native macOS game using SwiftUI for the menu and SpriteView to bridge into a SpriteKit game scene with four selectable animal characters.
- Implemented gravity-based jump physics with SKPhysicsBody and used SKPhysicsContactDelegate to detect collisions between the player and obstacles.
- Created a complete game loop with progressive difficulty, live scoring, keyboard and mouse input via NSEvent monitoring, and restart functionality.
- Secret Mission: Added a day/night visual cycle that toggles the game's color theme every 50 displayed score points.
Ready to quiz yourself?