Creating the Classic "Snake" Game with SpriteKit

Written by ze8c | Published 2023/09/13
Tech Story Tags: game-development | ios | spritekit | swift | swiftui | xcode | macos | spritekit-tutorial

TLDRHow to create simple game "Snake" with help from SpriteKit and a little bit of time.via the TL;DR App

SpriteKit is a powerful framework provided by Apple for creating 2D games and interactive animations. It's a versatile tool for game developers, allowing them to bring their creative ideas to life. In this article, we'll explore how to create the classic "Snake" game using SpriteKit. "Snake" is a timeless arcade game that involves controlling a snake to eat food and grow longer while avoiding collisions with the walls.

Prerequisites

Before we dive into the development process, make sure you have the following set up:

  1. Xcode: Download and install Xcode, Apple's integrated development environment (IDE), from the Mac App Store.
  2. Basic knowledge of Swift: Familiarity with the Swift programming language is essential for understanding the code in this article.

Setting Up Your Project

Let's get started by creating a new SpriteKit project in Xcode:

  1. Open Xcode and choose "Create a new Xcode project."
  2. Select the "Game" template under the "iOS" category.
  3. Fill in the project details, such as the product name and organization identifier. Ensure that the language is set to Swift.
  4. Choose "SpriteKit" as the game technology.
  5. Click "Next" and specify the project location, then click "Create."

Designing the Game

Collision Detection

To begin with, we will determine the components of our game, and we will make the masks for collisions for them

enum CollisionMask: UInt32 {
    case snakeHead = 1
    case snakeBody = 2
    case apple = 4
    case edgeBody = 8
}

In the update(_:) method our game scene, you'll need to implement collision detection to check if the snake has collided with the food or edge. When the snake eats the food, it should grow longer, and a new food item should appear.

Snake and Food

To create the snake and food objects, we'll use SKShapeNode.

final class Apple: SKShapeNode {
    convenience init(position: CGPoint) {
        self.init()
        
        self.path = CGPath(ellipseIn: CGRect(x: 0, y: 0, width: 10, height: 10), transform: nil)
        
        self.fillColor = .systemRed
        self.strokeColor = .systemOrange
        
        self.lineWidth = 2
        
        self.position = position
        
        let pBody = SKPhysicsBody(circleOfRadius: 10, center: CGPoint(x: 5, y: 5))
        
        pBody.categoryBitMask = CollisionMask.apple.rawValue
        self.physicsBody = pBody
    }
}

Our snake have base items which build body:

class SnakeBodyElement: SKShapeNode {
    
    var setChildSuperParent: (SKNode) -> Void = { _ in }
    
    var diameter: CGFloat { 10 }
    var runDuration: TimeInterval { 0.2 }
    
    private var isNeedAddCild: Bool = false
    private var childElement: SnakeBodyElement?
    
    init(atPoint point: CGPoint) {
        super.init()
        
        self.path = CGPath(
            ellipseIn: CGRect(x: 0, y: 0, width: diameter, height: diameter),
            transform: nil
        )
        
        let pBody = SKPhysicsBody(
            circleOfRadius: diameter / 2,
            center: CGPoint(x: diameter / 2, y: diameter / 2)
        )

        pBody.isDynamic = true
        pBody.categoryBitMask = CollisionMask.snakeBody.rawValue
        pBody.contactTestBitMask = CollisionMask.edgeBody.rawValue | CollisionMask.apple.rawValue
        
        self.physicsBody = pBody
        
        self.fillColor = .systemTeal
        self.strokeColor = .systemGreen
        
        self.lineWidth = 1
        
        self.position = point
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func removeFromParent() {
        super.removeFromParent()
        childElement?.removeFromParent()
    }
    
    func moveTo(position: CGPoint) {
        let currentPosition = self.position
        
        self.run(SKAction.move(to: position, duration: runDuration))
        
        if let child = childElement {
            child.moveTo(position: currentPosition)
        } else if isNeedAddCild {
            let newElement = SnakeBodyElement(atPoint: currentPosition)
            newElement.setChildSuperParent = setChildSuperParent
            childElement = newElement
            setChildSuperParent(newElement)
            isNeedAddCild = false
        }
    }
    
    func eat() {
        if let child = childElement {
            child.eat()
        } else {
            isNeedAddCild = true
        }
    }
}

And a head:

final class SnakeHead: SnakeBodyElement {
    
    override var diameter: CGFloat { 20 }
    override var runDuration: TimeInterval { 1 }
    
    private let moveSpeed: Double = 100
    private var angle: CGFloat = 0
    
    override init(atPoint point: CGPoint) {
        super.init(atPoint: point)
        
        self.name = "SnakeHead"
        self.physicsBody?.categoryBitMask = CollisionMask.snakeHead.rawValue
        
        self.physicsBody?.contactTestBitMask = CollisionMask.edgeBody.rawValue
            | CollisionMask.apple.rawValue
            | CollisionMask.snakeBody.rawValue
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func moveTo(position: CGPoint) {
        let dx = CGFloat(moveSpeed) * sin(angle)
        let dy = CGFloat(moveSpeed) * cos(angle)
        
        let nextPosition = CGPoint(x: self.position.x + dx, y: self.position.y + dy)
        
        super.moveTo(position: nextPosition)
    }
    
    func moveClockwise() {
        angle += CGFloat.pi / 2
    }
    
    func moveCounterClockwise() {
        angle -= CGFloat.pi / 2
    }
}

Game Loop

SpriteKit provides a built-in game loop through the update(_:) method in our scene. This is where you'll update the game's logic, such as moving the snake and checking for collisions.

override func update(_ currentTime: TimeInterval) {
    // Add game logic here
}

User Interaction

To control the snake, you'll want to capture user input. One common way to do this is by handling touch events:

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    // Handle touch events to change the snake's direction
}

Implementing Game Logic

Now that you have the basic structure of game, it's time to implement the game logic. Here are some key steps:

  1. Move the snake in the direction determined by user input.
  2. Check for collisions with the walls. If the snake collides, the game should end.
  3. Check if the snake's head collides with the food. If so, make the snake longer and spawn a new food item.
  4. Keep track of the snake's length and update the score.
  5. Implement game over logic when the snake collides with the walls.

Game Scene

In SpriteKit, games are built around scenes. We'll begin by designing our game scene, including the snake and food elements.

  1. Open the GameScene.swift file in your Xcode project.
  2. Change the existing code:
final class GameScene: SKScene {
    
    private var snake: SnakeHead?
    
    private var apple: Apple?
    
    let infoSender = CurrentValueSubject<Int, Never>(0)
    
    override init(size: CGSize) {
        super.init(size: size)
        
        scaleMode = .resizeFill
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func didMove(to view: SKView) {
        
        view.showsPhysics = true
        
        guard let viewScene = view.scene else { return }
        
        viewScene.physicsWorld.contactDelegate = self
        
        viewScene.physicsBody?.allowsRotation = false
        
        viewScene.backgroundColor = UIColor(red: 0.8, green: 0.8, blue: 0.8, alpha: 1)
        viewScene.physicsBody = SKPhysicsBody(edgeLoopFrom: frame)
        viewScene.physicsBody?.categoryBitMask = CollisionMask.edgeBody.rawValue
        viewScene.physicsBody?.collisionBitMask = CollisionMask.snakeBody.rawValue
            | CollisionMask.snakeHead.rawValue
        
        viewScene.physicsWorld.gravity = CGVector(dx: 0, dy: 0)
        
        setup(skScene: viewScene)
    }
    
    override func update(_ currentTime: TimeInterval) {
        if !isPaused {
            snake?.moveTo(position: apple!.position)
        }
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        buttonAction(touches, color: .green) { [weak self] tNode in
            if tNode.name == "CounterClockwiseBtn" && !(self?.isPaused ?? true) {
                self?.snake?.moveCounterClockwise()
            } else if tNode.name == "ClockwiseBtn" && !(self?.isPaused ?? true) {
                self?.snake?.moveClockwise()
            } else if tNode.name == "Pause" {
                self?.isPaused.toggle()
            }
        }
    }
    
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        buttonAction(touches, color: .gray)
    }
    
    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
        isPaused = true
    }
    
    private func setup(skScene: SKScene) {
        var position = CGPoint(x: skScene.frame.minX + 30, y: skScene.frame.minY + 30)
        self.addChild(createBtn(name: "CounterClockwiseBtn", color: .gray, position: position))
        
        position.x = skScene.frame.maxX - 80
        self.addChild(createBtn(name: "ClockwiseBtn", color: .gray, position: position))
        
        position.y = skScene.frame.maxY - 80
        self.addChild(createBtn(name: "Pause", color: .systemBlue, position: position))
        
        createSnake(skFrame: skScene.frame)
        
        createApple(skFrame: skScene.frame)
    }
    
    private func createSnake(skFrame: CGRect) {
        snake = SnakeHead(atPoint: CGPoint(x: skFrame.midX, y: skFrame.midY))
        snake?.setChildSuperParent = self.addChild(_:)
        self.addChild(snake!)
    }
    
    private func createApple(skFrame: CGRect) {
        
        let randX = CGFloat(arc4random_uniform(UInt32(skFrame.maxX)) + 1)
        let randY = CGFloat(arc4random_uniform(UInt32(skFrame.maxY)) + 1)
        
        apple = Apple(position: CGPoint(x: randX, y: randY))
        
        self.addChild(apple!)
    }
    
    private func createBtn(name: String?, color: UIColor, position: CGPoint) -> SKShapeNode {
        let btn = SKShapeNode()
        
        btn.path = CGPath(ellipseIn: CGRect(x: 0, y: 0, width: 45, height: 45), transform: nil)
        btn.position = position
        btn.fillColor = color
        btn.strokeColor = .darkGray
        btn.lineWidth = 10
        btn.name = name
        
        return btn
    }
    
    private func buttonAction(
        _ touches: Set<UITouch>,
        color: UIColor,
        action: (SKShapeNode) -> Void = {_ in}
    ) {
        for touch in touches {
            let touchLocation = touch.location(in: self)
            
            guard let touchedNode = self.atPoint(touchLocation) as? SKShapeNode,
                  touchedNode.name == "CounterClockwiseBtn"
                    || touchedNode.name == "ClockwiseBtn"
                    || touchedNode.name == "Pause"
            else {
                return
            }
            
            touchedNode.fillColor = color
            
            action(touchedNode)
        }
    }
}

extension GameScene: SKPhysicsContactDelegate {
    
    func didBegin(_ contact: SKPhysicsContact) {
        let collisionObject: SKPhysicsBody
        
        if contact.bodyA.node?.name == "SnakeHead" {
            collisionObject = contact.bodyB
        } else {
            collisionObject = contact.bodyA
        }
        
        switch CollisionMask(rawValue: collisionObject.categoryBitMask) {
        case .apple:
            let apple = contact.bodyA.node is Apple ? contact.bodyA.node : contact.bodyB.node
            snake?.eat()
            apple?.removeFromParent()
            infoSender.send(infoSender.value + 1)
            createApple(skFrame: view?.scene?.frame ?? .zero)
        case .edgeBody:
            snake?.removeFromParent()
            infoSender.send(0)
            createSnake(skFrame: view?.scene?.frame ?? .zero)
        default:
            break
        }
    }
}

And finally, we'll update our application to show the score:

@main
struct SnakeApp: App {
    private let scene: GameScene //BloxScene
    @State private var info = "0"

    var body: some Scene {
        WindowGroup {
            ZStack {
                Color.gray
                    .edgesIgnoringSafeArea(.all)
            
                VStack(spacing: 0) {
                    Text("Score: \(info)")
                        .font(.body)
                        .foregroundColor(.white)
                
                    SpriteView(
                        scene: scene,
                        debugOptions: [.showsFPS, .showsNodeCount, .showsDrawCount],
                        shouldRender: { timeInterval in true }
                    )
                    .frame(idealWidth: .infinity, idealHeight: .infinity)
                }
            }
            .onReceive(scene.infoSender.eraseToAnyPublisher()) {
                info = "\($0)"
            }
        }
    }

    init() {
        self.scene = GameScene(size: CGSize(width: 10, height: 10))
    }
}

Conclusion

Creating the classic "Snake" game using SpriteKit can be a fun and educational project for both beginners and experienced game developers. This article has provided a high-level overview of the steps involved in setting up the project and designing the game scene. The real challenge lies in implementing the game logic, handling user input, and creating an enjoyable gaming experience.

Remember that game development is an iterative process, so don't be discouraged by initial setbacks. With persistence and creativity, you can create a polished "Snake" game that is both engaging and entertaining. Good luck with your SpriteKit game development journey!


Written by ze8c | iOS Tech Lead
Published by HackerNoon on 2023/09/13