How to Make a Game Like Can Knockdown

Learn how to make a game like Can Knockdown using SceneKit and Swift. By Ryan Ackermann.

Leave a rating/review
Save for later
Share
You are currently viewing page 4 of 5 of this article. Click here to view the first page.

Adding Collision Detection

When the ball collides with other nodes in the level, you typically want to play sounds based on types of nodes participating in the collision. Also, when a can hits the floor you need to increase the score.

First, add the following property to GameViewController:

var bashedCanNames: [String] = []

You will use this to keep track of cans that have been hit.

To get started on handling collisions, add the following extension to the bottom of GameViewController.swift:

extension GameViewController: SCNPhysicsContactDelegate {
  
  // MARK: SCNPhysicsContactDelegate
  func physicsWorld(_ world: SCNPhysicsWorld, didBegin contact: SCNPhysicsContact) {
    guard let nodeNameA = contact.nodeA.name else { return }
    guard let nodeNameB = contact.nodeB.name else { return }
    
    // 1
    var ballFloorContactNode: SCNNode?
    if nodeNameA == "ball" && nodeNameB == "floor" {
      ballFloorContactNode = contact.nodeA
    } else if nodeNameB == "ball" && nodeNameA == "floor" {
      ballFloorContactNode = contact.nodeB
    }
    
    if let ballNode = ballFloorContactNode {
      // 2
      guard ballNode.action(forKey: GameHelper.ballFloorCollisionAudioKey) == nil else { return }
      
      ballNode.runAction(
        SCNAction.playAudio(
          helper.ballFloorAudioSource,
          waitForCompletion: true
        ),
        forKey: GameHelper.ballFloorCollisionAudioKey
      )
      return
    }
    
    // 3
    var ballCanContactNode: SCNNode?
    if nodeNameA.contains("Can") && nodeNameB == "ball" {
      ballCanContactNode = contact.nodeA
    } else if nodeNameB.contains("Can") && nodeNameA == "ball" {
      ballCanContactNode = contact.nodeB
    }
    
    if let canNode = ballCanContactNode {
      guard canNode.action(forKey: GameHelper.ballCanCollisionAudioKey) == nil else { 
        return 
      }
      
      canNode.runAction(
        SCNAction.playAudio(
          helper.ballCanAudioSource,
          waitForCompletion: true
        ),
        forKey: GameHelper.ballCanCollisionAudioKey
      )
      return
    }
    
    // 4
    if bashedCanNames.contains(nodeNameA) || bashedCanNames.contains(nodeNameB) { return }
    
    // 5
    var canNodeWithContact: SCNNode?
    if nodeNameA.contains("Can") && nodeNameB == "floor" {
      canNodeWithContact = contact.nodeA
    } else if nodeNameB.contains("Can") && nodeNameA == "floor" {
      canNodeWithContact = contact.nodeB
    }
    
    // 6
    if let bashedCan = canNodeWithContact {
      bashedCan.runAction(
        SCNAction.playAudio(
          helper.canFloorAudioSource,
          waitForCompletion: false
        )
      )
      bashedCanNames.append(bashedCan.name!)
      helper.score += 1
    }
  }
}

There’s a lot going on above, so let's unpack what’s happening:

  1. First you check to see if the contact was between the ball and the floor.
  2. You play a sound effect if the ball hits the floor.
  3. If the ball didn’t make contact with the floor, then you check to see if the ball contacted a can. If so, you also play an appropriate sound effect.
  4. If the can has already collided with the floor, simply bail because you’ve already resolved this collison.
  5. You now check if a can hit the floor.
  6. If the can contacted the floor, you keep track of the can’s name so you only handle this collision once. You also increment the score when a new can hits the floor.

There are a lot of collisions going on — and a lot to handle! But now that you now know when collisions occur, you can add in one of the best parts of a game — winning! :]

Add the following to the bottom of physicsWorld(_:didBegin:):

// 1
if bashedCanNames.count == helper.canNodes.count {
  // 2
  if levelScene.rootNode.action(forKey: GameHelper.gameEndActionKey) != nil {
    levelScene.rootNode.removeAction(forKey: GameHelper.gameEndActionKey)
  }
  
  let maxLevelIndex = helper.levels.count - 1
  
  // 3
  if helper.currentLevel == maxLevelIndex {
    helper.currentLevel = 0
  } else {
    helper.currentLevel += 1
  }
  
  // 4
  let waitAction = SCNAction.wait(duration: 1.0)
  let blockAction = SCNAction.run { _ in
    self.setupNextLevel()
  }
  let sequenceAction = SCNAction.sequence([waitAction, blockAction])
  levelScene.rootNode.runAction(sequenceAction)
}

Here’s what’s going on above:

  1. If the number of bashed cans is the same as the number of cans in the level, we advance to the next level.
  2. This removes the old game end action
  3. Once the last level is complete, loop through the levels again since the game is based on getting the highest score.
  4. Load the next level after a short delay.

To get the contact delegate working for your level scene, add the following at the top of createScene():

levelScene.physicsWorld.contactDelegate = self

Finally add the following right after presentLevel():

func resetLevel() {
  // 1
  currentBallNode?.removeFromParentNode()
  
  // 2
  bashedCanNames.removeAll()
  
  // 3
  for canNode in helper.canNodes {
    canNode.removeFromParentNode()
  }
  helper.canNodes.removeAll()
  
  // 4
  for ballNode in helper.ballNodes {
    ballNode.removeFromParentNode()
  }
}

This helps to clear out the state being tracked while the player is in the middle of playing through a level. Here’s what’s going on:

  1. If there is a current ball, remove it.
  2. Remove all of the bashed can names used in the contact delegate.
  3. Loop through the can nodes and remove each can from its parent, then clear out the array.
  4. Remove each ball node from the scene.

You’ll need to call this function in a couple places. Add the following code at the top of presentLevel():

resetLevel()

Replace the blockAction used to move on to the next level inside physicsWorld(_:didBegin:) with the following:

let blockAction = SCNAction.run { _ in
  self.resetLevel()
  self.setupNextLevel()
}

Build and run your game; you can finally play through the game! Well, that is, if you can beat each level in just one throw:

Game Play Through

You can’t really expect every player to have the skill to finish a level with one ball. Your next job is to implement a HUD so the player will be able to see their score and remaining balls.

Improving the Gameplay

Add the following at the end of createScene():

levelScene.rootNode.addChildNode(helper.hudNode)

Now the player can see their score and track the remaining balls. You still need a way of checking whether you should dispense another ball, or end the game.

Add the following at the end of throwBall():

if helper.ballNodes.count == GameHelper.maxBallNodes {
  let waitAction = SCNAction.wait(duration: 3)
  let blockAction = SCNAction.run { _ in
    self.resetLevel()
    self.helper.ballNodes.removeAll()
    self.helper.currentLevel = 0
    self.helper.score = 0
    self.presentMenu()
  }
  let sequenceAction = SCNAction.sequence([waitAction, blockAction])
  levelScene.rootNode.runAction(sequenceAction, forKey: GameHelper.gameEndActionKey)
} else {
  let waitAction = SCNAction.wait(duration: 0.5)
  let blockAction = SCNAction.run { _ in
    self.dispenseNewBall()
  }
  let sequenceAction = SCNAction.sequence([waitAction, blockAction])
  levelScene.rootNode.runAction(sequenceAction)
}

This if statement handles the case of the player throwing their last ball. It gives them a grace period of three seconds so the final can or two can stubbornly roll off the shelf. Otherwise, once the player has thrown the ball, you dispense a new ball after a short delay to give them another chance at bashing some more cans! :]

One final improvement is to also show the player their highest score so they can brag about it to their friends!

Add the following to presentMenu(), right after helper.state = .tapToPlay:

helper.menuLabelNode.text = "Highscore: \(helper.highScore)"

That piece of code refreshes the menu’s HUD so that the player can view their highest score!

You're all done! Build and run and see if you can beat your own high-score? :]

High Score