03 - Games · SpriteKit and SwiftUI Live on the App Store

Take Flight

A Belle Isle survival game built in six weeks at the Apple Developer Academy, where five mini games feed one shared score and hunger loop.

Take Flight app icon
Demo

One run, five challenges

Hunger, score, and nest progress carry across every mini game, which is what makes a run feel like one game instead of five.

Overview

Take Flight is a 5-in-1 mini game collection set on Belle Isle, built by a team of five in six weeks. The loop is survive, grow your nest, and chase a high score across challenges built around memory, coordination, speed, and reflexes.

It was my first SpriteKit project. I owned the core game loop, Game Center integration, the virtual controller, and tutorial mode.

The problem

Five separate mini games can very easily feel like a playlist of unrelated screens rather than one game with stakes.

  • Each mini game had its own scene and its own logic, but all of them needed to read and write the same hunger, score, and progression state.
  • Input had to work as a touch joystick on device and as keyboard control in the simulator, both driving the same movement system.
  • I had never used SpriteKit, so the scene graph, physics bodies, update loop, and camera all had to be learned while the build was already running.

Approach

  1. 01

    Learn it system by system

    Rather than reading the whole framework first, I built small isolated tests for physics, cameras, and collision, then wired each proven piece into the real game.

  2. 02

    One state across five scenes

    A central RunState model that every scene reads from and writes to. Scene transitions hand the same model forward, so hunger and score carry across challenges.

  3. 03

    One input layer

    A custom SwiftUI joystick normalizes drag into a CGPoint velocity clamped to the joystick radius. Keyboard input writes the same property, so SpriteKit only ever reads one value.

  4. 04

    Persist as you play

    The update loop accumulates deltas and writes player position, camera position, and hunger on an interval, so a run survives being interrupted.

Technical highlights

  • Accumulator-driven updates - Position saves and hunger decay run off separate time accumulators in the update loop rather than per frame, keeping writes cheap.
  • Camera and player clamping - Both the player and the following camera clamp to map bounds each frame, so the world never shows its edges.
  • Game Center - Full authentication, leaderboards, and achievement reporting, wired in early enough that progression could be designed around it.
  • SwiftUI and SpriteKit split - Menus, HUD, and tutorial live in SwiftUI while gameplay stays in SpriteKit, with a clear boundary about which layer owns what.

Screens

Take Flight run summary

The Run

Survive, feed, nest, and push the score higher on Belle Isle.

Take Flight quick challenges

Quick Challenges

Rotating mini games testing memory, coordination, speed, and reflexes.

Take Flight nest building

Build Your Nest

Collect materials around the island and find the right nesting tree.

Take Flight predator encounter

Avoid Predators

Dodge threats around the island to keep the run alive.

Code

Game Center - Auth + Achievements

Swift

Authenticates the local player with Game Center on launch and reports achievement completions with a native banner.

// Call once at app start or main menu.
@MainActor
func authenticateLocalPlayer(presentingViewController: UIViewController?) async {
    let localPlayer = GKLocalPlayer.local
    localPlayer.authenticateHandler = { viewController, error in
        if let viewController, let presentingViewController {
            presentingViewController.present(viewController, animated: true)
            return
        }
        
        if let error {
            print("Game Center auth error: \(error.localizedDescription)")
            return
        }
        
        self.isAuthenticated = localPlayer.isAuthenticated
    }
}

// Set an achievement to 100% immediately.
func completeAchievement(id: String, showBanner: Bool = true) async {
    guard GKLocalPlayer.local.isAuthenticated else { return }
    
    let achievement = GKAchievement(identifier: id)
    achievement.percentComplete = 100
    achievement.showsCompletionBanner = showBanner
    
    do {
        try await GKAchievement.report([achievement])
    } catch {
        print("Achievement report error: \(error.localizedDescription)")
    }
}

Custom On-Screen Joystick

Swift

A SwiftUI joystick built with DragGesture that clamps input to a circle radius and writes a normalized CGPoint velocity into the shared ViewModel for SpriteKit to read each frame.

// Custom Joystick
ZStack {
    Circle() // Background
        .fill(.white.opacity(0.3))
        
    
    Circle() // Thumbstick
        .fill(.white.opacity(0.8))
        .frame(width: radius, height: radius)
        .offset(x: fingerLocation.x, y: fingerLocation.y)
        .gesture(
            DragGesture(minimumDistance: 0)
                .onChanged { value in
                    isDragging = true

                    let dx = value.translation.width
                    let dy = value.translation.height

                    // Clamp to joystick radius
                    let distance = hypot(dx, dy)
                    let angle = atan2(dy, dx)
                    let clamped = min(distance, radius)

                    // Knob position inside the base circle
                    let knob = CGPoint(x: cos(angle) * clamped, y: sin(angle) * clamped)
                    fingerLocation = knob

                    // Normalize and flip Y so up is positive in SpriteKit
                    viewModel.joystickVelocity = CGPoint(x: knob.x / radius, y: -knob.y / radius)
                }
                .onEnded  { _ in
                    isDragging = false
                    fingerLocation = .zero
                    viewModel.joystickVelocity = .zero
                }
        )
    
    
}
.frame(width: radius * 2, height: radius * 2)
.contentShape(Circle())

Tutorial Mode

Swift

A RunState enum drives tutorial, active, and game-over phases. Contextual onboarding sheets fire at the right moments and dismiss cleanly into the active run.

if viewModel?.tutorialIsOn == true, viewModel?.inventoryFullOnce == false {
    viewModel?.showMainGameInstructions(type: .nestBuilding)
    viewModel?.inventoryFullOnce = true
}

enum RunState {
    case tutorial
    case active
    case gameOver
}

@Published private(set) var state: RunState = .tutorial

func completeTutorial() {
    state = .active
    showTutorialOverlay = false
}

func restartToTutorial() {
    state = .tutorial
    showTutorialOverlay = true
}

struct MainOnboardingView: View {
    @ObservedObject var viewModel: MainGameView.ViewModel
    @Environment(\.dismiss) var dismiss
    let type: MainGameView.ViewModel.InstructionType

    var body: some View {
        VStack(spacing: 16) {
            Text("Tutorial").font(.system(.title, design: .rounded)).bold()
            Text(viewModel.mainInstructionText(for: type))
                .multilineTextAlignment(.center)

            let resources = viewModel.mainInstructionImage(for: type)
            if let imageName = resources.first {
                Image(imageName).resizable().scaledToFit()
            }

            Button("Start") { dismiss() }
                .buttonStyle(.borderedProminent)
        }
        .presentationDetents([.medium])
    }
}

Core Game Loop

Swift

The SpriteKit update loop clamps delta time to a safe range, ticks down hunger on an accumulator, persists player position every second, then drives movement and camera follow.

override func update(_ currentTime: TimeInterval) {
    handleKeyboardMapInput()
    if viewModel?.isMapMode == true { return }

    viewModel?.currentMessage = ""

    if lastUpdateTime == 0 { lastUpdateTime = currentTime }
    let rawDelta: CGFloat = CGFloat(currentTime - lastUpdateTime)
    let deltaTime = min(max(rawDelta, 1.0/120.0), 1.0/30.0)
    lastUpdateTime = currentTime

    positionPersistAccumulator += deltaTime
    if positionPersistAccumulator >= 1.0 {
        positionPersistAccumulator = 0
        if let player = childNode(withName: "userBird") {
            viewModel?.savedPlayerPosition = player.position
        }
        viewModel?.savedCameraPosition = cameraNode.position
        viewModel?.saveState()
    }

    healthAccumulator += deltaTime
    if healthAccumulator >= 35.0 {
        healthAccumulator = 0
        if let current = viewModel?.hunger, current > 0 { viewModel?.hunger = current - 1 }
    }

    guard let player = childNode(withName: "userBird") else { return }

    updatePlayerPosition(deltaTime: deltaTime)
    clampPlayerToMap()
    updateCameraFollow(target: player.position, deltaTime: deltaTime)
    clampCameraToMap()
}

What is next

Next, I'd add adaptive difficulty so the game adjusts based on how you're playing, things like predator pressure, timers, and spawn rates. I'd also add more little milestone moments so progression feels clearer between the big goals. And I'd start tracking a few more stats besides score and hunger (time survived, nests completed, failed attempts) so I can balance the difficulty and pacing using real numbers instead of guessing.

Outcome
5 Mini games
6 Weeks to ship
5 Person team
1 Shared run loop

The final build feels like one survival game rather than a bundle of mini games, and that came down to a single shared state model more than any individual scene. Learning SpriteKit under a six week deadline also changed how I approach unfamiliar frameworks: isolate the piece, prove it works, then integrate, instead of trying to understand everything before writing anything.