How to Make a Game Like Candy Crush with SpriteKit and Swift: Part 1

Updated for Xcode 9.3 and Swift 4.1. Learn how to make a Candy Crush-like mobile game, using Swift and SpriteKit to animate and build the logic of your game.

Version

  • Swift 4, iOS 11, Xcode 9

Update note: This SpriteKit tutorial has been updated for Xcode 9.3 and Swift 4.1 by Kevin Colligan. The original tutorial was written by Matthijs Hollemans and subsequently updated by Morten Faarkrog.

In this three-part “How to” tutorial with SpriteKit and Swift series, you’ll learn how to make a game like Candy Crush Saga named Cookie Crunch Adventure. Yum, that sounds even better than candy!

  • (You’re here) In the first part, you’ll put some of the foundation in place. You’ll set up the gameplay view, the sprites, and the logic for loading levels.
  • In the second part you’ll focus on detecting swipes, swapping cookies, and finding and removing cookie chains.
  • In the third part, you’ll work on refilling the level with new yummy cookies after successful swipes. You’ll complete the gameplay by adding support for scoring points, winning and losing, shuffling the cookies, and more.

In the process of going through this tutorial, you’ll get some excellent practice with Swift techniques such as enums, generics, subscripting, closures, and extensions. You’ll also learn a lot about game architecture and best practices.

There’s a lot to cover, so dive right in!

Note: This Swift tutorial assumes you have working knowledge of SpriteKit and Swift. If you’re new to SpriteKit, check out the SpriteKit for beginners tutorial or our book, 2D iOS & tvOS Games by Tutorials. For an introduction to Swift, see our Swift tutorials.

Getting Started

Cookie Crunch will use an architecture that is very much like the model-view-controller, or MVC, pattern that you may know from non-game apps:

  • The data model will consist of Level, Cookie and a few other classes. The models will contain the data, such as the 2D grid of cookie objects, and handle most of the gameplay logic.
  • The views will be GameScene and the SKSpriteNodes on the one hand, and UIViews on the other. The views will be responsible for showing things on the screen and for handling touches on those things. The scene in particular will draw the cookie sprites and detect swipes.
  • The view controller will play the same role here as in a typical MVC app: it will sit between the models and the views and coordinate the whole shebang.

All of these objects will communicate with each other, mostly by passing arrays and sets of objects to be modified. This separation will give each object only one job that it can do, totally independent of the others, which will keep the code clean and easy to manage.

Use the Download Materials button at the top or bottom of this tutorial to download the starter project. Open it and run it in the simulator, and you’ll see you’ve got the foundations in place for your game:

  • A tasty background image
  • Labels for your Target, Moves and Score
  • A Shuffle button

Who ate all the cookies?

The cookies are missing, of course. You’ll add those soon. But first, it’s time to tour the starter project.

GameScene.swift

The GameScene includes sound properties, which allow your app to load all sounds once and reuse them as needed:

let swapSound = SKAction.playSoundFileNamed("Chomp.wav", waitForCompletion: false)
let invalidSwapSound = SKAction.playSoundFileNamed("Error.wav", waitForCompletion: false)
let matchSound = SKAction.playSoundFileNamed("Ka-Ching.wav", waitForCompletion: false)
let fallingCookieSound = SKAction.playSoundFileNamed("Scrape.wav", waitForCompletion: false)
let addCookieSound = SKAction.playSoundFileNamed("Drip.wav", waitForCompletion: false)

It also loads the background image from the asset catalog. Since the scene’s anchorPoint is (0.5, 0.5), the background image will always be centered on all iPhone screen sizes:

override init(size: CGSize) {
  super.init(size: size)
    
  anchorPoint = CGPoint(x: 0.5, y: 0.5)
    
  let background = SKSpriteNode(imageNamed: "Background")
  background.size = size
  addChild(background)
}

GameViewController

There are two important GameViewController properties to note. The scene variable provides a reference to the GameScene.

The lazy backgroundMusic declares a variable and initializes it in the same statement, a common iOS pattern:

lazy var backgroundMusic: AVAudioPlayer? = {
    guard let url = Bundle.main.url(forResource: "Mining by Moonlight", withExtension: "mp3") else {
      return nil
    }
    do {
      let player = try AVAudioPlayer(contentsOf: url)
      player.numberOfLoops = -1
      return player
    } catch {
      return nil
    }
}()

The initialization code sits in a closure. It loads the background music MP3 and sets it to loop forever. Because the variable is marked lazy, the code from the closure won’t run until backgroundMusic is first accessed.

The GameScene is set up in viewDidLoad(). You’ll come back to that later.

The IBOutlets and IBActions correspond to objects in the Main.storyboard. You’ll connect them in the later part of the tutorial.

Main Storyboard

Remember those labels and the Shuffle button you saw in the simulator? Those were created in the Main.storyboard. They don’t work now, but you’ll fix that before we’re done.

Main Storyboard

UIKit and SpriteKit get along quite nicely

The Target, Moves and Score labels appear in nested stack views at the top of the screen. If you need a refresher, check out our Stack Views tutorial.

Assets

The starter project contains a bunch of audio and image files to make your game sound and look tasty. Audio files are in a folder named Sounds.

Sprites.atlas

Images are either in the global assets catalog (Assets.xcassets), or in a texture atlas. In Xcode, a texture atlas looks like any other folder, with a name that ends in .atlas. The special name tells Xcode to pack the images into a texture atlas when it builds the game, dramatically improving performance. To learn more about texture atlases, check out our SpriteKit Animations and Texture Atlases tutorial.

Take a look inside Sprites.atlas to find the matching-images for your game: croissants, cupcakes, donuts (yum!). Grid.atlas contains grid images (less yum).

Other items of note:

  • LevelData.swift uses Swift 4’s new Decodable API to make parsing the JSON files a snap. You’ll use it to create levels. See this JSON Parsing screencast for more.
  • Array2D.swift is a helper file which makes it easier to create two-dimensional arrays.
  • Tile.swift is empty now. But contains some hints for adding jelly.

That does it for the starter project tour!

Add Your Cookies

Enough with the pre-heating, let’s start baking! Your next steps are to:

  • Create the Cookie class.
  • Create the Level class.
  • Load levels from JSON files.
  • Serve up your cookies atop background tiles — mom taught you to always use a plate, after all!

The Cookie Class

This game’s playing field consists of a grid, 9 columns by 9 rows. Each square of this grid can contain a cookie.

Column 0, row 0 is in the bottom-left corner of the grid. Since the point (0,0) is also at the bottom-left of the screen in SpriteKit’s coordinate system, it makes sense to have everything else “upside down” — at least compared to the rest of UIKit. :]

To begin implementing this, you need to create the class representing a cookie object. Go to File\New\File…, choose the iOS\Source\Swift File template and click Next. Name the file Cookie.swift and click Create.

Replace the contents of Cookie.swift with the following:

import SpriteKit

// MARK: - CookieType
enum CookieType: Int {
  case unknown = 0, croissant, cupcake, danish, donut, macaroon, sugarCookie 
}

// MARK: - Cookie
class Cookie: CustomStringConvertible, Hashable {
  
  var hashValue: Int {
    return row * 10 + column
  }
  
  static func ==(lhs: Cookie, rhs: Cookie) -> Bool {
    return lhs.column == rhs.column && lhs.row == rhs.row
    
  }
 
  var description: String {
    return "type:\(cookieType) square:(\(column),\(row))"
  }
  
  var column: Int
  var row: Int
  let cookieType: CookieType
  var sprite: SKSpriteNode?
  
  init(column: Int, row: Int, cookieType: CookieType) {
    self.column = column
    self.row = row
    self.cookieType = cookieType
  }
}

You use two protocols that will pay dividends later:

  • CustomStringConvertible: This will make your print statements a lot easier to read.
  • Hashable: Cookies will later be used in a Set and the objects that you put into a set must conform to Hashable. That’s a requirement from Swift.

The column and row properties let Cookie keep track of its position in the 2D grid.

The sprite property is optional, hence the question mark after SKSpriteNode, because the cookie object may not always have its sprite set.

The cookieType property describes the — wait for it — type of the cookie, which takes a value from the CookieType enum. The type is really just a number from 1 to 6, but wrapping it in an enum allows you to work with easy-to-remember names instead of numbers.

You will deliberately not use cookie type Unknown (value 0). This value has a special meaning, as you’ll learn later on.

Each cookie type number corresponds to a sprite image:

In Swift, an enum isn’t useful only for associating symbolic names with numbers; you can also add functions and computed properties to an enum. Add the following code inside the enum CookieType:

var spriteName: String {
  let spriteNames = [
    "Croissant",
    "Cupcake",
    "Danish",
    "Donut",
    "Macaroon",
    "SugarCookie"]

  return spriteNames[rawValue - 1]
}

var highlightedSpriteName: String {
  return spriteName + "-Highlighted"
}

The spriteName property returns the filename of the corresponding sprite image in the texture atlas. In addition to the regular cookie sprite, there is also a highlighted version that appears when the player taps on the cookie.

The spriteName and highlightedSpriteName properties simply look up the name for the cookie sprite in an array of strings. To find the index, you use rawValue to convert the enum’s current value to an integer. Recall that the first useful cookie type, croissant, starts at 1 but arrays are indexed starting at 0, so you need to subtract 1 to find the correct array index.

Every time a new cookie gets added to the game, it will get a random cookie type. It makes sense to add that as a function on CookieType. Add the following to the enum as well:

static func random() -> CookieType {
  return CookieType(rawValue: Int(arc4random_uniform(6)) + 1)!
}

This calls arc4random_uniform(_:) to generate a random number between 0 and 5, then adds 1 to make it a number between 1 and 6. Because Swift is very strict, the result from arc4random_uniform(_:)UInt32 — must first be converted to Int, then you can convert this number into a proper CookieType value.

Now, you may wonder why you’re not making Cookie a subclass of SKSpriteNode. After all, the cookie is something you want to display on the screen. If you’re familiar with the MVC pattern, think of Cookie as a model object that simply describes the data for the cookie. The view is a separate object, stored in the sprite property.

This kind of separation between data models and views is something you’ll use consistently throughout this tutorial. The MVC pattern is more common in regular apps than in games but, as you’ll see, it can help keep the code clean and flexible.

The Level Class

Now let start building levels. Go to File\New\File…, choose the iOS\Source\Swift File template and click Next. Name the file Level.swift and click Create.

Replace the contents of Level.swift with the following:

import Foundation

let numColumns = 9
let numRows = 9 

class Level {
  private var cookies = Array2D<Cookie>(columns: numColumns, rows: numRows)
}

This declares two constants for the dimensions of the level, numColumns and numRows, so you don’t have to hardcode the number 9 everywhere.

The property cookies is the two-dimensional array that holds the Cookie objects, 81 in total, 9 rows of 9 columns.

The cookies array is private, so Level needs to provide a way for others to obtain a cookie object at a specific position in the level grid.

Add the code for this method to end of Level:

func cookie(atColumn column: Int, row: Int) -> Cookie? {
  precondition(column >= 0 && column < numColumns)
  precondition(row >= 0 && row < numRows)
  return cookies[column, row]
}

Using cookie(atColumn: 3, row: 6) you can ask the Level for the cookie at column 3, row 6. Behind the scenes this asks the Array2D for the cookie and then returns it. Note that the return type is Cookie?, an optional, because not all grid squares will necessarily have a cookie.

Notice the use of precondition to verify that the specified column and row numbers are within the valid range of 0-8.

Now to fill up that cookies array with some cookies! Later on you will learn how to read level designs from a JSON file but for now, you’ll fill up the array yourself, just so there is something to show on the screen.

Add the following two methods to the end of Level:

func shuffle() -> Set<Cookie> {
  return createInitialCookies()
}

private func createInitialCookies() -> Set<Cookie> {
  var set: Set<Cookie> = []

  // 1
  for row in 0..<numRows {
    for column in 0..<numColumns {

      // 2
      let cookieType = CookieType.random()

      // 3
      let cookie = Cookie(column: column, row: row, cookieType: cookieType)
      cookies[column, row] = cookie

      // 4
      set.insert(cookie)
    }
  }
  return set
}

Both methods return a Set. A Set is a collection, like an array, but it allows each element to appear only once, and it doesn't store the elements in any particular order.

shuffle() fills up the level with random cookies. Right now it just calls createInitialCookies(), where the real work happens. Here's what it does, step by step:

  1. The method loops through the rows and columns of the 2D array. This is something you’ll see a lot in this tutorial. Remember that column 0, row 0 is in the bottom-left corner of the 2D grid.
  2. Then it picks a random cookie type using the method you added earlier.
  3. Next, it creates a new Cookie and adds it to the 2D array.
  4. Finally, it adds the new Cookie to a Set. shuffle() returns this set of cookies to its caller.

One of the main difficulties when designing your code is deciding how the different objects will communicate with each other. In this game, you often accomplish this by passing around a collection of objects, usually a Set or Array.

In this case, after you create a new Level and call shuffle() to fill it with cookies, the Level replies, “Here is a set with all the new Cookies I just added.” You can take that set and, for example, create new sprites for all the cookies it contains. In fact, that’s exactly what you’ll do in the next section.

Build the app and make sure you're not getting any compilation errors.

Open GameScene.swift and add the following properties to the class:

var level: Level!

let tileWidth: CGFloat = 32.0
let tileHeight: CGFloat = 36.0

let gameLayer = SKNode()
let cookiesLayer = SKNode()

The scene has a public property to hold a reference to the current level.

Each square of the 2D grid measures 32 by 36 points, so you put those values into the tileWidth and tileHeight constants. These constants will make it easier to calculate the position of a cookie sprite.

To keep the SpriteKit node hierarchy neatly organized, GameScene uses several layers. The base layer is called gameLayer. This is the container for all the other layers and it’s centered on the screen. You’ll add the cookie sprites to cookiesLayer, which is a child of gameLayer.

Add the following lines to init(size:) to add the new layers. Put this after the code that creates the background node:

addChild(gameLayer)

let layerPosition = CGPoint(
    x: -tileWidth * CGFloat(numColumns) / 2,
    y: -tileHeight * CGFloat(numRows) / 2)

cookiesLayer.position = layerPosition
gameLayer.addChild(cookiesLayer)

This adds two empty SKNodes to the screen to act as layers. You can think of these as transparent planes you can add other nodes in.

Remember that earlier you set the anchorPoint of the scene to (0.5, 0.5)? This means that when you add children to the scene their starting point (0, 0) will automatically be in the center of the scene.

However, because column 0, row 0 is in the bottom-left corner of the 2D grid, you want the positions of the sprites to be relative to the cookiesLayer’s bottom-left corner, as well. That’s why you move the layer down and to the left by half the height and width of the grid.

Adding the sprites to the scene happens in addSprites(for:). Add it after init(size:):

func addSprites(for cookies: Set<Cookie>) {
  for cookie in cookies {
    let sprite = SKSpriteNode(imageNamed: cookie.cookieType.spriteName)
    sprite.size = CGSize(width: tileWidth, height: tileHeight)
    sprite.position = pointFor(column: cookie.column, row: cookie.row)
    cookiesLayer.addChild(sprite)
    cookie.sprite = sprite
  }
}

private func pointFor(column: Int, row: Int) -> CGPoint {
  return CGPoint(
    x: CGFloat(column) * tileWidth + tileWidth / 2,
    y: CGFloat(row) * tileHeight + tileHeight / 2)
}

addSprites(for:) iterates through the set of cookies and adds a corresponding SKSpriteNode instance to the cookie layer. This uses a helper method, pointFor(column:, row:), that converts a column and row number into a CGPoint that is relative to the cookiesLayer. This point represents the center of the cookie’s SKSpriteNode.

Open GameViewController.swift and add a new property to the class:

var level: Level!

Next, add these two new methods:

func beginGame() {
  shuffle()
}

func shuffle() {
  let newCookies = level.shuffle()
  scene.addSprites(for: newCookies)
}

beginGame() kicks off the game by calling shuffle(). This is where you call Level's shuffle() method, which returns the Set containing new Cookie objects. Remember that these cookie objects are just model data; they don’t have any sprites yet. To show them on the screen, you tell GameScene to add sprites for those cookies.

The only missing piece is creating the actual Level instance. Add the following lines in viewDidLoad(), just before the code that presents the scene:

level = Level()
scene.level = level

After creating the new Level instance, you set the level property on the scene to tie together the model and the view.

Finally, make sure you call beginGame() at the end of viewDidLoad() to set things in motion:

beginGame()

Build and run, and you should finally see some cookies:

finally, some cookies!

Loading Levels from JSON Files

Not all the levels in Candy Crush Saga have grids that are a simple square shape. You will now add support for loading level designs from JSON files. The five designs you’re going to load still use the same 9x9 grid, but they leave some of the squares blank.

Look in the Levels folder in the starter project and you’ll see several files.

Level JSON

Click on Level_1.json to look inside. You’ll see that the contents are structured as a dictionary containing three elements: tiles, targetScore and moves.

The tiles array contains nine other arrays, one for each row of the level. If a tile has a value of 1, it can contain a cookie; a 0 means the tile is empty.

Open Level.swift and add a new property and method:

private var tiles = Array2D<Tile>(columns: numColumns, rows: numRows)

func tileAt(column: Int, row: Int) -> Tile? {
  precondition(column >= 0 && column < numColumns)
  precondition(row >= 0 && row < numRows)
  return tiles[column, row]
}

The tiles variable describes the structure of the level. This is very similar to the cookies array, except now you make it an Array2D of Tile objects.

Whereas the cookies array keeps track of the Cookie objects in the level, tiles simply describes which parts of the level grid are empty and which can contain a cookie:

Wherever tiles[a, b] is nil, the grid is empty and cannot contain a cookie.

Now that the instance variables for level data are in place, you can start adding the code to fill in the data.

Add the new init(filename:) initializer to Level.swift:

init(filename: String) {
  // 1
  guard let levelData = LevelData.loadFrom(file: filename) else { return }
  // 2
  let tilesArray = levelData.tiles
  // 3
  for (row, rowArray) in tilesArray.enumerated() {
    // 4
    let tileRow = numRows - row - 1
    // 5
    for (column, value) in rowArray.enumerated() {
      if value == 1 {
        tiles[column, tileRow] = Tile()
      }
    }
  }
}

Here's what this initializer does, step-by-step:

  1. Load the data level from a specific JSON file. Note that this function may return nil — it returns an optional — and you use guard to handle this situation.
  2. Create a “tiles” array.
  3. Step through the rows using built-in enumerated() function, which is useful because it also returns the current row number.
  4. In SpriteKit (0, 0) is at the bottom of the screen, so you have to reverse the order of the rows here. The first row you read from the JSON corresponds to the last row of the 2D grid.
  5. Step through the columns in the current row. Every time you find a 1, create a Tile object and place it into the tiles array.

You still need to put this new tiles array to good use. Inside createInitialCookies(), add an if-clause inside the two for-loops, around the code that creates the Cookie object:

// This line is new
if tiles[column, row] != nil {

  let cookieType = ...
  ...
  set.insert(cookie)
}

Now the app will only create a Cookie object if there is a tile at that spot.

One last thing remains: In GameViewController.swift’s viewDidLoad(), replace the line that creates the level object with:

level = Level(filename: "Level_1")

Build and run, and now you should have a non-square level shape:

level 1 rendered on screen

Who are you calling a square?

Making the Tiles Visible

To make the cookie sprites stand out from the background a bit more, you can draw a slightly darker “tile” sprite behind each cookie. These new tile sprites will live on their own layer, the tilesLayer. The graphics are included in the starter project, in Grid.atlas.

In GameScene.swift, add three new properties:

let tilesLayer = SKNode() 
let cropLayer = SKCropNode() 
let maskLayer = SKNode()

This makes three layers: tilesLayer, cropLayer, which is a special kind of node called an SKCropNode, and maskLayer. A crop node only draws its children where the mask contains pixels. This lets you draw the cookies only where there is a tile, but never on the background.

In init(size:), add these lines below the code that creates the layerPosition:

tilesLayer.position = layerPosition
maskLayer.position = layerPosition
cropLayer.maskNode = maskLayer
gameLayer.addChild(tilesLayer)
gameLayer.addChild(cropLayer)

Make sure you add the children nodes in the correct order so tiles appear behind the cropLayer (which contains your cookies.) SpriteKit nodes with the same zPosition are drawn in the order they were added.

Replace this line:

gameLayer.addChild(cookiesLayer)

With this:

cropLayer.addChild(cookiesLayer)

Now, instead of adding the cookiesLayer directly to the gameLayer, you add it to this new cropLayer.

Add the following method to GameScene.swift, as well:

func addTiles() {
  // 1
  for row in 0..<numRows {
    for column in 0..<numColumns {
      if level.tileAt(column: column, row: row) != nil {
        let tileNode = SKSpriteNode(imageNamed: "MaskTile")
        tileNode.size = CGSize(width: tileWidth, height: tileHeight)
        tileNode.position = pointFor(column: column, row: row)
        maskLayer.addChild(tileNode)
      }
    }
  }

  // 2
  for row in 0...numRows {
    for column in 0...numColumns {
      let topLeft     = (column > 0) && (row < numRows)
        && level.tileAt(column: column - 1, row: row) != nil
      let bottomLeft  = (column > 0) && (row > 0)
        && level.tileAt(column: column - 1, row: row - 1) != nil
      let topRight    = (column < numColumns) && (row < numRows)
        && level.tileAt(column: column, row: row) != nil
      let bottomRight = (column < numColumns) && (row > 0)
        && level.tileAt(column: column, row: row - 1) != nil

      var value = topLeft.hashValue
      value = value | topRight.hashValue << 1
      value = value | bottomLeft.hashValue << 2
      value = value | bottomRight.hashValue << 3

      // Values 0 (no tiles), 6 and 9 (two opposite tiles) are not drawn.
      if value != 0 && value != 6 && value != 9 {
        let name = String(format: "Tile_%ld", value)
        let tileNode = SKSpriteNode(imageNamed: name)
        tileNode.size = CGSize(width: tileWidth, height: tileHeight)
        var point = pointFor(column: column, row: row)
        point.x -= tileWidth / 2
        point.y -= tileHeight / 2
        tileNode.position = point
        tilesLayer.addChild(tileNode)
      }
    }
  }
}

Here's what's going on.

  1. Wherever there’s a tile, the method now draws the special MaskTile sprite into the layer functioning as the SKCropNode’s mask.
  2. This draws a pattern of border pieces in between the level tiles.

    Imagine dividing each tile into four quadrants. The four boolean variables — topLeft, bottomLeft, topRight, topLeft — indicate which quadrants need a background. For example, a tile surrounded on all sides wouldn't need any border, just a full background to fit in seamlessly to the tiles around it. But in a square level, a tile in the lower-right corner would need a background to cover the top-left only, like so:

    The code checks which tile is required, and selects the right sprite.

Finally, open GameViewController.swift. Add the following line to viewDidLoad(), immediately after you present the scene:

scene.addTiles()

Build and run and notice how nice your cookies look!

Looks good enough to eat!

Where to Go From Here?

You can download the completed version of the project to this point using the Download Materials button at the top or bottom of this tutorial.

Your game is shaping up nicely, but there’s still a way to go before it's finished. For now give yourself a cookie for making it through part one!

In the next part, you’ll work on detecting swipes, swapping cookies and finding cookie chains. You're in for a treat ;]

While you eat your cookie, take a moment to let us hear from you in the forums!

Credits: Free game art from Game Art Guppy.

Portions of the source code were inspired by Gabriel Nica's Swift port of the game.

Contributors

Comments