Data from SpriteKit to SwiftUI with Delegation

Data from SpriteKit to SwiftUI with Delegation

This reference article covers how you can build communication between your SpriteKit scene and a SwiftUI view using the Delegate pattern.

In the article Using SpriteKit in a SwiftUI Project, we covered how to use a SwiftUI based application to start working on a simple SpriteKit game. This foundation is enough to take our first steps, but as soon as your project starts growing you might want to take advantage of the interface elements and flexibility that SwiftUI offers to build the interfaces of your game and even the user interface of your game.

With this comes the question, how do establish communication between my SpriteKit Scenes and my SwiftUI Views? Let's have a look.

There are different ways to achieve this goal, each with its pros and cons. In this article, we are going to cover how to update the interface of our game, made with SwiftUI, based on events that happen inside our game scene using delegation.

Delegation is a design pattern very common in iOS development. It consists of delegating the responsibility of implementing certain aspects of a task to an object. In our example, we are going to delegate the responsibility of dealing with the task of increasing the score of the game to our SwiftUI view, instead of letting the Game Scene handle it.

The Starting Point

As an example, we are going to use a simple game project in which squares will appear on random places in the screen and your goal is to touch them to make them disappear and score points by doing it.

The SwiftUI View

The SwiftUI View that hosts your game scene will be responsible to define the dimensions of the game scene and presenting the game user interface.

import SwiftUI
// 1. Import SpriteKit to have access to the SpriteView(_:) View
import SpriteKit

struct ContentView: View {
    // 2. Getting the dimensions of the screen
    var screenWidth: CGFloat { UIScreen.main.bounds.width }
    var screenHeight: CGFloat { UIScreen.main.bounds.height }

    // 3. Set the dimensions of the game scene
    var gameSceneWidth: CGFloat { screenWidth }
    var gameSceneHeight: CGFloat { screenHeight - 100 }
    
	// 4. Initialize the Game Scene
    var squareGameGameScene: SquareGameGameScene {
        let scene = SquareGameGameScene()
        
        scene.size = CGSize(width: gameSceneWidth, height: gameSceneHeight)
        scene.scaleMode = .fill
        
        return scene
    }
    
    var body: some View {
		// You can also use a VStack to organize your user interface
        ZStack(alignment: .top) {
			// 5. Use the SpriteView from SpriteKit frameworks to present
			// your game scene
            SpriteView(scene: self.squareGameGameScene)
                .frame(width: gameSceneWidth, height: gameSceneHeight)
            // 6. Presents the score of the player
            Text("Score: \\(0)")
                .font(.headline).fontWeight(.bold)
                .padding().background(Color.white).cornerRadius(10)
                .overlay(RoundedRectangle(cornerRadius: 10).stroke(lineWidth: 4.0))
        }
    }
}
  1. Import SpriteKit to have access to the SpriteView view.
  2. It’s important to know the dimensions of the screen, so you can give the proper dimensions to your game scene, once you initialize it.
  3. If you want, you can create computed variables that will calculate the dimensions of the game scene based on the frame, so you can have more control over the exact dimensions of the game scene. For this example, the height of the game scene won’t be the same as the device screen for example.
  4. Initializing the game scene as a computed property allows you to set up specific properties of the game scene.
  5. Present the game scene using the SpriteView view. Remember to set up its frame to the same width and height of the game scene, to avoid distortion.
  6. The Text View will present the score of the player (number of squares the player taps).

An interesting advantage to keep the interface elements on the SwiftUI view, instead of creating them on the game scene, is that you can take advantage of modifiers to customize them, as well as the flexibility of working with SwiftUI to position the interface elements on the screen.

The Game Scene

Our game scene is quite simple. Its job is to keep track of how much time has passed since the last time a square was created and every couple of seconds creating a new one. We are keeping track of the time and spawning new squares in the update(_:) function, but we could set it up as an SKAction as well. To better organize the code we will divide different parts of the game into extensions.

import SpriteKit

// A struct to store values that repeat often in the code
struct GameSizes {
    static let squareSize: CGFloat = 40.0
}

class SquareGameGameScene: SKScene {
    // 1. A variable to keep track of how much time since the last update cycle
    private var lastTimeSpawnedSquare: TimeInterval = 0.0
    
	// 2. Set up the game once the Game Scene is presented
    override func didMove(to view: SKView) {
        self.setUpGame()
    }

    override func update(_ currentTime: TimeInterval) {
        // 3. Every 2 seconds a new square is created and placed on the screen
        if self.lastTimeSpawnedSquare == 0.0 { self.lastTimeSpawnedSquare = currentTime }
        let timePassed = currentTime - self.lastTimeSpawnedSquare

        if timePassed >= 2 {
            self.spawnNewSquare()
            // Reseting the timer
            self.lastTimeSpawnedSquare = currentTime
        }
    }

}
  1. The lastTimeUpdate variable will keep track of when was the last time that a squared was placed on the game scene.
  2. The didMove(to:) method will set up the game scene once it is presented
  3. In the update(_:) method we will keep track of time to spawn a new square on the screen every two seconds.

The first extension will host the functions to set up the game. For this example, we just need to set the background color to white, but here you can also set up the physics world, for example, or have a separate function for each part of the game you are setting up.

// MARK: - Setting up the game
extension SquareGameGameScene {
    private func setUpGame() {
        backgroundColor = SKColor.white
    }
}

The next extension will host the functions to handle the interactions with the game scene. Here, every time the player touches the screen we will check if the player touched one of the squares. If so, the squared will be destroyed!

// MARK: - Handling Interaction
extension SquareGameGameScene {

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
		// Gets the first touch object in the set of touches
        guard let touch = touches.first else { return }
        // Gets the position of the touch in the game scene
        let touchPosition = touch.location(in: self)

        // 1. Checks if there is a SKShapeNode where the player touched.
        if let selectedNode = nodes(at: touchPosition).first as? SKShapeNode {
			// 2. If so, calls a function to destroy the node
            self.destroy(selectedNode)
        }
    }
    
}
  1. Check if the node that was touched is of the SKShapeNode type, the type we use to create our squares.
  2. If so, we call a method responsible to destroy the squares and handle with the consequences of destroying them.

This extension hosts the functions that implement the square creation dynamics. The function spawnNewSquare() is the function called every two seconds to place a new square on the screen.

// MARK: - Square Creation Dynamics
extension SquareGameGameScene {
    
	// Called every two seconds in the update(:_) function.
    private func spawnNewSquare() {
        let square = self.getNewSquare()
        square.position = self.getRandomPosition()
        
        addChild(square)
    }
    
	// Returns a random position based on the dimensions of the game scene
	// and the dimensions of the squares.
    private func getRandomPosition() -> CGPoint {
        let screeWidth = self.frame.width
        let screeHeight = self.frame.height
        
        let minX = 0 + (GameSizes.squareSize * 2)
        let maxX = screeWidth - (GameSizes.squareSize * 2)
        
        let minY = 0 + (GameSizes.squareSize * 2)
        let maxY = screeHeight - (GameSizes.squareSize * 2)
        
        let randomX = CGFloat.random(in: minX...maxX)
        let randomY = CGFloat.random(in: minY...maxY)
        
        return CGPoint(x: randomX, y: randomY)
    }
    
	// Creates a SKShapeNode to be used in the game.
    private func getNewSquare() -> SKShapeNode {
        let newSquare = SKShapeNode(rectOf: CGSize(width: GameSizes.squareSize, height: GameSizes.squareSize))
        newSquare.fillColor = SKColor.red
        newSquare.strokeColor = SKColor.black
        newSquare.lineWidth = 4.0
        
        return newSquare
    }
    
}

Here we are handling the destruction of the squares. Right now it is super simple, but you can also add some actions to make it a bit more interesting.

// MARK: - Square Destruction Dynamics
extension SquareGameGameScene {
    private func destroy(_ square: SKShapeNode) {
        square.removeFromParent()
    }
}

You can check the complete code of this example in the following GitHub Gist.

Example code of a simple integration between SwiftUI and SpriteKit using the delegate pattern.
Example code of a simple integration between SwiftUI and SpriteKit using the delegate pattern. - SquareGame-After.swift

Using delegation to communicate between the View and the SKScene

In the example above, the main gameplay loop works fine, but there is one thing missing. Once the player touches on a square and the square is destroyed we want to update the score on our user interface, but this is not happening right now.

To achieve this behavior we are going to implement the delegate pattern. To do it we need to:

  1. Create a protocol that defines the expected properties and methods the object we will delegate the responsibility to update the game score must have;
  2. Create a delegate property in our game scene of the type of our protocol;
  3. Create a method in our game scene responsible to call the delegate once we score a point;
  4. Conform our ContentView to the protocol we created;
  5. Implement the scoring logic in our View.

Step 1: Let’s create our protocol

It will be called SquareGameLogicDelegate and it will:

  • Define a read-only property called totalScore.
  • Define a mutating function called addPoint().
protocol SquareGameLogicDelegate {
    var totalScore: Int { get }
    
    mutating func addPoint() -> Void
}

Step 2: Creating the delegate property in the game scene

Once we have our protocol created, add a property called gameLogicDelegate of the type optional SquareGameLogicDelegate to the SquareGameGameScene class and initialize it as nil.

class SquareGameGameScene: SKScene {

    var gameLogicDelegate: SquareGameLogicDelegate? = nil

	...
}

Step 3: Creating the addPoint() method

Afterward, create a function called addPoint(), that will check if the gameLogicDelegate exists and, if so, call the addPoint() function it implements.

// MARK: - Score Dynamics
extension SquareGameGameScene {
    private func addPoint() {
        if var gameLogicDelegate = self.gameLogicDelegate {
            gameLogicDelegate.addPoint()
        }
    }
}

This function will be called right after we destroy the square that was touched, in the touchesBegan(_:with:) method.

// MARK: - Handling Interaction
extension SquareGameGameScene {
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let touch = touches.first else { return }
        
        let touchPosition = touch.location(in: self)
        
        if let selectedNode = nodes(at: touchPosition).first as? SKShapeNode {
            self.destroy(selectedNode)
            self.addPoint()
        }
    }
    
}

Step 4 and 5: Conforming the View to the SquareGameLogicDelegate protocol and implementing the addPoint()method

Now let’s conform the ContentView to the SquareGameLogicDelegate protocol, set it up as the delegate of our game scene, and implement the scoring logic.

// 1. Conform the ContentView to the SquareLogicDelegate protocol
struct ContentView: View, SquareGameLogicDelegate {
    // 2. Implement the totalScore variable and add the @State property wrapper
	// so once it changes value the user interface updates too
	@State var totalScore: Int = 0

    mutating func addPoint() {
        self.totalScore += 1
    }
    
	...    

    var squareGameGameScene: SquareGameGameScene {
        let scene = SquareGameGameScene()
        
        scene.size = CGSize(width: gameSceneWidth, height: gameSceneHeight)
        scene.scaleMode = .fill
        // 3. Remember to assign your view as the gameLogicDelegate of your
		// game scene
        scene.gameLogicDelegate = self
        
        return scene
    }
    
    var body: some View {
        ZStack(alignment: .top) {
           ...
			// 4. An use the totalScore property to show the updated score
        	Text("Score: \\(self.totalScore)")
        }
        
    }
}

And that’s it!

Now when the game scene calls the delegate method to add a point to the score, the method that will be called is the method implemented in our ContentView, adding a point to the total score and updating the text on the screen that keeps track of the score of the player.

Wrapping Up

Of course, the example in this article is very simple, but what matters here is the thinking process behind it. You can use delegation for many different purposes in your projects and some cases, it might be that it is not even the best solution for what you need at that specific moment.

I also suggest you explore the singleton design pattern for certain use cases, like handling Game Center or data sources inside your game and Notification Center to trigger certain actions on your SwiftUI views based on data emitted in other parts of your code.