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

Morten Faarkrog

Update 10/10/16: This SpriteKit tutorial has been updated for Xcode 8 and Swift 3 by Morten Faarkrog. The original tutorial was written by Matthijs Hollemans.

In this epic tutorial you'll learn how to make a tasty match-3 game like Candy Crush with SpriteKit and Swift. Now updated for Xcode 8 and Swift 3.

In this epic tutorial you’ll learn how to make a tasty match-3 game like Candy Crush with SpriteKit and Swift. Now updated for Xcode 8 and Swift 3.

Once again, welcome back to our “How to Make a Game Like Candy Crush” tutorial with SpriteKit and Swift series. This is the third instalment in the four-part series, and we’re excited to get your game past the foundations :]

  • In the first part, you put some of the foundation in place. You setup the gameplay view, the sprites, and the logic for loading levels.
  • In the second part you continued expanding on the foundation of the game. You focused on detecting swipes and swapping cookies, as well as creating some nice visual effects for your game.
  • (You’re here) In the third part, you’ll work on finding and removing chains and refilling the level with new yummy cookies after successful swipes.
  • Finally, in the fourth part, you’ll complete the gameplay by adding support for scoring points, winning and losing, shuffling the cookies, and more.

This tutorial picks up where you left off in the last part. If you don’t have it already, here is the project with all of the source code up to this point.

Let’s get started! :]

Getting Started

Everything you’ve worked on so far has been to allow the player to swap cookies. Next, your game needs to process the results of the swaps.

Swaps always lead to a chain of three or more matching cookies. The next thing to do is to remove those matching cookies from the screen and reward the player with some points.

This is the sequence of events:

Game flow

You’ve already done the first three steps: filling the level with cookies, calculating possible swaps and waiting for the player to make a swap. In this part of the Swift tutorial, you’ll implement the remaining steps.

Finding the Chains

At this point in the game flow, the player has made her move and swapped two cookies. Because the game only lets the player make a swap if it will result in a chain of three or more cookies of the same type, you know there is now at least one chain—but there could be additional chains, as well.

Before you can remove the matching cookies from the level, you first need to find all the chains. That’s what you’ll do in this section.

First, make a class that describes a chain. Go to File\New\File…, choose the iOS\Source\Swift File template and click Next. Name the file Chain.swift and click Create.

Replace the contents of Chain.swift with this:

class Chain: Hashable, CustomStringConvertible {
  var cookies = [Cookie]()

  enum ChainType: CustomStringConvertible {
    case horizontal
    case vertical

    var description: String {
      switch self {
      case .horizontal: return "Horizontal"
      case .vertical: return "Vertical"
      }
    }
  }

  var chainType: ChainType

  init(chainType: ChainType) {
    self.chainType = chainType
  }

  func add(cookie: Cookie) {
    cookies.append(cookie)
  }

  func firstCookie() -> Cookie {
    return cookies[0]
  }

  func lastCookie() -> Cookie {
    return cookies[cookies.count - 1]
  }

  var length: Int {
    return cookies.count
  }

  var description: String {
    return "type:\(chainType) cookies:\(cookies)"
  }

  var hashValue: Int {
    return cookies.reduce (0) { $0.hashValue ^ $1.hashValue }
  }
}

func ==(lhs: Chain, rhs: Chain) -> Bool {
  return lhs.cookies == rhs.cookies
}

A chain has a list of cookie objects and a type: It’s either horizontal (a row of cookies) or vertical (a column). The type is defined as an enum; it is nested inside the Chain class because these two things are tightly coupled. If you feel adventurous, you can also add more complex chain types, such as L- and T-shapes.

There is a reason you’re using an array here to store the cookie objects and not a Set: It’s convenient to remember the order of the cookie objects so that you know which cookies are at the ends of the chain. This makes it easier to combine multiple chains into a single one to detect those L- or T-shapes.

Note: The chain implements Hashable so it can be placed inside a Set. The code for hashValue may look strange but it simply performs an exclusive-or on the hash values of all the cookies in the chain. The reduce() function is one of Swift’s more advanced functional programming features.

To start putting these new chain objects to good use, open Level.swift. You’re going to add a method named removeMatches(), but before you get to that, you need a couple of helper methods to do the heavy lifting of finding chains.

To find a chain, you’ll need a pair of for loops that step through each square of the level grid.

Finding chains

While stepping through the cookies in a row horizontally, you want to find the first cookie that starts a chain.

You know a cookie begins a chain if at least the next two cookies on its right are of the same type. Then you skip over all the cookies that have that same type until you find one that breaks the chain. You repeat this until you’ve looked at all the possibilities.

Add this method to Level.swift to scan for horizontal cookie matches:

private func detectHorizontalMatches() -> Set<Chain> {
  // 1
  var set = Set<Chain>()
  // 2
  for row in 0..<NumRows {
    var column = 0
    while column < NumColumns-2 {
      // 3
      if let cookie = cookies[column, row] {
        let matchType = cookie.cookieType
        // 4
        if cookies[column + 1, row]?.cookieType == matchType &&
           cookies[column + 2, row]?.cookieType == matchType {
          // 5
          let chain = Chain(chainType: .Horizontal)
          repeat {
            chain.add(cookie: cookies[column, row]!)
            column += 1
          } while column < NumColumns && cookies[column, row]?.cookieType == matchType

          set.insert(chain)
          continue
        }
      }
      // 6
      column += 1
    }
  }
  return set
}

Here’s how this method works, step by step:

  1. You create a new set to hold the horizontal chains (Chain objects). Later, you’ll remove the cookies in these chains from the playing field.
  2. You loop through the rows and columns. Note that you don’t need to look at the last two columns because these cookies can never begin a new chain.
  3. You skip over any gaps in the level design.
  4. You check whether the next two columns have the same cookie type. Normally you have to be careful not to step outside the bounds of the array when doing something like cookies[column + 2, row], but here that can’t go wrong. That’s why the for loop only goes up to NumColumns - 2. Also note the use of optional chaining with the question mark.
  5. At this point, there is a chain of at least three cookies but potentially there are more. This steps through all the matching cookies until it finds a cookie that breaks the chain or it reaches the end of the grid. Then it adds all the matching cookies to a new Chain object. You increment column for each match.
  6. If the next two cookies don’t match the current one or if there is an empty tile, then there is no chain, so you skip over the cookie.

Note: If there’s a gap in the grid, the use of optional chaining -- the question mark after cookies[column, row]? -- makes sure the while loop terminates at that point. So the logic above also works on levels with empty squares. Neat!

Next, add this method to scan for vertical cookie matches:

private func detectVerticalMatches() -> Set<Chain> {
  var set = Set<Chain>()

  for column in 0..<NumColumns {
    var row = 0
    while row < NumRows-2 {
      if let cookie = cookies[column, row] {
        let matchType = cookie.cookieType

        if cookies[column, row + 1]?.cookieType == matchType &&
           cookies[column, row + 2]?.cookieType == matchType {
          let chain = Chain(chainType: .Vertical)
          repeat {
            chain.add(cookie: cookies[column, row]!)
            row += 1
          } while row < NumRows && cookies[column, row]?.cookieType == matchType

          set.insert(chain)
          continue
        }
      }
      row += 1
    }
  }
  return set
}

The vertical version has the same kind of logic, but loops by column in the outer while loop and by row in the inner loop.

You may wonder why you don’t immediately remove the cookies from the level as soon as you detect that they’re part of a chain. The reason is that a cookie may be part of two chains at the same time: one horizontal and one vertical. So you don’t want to remove it until you’ve checked both the horizontal and vertical options.

Now that the two match detectors are ready, add the implementation for removeMatches():

func removeMatches() -> Set<Chain> {
  let horizontalChains = detectHorizontalMatches()
  let verticalChains = detectVerticalMatches()

  print("Horizontal matches: \(horizontalChains)")
  print("Vertical matches: \(verticalChains)")

  return horizontalChains.union(verticalChains)
}

This method calls the two helper methods and then combines their results into a single set. Later, you’ll add more logic to this method but for now you’re only interested in finding the matches and returning the set.

You still need to call removeMatches() from somewhere and that somewhere is GameViewController.swift. Add this helper method:

func handleMatches() {
  let chains = level.removeMatches()
  // TODO: do something with the chains set
}

Later, you'll fill out this method with code to remove cookie chains and drop other cookies into the empty tiles.

In handleSwipe(), change the call to scene.animateSwap() to this:

scene.animate(swap: swap, completion: handleMatches)

Recall that in Swift a closure and a function are really the same thing, so instead of passing a closure block to animate(swap:completion:), you can also give it the name of a function.

Build and run, and swap two cookies to make a chain. You should now see something like this in Xcode’s debug pane:

List of matches

Removing Chains

Level’s method is called "removeMatches", but so far it only detects the matching chains. Now you’re going to remove those cookies from the game with a nice animation.

First, you need to update the data model by removing the Cookie objects from the array for the 2D grid. When that’s done, you can tell GameScene to animate the sprites for these cookies out of existence.

eatcookies

Removing the cookies from the model is simple enough. Add the following method to Level.swift:

private func removeCookies(chains: Set<Chain>) {
  for chain in chains {
    for cookie in chain.cookies {
      cookies[cookie.column, cookie.row] = nil
    }
  }
}

Each chain has a list of cookie objects and each cookie knows its column and row in the grid, so you simply set that element in the array to nil to remove the cookie object from the data model.

Note: At this point, the Chain object is the only owner of the Cookie object. When the chain gets deallocated, so will these cookie objects.

In removeMatches(), replace the print() statements with the following:

removeCookies(horizontalChains)
removeCookies(verticalChains)

That takes care of the data model. Now switch to GameScene.swift and add the following method:

func animateMatchedCookies(for chains: Set<Chain>, completion: @escaping () -> ()) {
  for chain in chains {
    for cookie in chain.cookies {
      if let sprite = cookie.sprite {
        if sprite.action(forKey: "removing") == nil {
          let scaleAction = SKAction.scale(to: 0.1, duration: 0.3)
          scaleAction.timingMode = .easeOut
          sprite.run(SKAction.sequence([scaleAction, SKAction.removeFromParent()]),
                     withKey:"removing")
        }
      }
    }
  }
  run(matchSound)
  run(SKAction.wait(forDuration: 0.3), completion: completion)
}

This loops through all the chains and all the cookies in each chain, and then triggers the animations.

Because the same Cookie could be part of two chains (one horizontal and one vertical), you need to make sure to add only one animation to the sprite, not two. That's why the action is added to the sprite under the key "removing". If such an action already exists, you shouldn't add a new animation to the sprite.

When the shrinking animation is done, the sprite is removed from the cookie layer. The waitForDuration() action at the end of the method ensures that the rest of the game will only continue after the animations finish.

Open GameViewController.swift and change handleMatches() to call this new animation:

func handleMatches() {
  let chains = level.removeMatches()

  scene.animateMatchedCookies(for: chains) {
    self.view.isUserInteractionEnabled = true
  }
}

Try it out. Build and run, and make some matches.

Match animation

Note: You don’t want the player to be able to tap or swipe on anything while the chain removal animations are happening. That’s why you disable isUserInteractionEnabled as the first thing in the swipe handler and enable it again once all the animations are done.

Dropping Cookies Into Empty Tiles

Removing the cookie chains leaves holes in the grid. Other cookies should now fall down to fill up those holes. Again, you’ll tackle this in two steps:

  1. Update the model.
  2. Animate the sprites.

Add this new method to Level.swift:

func fillHoles() -> [[Cookie]] {
  var columns = [[Cookie]]()
  // 1
  for column in 0..<NumColumns {
    var array = [Cookie]()
    for row in 0..<NumRows {
      // 2
      if tiles[column, row] != nil && cookies[column, row] == nil {
        // 3
        for lookup in (row + 1)..<NumRows {
          if let cookie = cookies[column, lookup] {
            // 4
            cookies[column, lookup] = nil
            cookies[column, row] = cookie
            cookie.row = row
            // 5
            array.append(cookie)
            // 6
            break
          }
        }
      }
    }
    // 7
    if !array.isEmpty {
      columns.append(array)
    }
  }
  return columns
}

This method detects where there are empty tiles and shifts any cookies down to fill up those tiles. It starts at the bottom and scans upward. If it finds a square that should have a cookie but doesn’t, then it finds the nearest cookie above it and moves this cookie to the empty tile.

Filling holes

Here is how it all works, step by step:

  1. You loop through the rows, from bottom to top.
  2. If there’s a tile at a position but no cookie, then there’s a hole. Remember that the tiles array describes the shape of the level.
  3. You scan upward to find the cookie that sits directly above the hole. Note that the hole may be bigger than one square (for example, if this was a vertical chain) and that there may be holes in the grid shape, as well.
  4. If you find another cookie, move that cookie to the hole. This effectively moves the cookie down.
  5. You add the cookie to the array. Each column gets its own array and cookies that are lower on the screen are first in the array. It’s important to keep this order intact, so the animation code can apply the correct delay. The farther up the piece is, the bigger the delay before the animation starts.
  6. Once you’ve found a cookie, you don't need to scan up any farther so you break out of the inner loop.
  7. If a column does not have any holes, then there's no point in adding it to the final array.

At the end, the method returns an array containing all the cookies that have been moved down, organized by column.

Note: The return type of fillHoles() is [[Cookie]], or an array-of-array-of-cookies. You can also write this as Array<Array<Cookie>>.

You’ve already updated the data model for these cookies with the new positions, but the sprites need to catch up. GameScene will animate the sprites and GameViewController is the in-between object to coordinate between the the model (Level ) and the view (GameScene).

Switch to GameScene.swift and add a new animation method:

func animateFallingCookies(columns: [[Cookie]], completion: () -> ()) {
  // 1
  var longestDuration: TimeInterval = 0
  for array in columns {
    for (idx, cookie) in array.enumerated() {
      let newPosition = pointFor(column: cookie.column, row: cookie.row)
      // 2
      let delay = 0.05 + 0.15*TimeInterval(idx)
      // 3
      let sprite = cookie.sprite!   // sprite always exists at this point
      let duration = TimeInterval(((sprite.position.y - newPosition.y) / TileHeight) * 0.1)
      // 4
      longestDuration = max(longestDuration, duration + delay)
      // 5
      let moveAction = SKAction.move(to: newPosition, duration: duration)
      moveAction.timingMode = .easeOut
      sprite.run(
        SKAction.sequence([
          SKAction.wait(forDuration: delay),
          SKAction.group([moveAction, fallingCookieSound])]))
    }
  }

  // 6
  run(SKAction.wait(forDuration: longestDuration), completion: completion)
}

Here’s how this works:

  1. As with the other animation methods, you should only call the completion block after all the animations are finished. Because the number of falling cookies may vary, you can’t hardcode this total duration but instead have to compute it.
  2. The higher up the cookie is, the bigger the delay on the animation. That looks more dynamic than dropping all the cookies at the same time. This calculation works because fillHoles() guarantees that lower cookies are first in the array.
  3. Likewise, the duration of the animation is based on how far the cookie has to fall (0.1 seconds per tile). You can tweak these numbers to change the feel of the animation.
  4. You calculate which animation is the longest. This is the time the game has to wait before it may continue.
  5. You perform the animation, which consists of a delay, a movement and a sound effect.
  6. You wait until all the cookies have fallen down before allowing the gameplay to continue.

Now you can tie it all together. Open GameViewController.swift. Replace the existing handleMatches() function with the following:

func handleMatches() {
  let chains = level.removeMatches()
  scene.animateMatchedCookies(for: chains) {
    let columns = self.level.fillHoles()
    self.scene.animateFallingCookiesFor(columns: columns) {
      self.view.isUserInteractionEnabled = true
    }
  }
}

This now calls fillHoles() to update the model, which returns the array that describes the fallen cookies and then passes that array onto the scene so it can animate the sprites to their new positions.

Note: To access a property or call a method in Objective-C you always had to use self. In Swift you don't have to do this, except inside closures. That's why inside handleMatches() you see self a lot. Swift insists on this to make it clear that the closure actually captures the value of self with a strong reference. In fact, if you don't specify self inside a closure, the Swift compiler will give an error message.

Try it out!

Falling animation

It’s raining cookies! Notice that the cookies even fall properly across gaps in the level design.

Adding New Cookies

There’s one more thing to do to complete the game loop. Falling cookies leave their own holes at the top of each column.

Holes at top

You need to top up these columns with new cookies. Add a new method to Level.swift:

func topUpCookies() -> [[Cookie]] {
  var columns = [[Cookie]]()
  var cookieType: CookieType = .unknown

  for column in 0..<NumColumns {
    var array = [Cookie]()

    // 1
    var row = NumRows - 1
    while row >= 0 && cookies[column, row] == nil {
      // 2
      if tiles[column, row] != nil {
        // 3
        var newCookieType: CookieType
        repeat {
          newCookieType = CookieType.random()
        } while newCookieType == cookieType
        cookieType = newCookieType
        // 4
        let cookie = Cookie(column: column, row: row, cookieType: cookieType)
        cookies[column, row] = cookie
        array.append(cookie)
      }

      row -= 1
    }
    // 5
    if !array.isEmpty {
      columns.append(array)
    }
  }
  return columns
}

Where necessary, this adds new cookies to fill the columns to the top. It returns an array with the new Cookie objects for each column that had empty tiles.

If a column has X empty tiles, then it also needs X new cookies. The holes are all at the top of the column now, so you can simply scan from the top down until you find a cookie.

Here’s how it works, step by step:

  1. You loop through the column from top to bottom. This while loop ends when cookies[column, row] is not nil—that is, when it has found a cookie.
  2. You ignore gaps in the level, because you only need to fill up grid squares that have a tile.
  3. You randomly create a new cookie type. It can’t be equal to the type of the last new cookie, to prevent too many "freebie" matches.
  4. You create the new Cookie object and add it to the array for this column.
  5. As before, if a column does not have any holes, you don't add it to the final array.

The array that topUpCookies() returns contains a sub-array for each column that had holes. The cookie objects in these arrays are ordered from top to bottom. This is important to know for the animation method coming next.

Switch to GameScene.swift and the new animation method:

func animateNewCookies(_ columns: [[Cookie]], completion: @escaping () -> ()) {
  // 1
  var longestDuration: TimeInterval = 0

  for array in columns {
    // 2
    let startRow = array[0].row + 1

    for (idx, cookie) in array.enumerated() {
      // 3
      let sprite = SKSpriteNode(imageNamed: cookie.cookieType.spriteName)
      sprite.size = CGSize(width: TileWidth, height: TileHeight)
      sprite.position = pointFor(column: cookie.column, row: startRow)
      cookiesLayer.addChild(sprite)
      cookie.sprite = sprite
      // 4
      let delay = 0.1 + 0.2 * TimeInterval(array.count - idx - 1)
      // 5
      let duration = TimeInterval(startRow - cookie.row) * 0.1
      longestDuration = max(longestDuration, duration + delay)
      // 6
      let newPosition = pointFor(column: cookie.column, row: cookie.row)
      let moveAction = SKAction.move(to: newPosition, duration: duration)
      moveAction.timingMode = .easeOut
      sprite.alpha = 0
      sprite.run(
        SKAction.sequence([
          SKAction.wait(forDuration: delay),
          SKAction.group([
            SKAction.fadeIn(withDuration: 0.05),
            moveAction,
            addCookieSound])
          ]))
    }
  }
  // 7
  run(SKAction.wait(forDuration: longestDuration), completion: completion)
}

This is very similar to the “falling cookies” animation. The main difference is that the cookie objects are now in reverse order in the array, from top to bottom. Step by step, this is what the method does:

  1. The game is not allowed to continue until all the animations are complete, so you calculate the duration of the longest animation to use later in step 7.
  2. The new cookie sprite should start out just above the first tile in this column. An easy way to find the row number of this tile is to look at the row of the first cookie in the array, which is always the top-most one for this column.
  3. You create a new sprite for the cookie.
  4. The higher the cookie, the longer you make the delay, so the cookies appear to fall after one another.
  5. You calculate the animation’s duration based on far the cookie has to fall.
  6. You animate the sprite falling down and fading in. This makes the cookies appear less abruptly out of thin air at the top of the grid.
  7. You wait until the animations are done before continuing the game.

Finally, in GameViewController.swift, replace the chain of completion blocks in handleMatches() with the following:

func handleMatches() {
  let chains = level.removeMatches()
  scene.animateMatchedCookies(chains) {
    let columns = self.level.fillHoles()
    self.scene.animateFallingCookiesFor(columns: columns) {
      let columns = self.level.topUpCookies()
      self.scene.animateNewCookies(columns) {
        self.view.isUserInteractionEnabled = true
      }
    }
  }
}

Try it out!

Adding new cookies

A Cascade of Cookies

You may have noticed a couple of oddities after playing for a while. When the cookies fall down to fill up the holes and new cookies drop from the top, these actions sometimes create new chains of three or more. But what happens then?

You also need to remove these matching chains and ensure other cookies take their place. This cycle should continue until there are no matches left on the board. Only then should the game give control back to the player.

Handling these possible cascades may sound like a tricky problem, but you’ve already written all the code to do it! You just have to call handleMatches() again and again and again until there are no more chains.

In GameViewController.swift, inside handleMatches(), change the line that sets isUserInteractionEnabled to:

self.handleMatches()

Yep, you’re seeing that right: handleMatches() calls itself. This is called recursion and it’s a powerful programming technique. There’s only one thing you need to watch out for with recursion: At some point, you need to stop it, or the app will go into an infinite loop and eventually crash.

For that reason, add the following to the top of handleMatches(), right after the line that calls removeMatches() on the level:

if chains.count == 0 {
  beginNextTurn()
  return
}

If there are no more matches, the player gets to move again and the function exits to prevent another recursive call.

Finally, add this new beginNextTurn() method:

func beginNextTurn() {
  view.isUserInteractionEnabled = true
}

Try it out. If removing a chain creates another chain elsewhere, the game should now remove that chain, as well:

Cascade

There’s another problem. After a while, the game no longer seems to recognize swaps that it should consider valid. There’s a good reason for that. Can you guess what it is?

Solution Inside: Solution SelectShow

The logic for this sits in Level.swift, in detectPossibleSwaps(). You need to call this method from beginNextTurn() in GameViewController.swift:

func beginNextTurn() {
  level.detectPossibleSwaps()
  view.isUserInteractionEnabled = true
}

Excellent! Now your game loop is complete. It has an infinite supply of cookies!

Where to Go From Here?

Once again, here is the sample project with all of the code from the Swift tutorial up to this point.

By now you're almost done and only have one part left of this exciting cookie crunching adventure.

In the final part, you’ll complete the gameplay by adding support for scoring points, winning and losing, shuffling the cookies, and more. We know you'll enjoy finishing up your game.

We love hearing what you have to say about our tutorials. Please take a moment to let us hear from you in the forums :]

Credits: Free game art from Game Art Guppy. The music is by Kevin MacLeod. The sound effects are based on samples from freesound.org.

Some of the source code for this tutorial series was inspired by Gabriel Nica's Swift port of the game.

Morten Faarkrog

Morten is a twenty-something Software Development student and iOS developer from Copenhagen, Denmark. He was first introduced to iOS development around the launch of Swift and has been in love with it ever since. He strives to learn something new about iOS development every single day and he has at least one side project running at all times.

When Morten isn't developing apps and studying, and his adorable cat isn't riding on his shoulders, he spends his time working out, reading interesting books, and diving into the world of biohacking.

You can find Morten on Twitter, Facebook and LinkedIn.

Other Items of Interest

Big Book SaleAll raywenderlich.com iOS 11 books on sale for a limited time!

raywenderlich.com Weekly

Sign up to receive the latest tutorials from raywenderlich.com each week, and receive a free epic-length tutorial as a bonus!

Advertise with Us!

PragmaConf 2016 Come check out Alt U

Our Books

Our Team

Video Team

... 19 total!

iOS Team

... 73 total!

Android Team

... 20 total!

Unity Team

... 11 total!

Articles Team

... 15 total!

Resident Authors Team

... 18 total!

Podcast Team

... 7 total!

Recruitment Team

... 9 total!