A simple physics based Air Hockey game

This task builds a simple implementation of Air Hockey for 2 players (for iPad) The first part of the task involves creating a class for a mallet – the mallet will be used to hit the puck, and it needs to be able to send information to the scene to say how hard it has hit the puck.

Creating the Mallet

  1. Create a new sprite kit game for iOS and iPad devices. It will simulate a two-player air hockey game, so you might want to call it iPadAirHockey.
  2. Set the game to use portrait orientation only: in the project navigator (left hand side of Xcode) select the project. Under the deployment info settings, ensure only the Portrait device orientation is checked.
  3. The first part of the task involves creating a class for a mallet – the mallet will be used to hit the puck, and it needs to be able to send information to the scene to say how hard it has hit the puck. Add a new file, use the iOS Swift File template, and name it Mallet (Xcode will append the .swift extension)
  4. Change the first line to read import SpriteKit
  5. Add the following code to the file (underneath the previous line) to declare the class:
    class Mallet: SKShapeNode {
        
    }
  6. Because the mallet will act on the puck (e.g. when the player moves it to hit the puck), we need to be able to send messages to it. This is done through delegation. Add the following code to declare a protocol, which we will use to define a method to send the force from the mallet:
    protocol MalletDelegate {
        func force(force: CGVector, fromMallet mallet: Mallet)
    }
  7. Inside the class declaration, add the following code to create the variables we need:
    //keep track of previous touch time (will use to calculate vector)
    var lastTouchTimeStamp: Double?
        
    //delegate will refer to class which will act on mallet force
    var delegate:MalletDelegate?
        
    //this will determine the allowable area for the mallet
    let activeArea:CGRect
    //define mallet size
    let radius:CGFloat = 40.0
  8. Add the following code to allow the class to be instantiated with a pre-determined area in which the mallet should be allowed to operate:
    //when we instantiate the class we will set the active area
    init(activeArea: CGRect) {
        //set the active area variable this class with the variable passed in
        self.activeArea = activeArea
            
        //ensure we pass the init call to the base class
        super.init()
            
        //allow the mallet to handle touch events
        userInteractionEnabled = true
    }
  9. Now add the following code to the init(activeArea:) method which will setup the mallet:
    //create a mutable path (later configured as a circle)
    let circularPath = CGMutablePath()
            
    //define pi as CGFloat (type π using alt-p)
    let π = CGFloat.pi
            
    //create the circle shape
    circularPath.addArc(center: CGPoint(x: 0, y:0), radius: radius, startAngle: 0, endAngle: 2*π, clockwise: true)
           
    //assign the path to this SKShapeNode's path property
    path = circularPath
            
    lineWidth = 0;        
    fillColor = .red
    
    //set physics properties (note physicsBody is an optional)
    physicsBody = SKPhysicsBody(circleOfRadius: radius)
    physicsBody!.mass = 500;
    physicsBody!.affectedByGravity = false;
    
  10. At this stage there will be an error showing in Xcode, either, by using the 'Fix-it' option on the error, or by typing it in, add the following code:
      required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    The code is now in place to create a pre-made mallet with the correct shape, size, colour and physics properties which we can use in a scene.

  11. The next stage is to enable the mallet to calculate a vector representing the direction and speed of the mallet as it is moved by the player. Implement the touchesMoved(_ touches: with event:) method as shown below:

    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
            
        var relevantTouch:UITouch!
        
        //get array of touches so we can loop through them
        let orderedTouches = Array(touches)
            
        for touch in orderedTouches{
        //if we've not yet found a relevant touch
            if relevantTouch == nil{
                //look for a touch that is in the activeArea (Avoid touches by opponent)
                if CGRectContainsPoint(activeArea, touch.locationInNode(parent!)){
                    relevantTouch = touch
                }
            }
        }
                    
        if (relevantTouch != nil){
                
            //get touch position and relocate mallet
            let location = relevantTouch!.location(in: parent!)
            position = location
                
            //find old location and use Pythagoras to determine length between both points
            let oldLocation = relevantTouch!.previousLocation(in: parent!)
            let xOffset = location.x - oldLocation.x
            let yOffset = location.y - oldLocation.y
            let vectorLength = sqrt(xOffset * xOffset + yOffset * yOffset)
                
            //get elapsed time and use to calculate speed
            if lastTouchTimeStamp != nil {
            	let seconds = relevantTouch.timestamp - lastTouchTimeStamp!
            	let velocity = 0.01 * Double(vectorLength) / seconds
                
            	//to calculate the vector, the velcity needs to be converted to a CGFloat
            	let velocityCGFloat = CGFloat(velocity)
                
            	//calculate the impulse
            	let directionVector = CGVector(dx: velocityCGFloat * xOffset / vectorLength, dy: velocityCGFloat * yOffset / vectorLength)
            
            	//pass the vector to the scene (so it can apply an impulse to the puck)    
            	delegate?.force(directionVector, fromMallet: self)
            }    
            //update latest touch time for next calculation
            lastTouchTimeStamp = relevantTouch.timestamp
                
        }
    }

Creating the game scene

  1. Open GameViewController.swift and modify the supportedInterfaceOrientations() method to appear as shown below:
    override func supportedInterfaceOrientations() -> UIInterfaceOrientationMask {
            return UIInterfaceOrientationMask.Portrait
    }
  2. Also within GameViewController.swift, inside the viewDidLoad() method, before the final line, add this code to ensure the scenes size matches that of the portrait view:
    scene.size = skView.frame.size
  3. Open the GameScene.swift file.
  4. In order for the GameScene to handle interactions from the mallet, we need to declare modify it so that it adopts the protocol of the Mallet class, in order to do this, modify the line that declares the class so it appears as shown below:
    class GameScene: SKScene, MalletDelegate {
  5. Xcode will now be showing a warning saying that GameScene does not conform to MalletDelegate protocol. In order to resolve this implement the method shown below, inside the GameScene class (you can start typing force and hit tab to have Xcode add it for you automatically):
    func force(force: CGVector, fromMallet mallet: Mallet) {
    }

    We will return to this method later

  6. Inside the class, but outside any existing methods, declare the variables for the puck and both mallets, as shown below:
    var puck : SKShapeNode?
    var southMallet : Mallet?
    var northMallet : Mallet?
    
  7. Remove the existing code from within the didMove(to view:) method and replace it with
    view.isMultipleTouchEnabled = true;
  8. Delete the touchesBegan(_ touches: with event:) functions, as we do not need it.
  9. Next, we will write a function to draw the centre line on the air hockey table, so players know how have they should move their mallets. Add the code shown below to do this:
    func drawCenterLine(){
        
        let centerLine = SKSpriteNode(color: UIColor.white, size: CGSize(width:size.width, height:10))
        centerLine.position = CGPoint(x: size.width/2, y: size.height/2)
        centerLine.colorBlendFactor = 0.5;
        addChild(centerLine)  
    }
  10. We will also write code to create a mallet at a set position, and within a set area - this saves duplicating code, as we have two mallets to add. Add the following method inside the GameScene class:
    func malletAt(position: CGPoint, withBoundary boundary:CGRect) -> Mallet{
            
        let mallet = Mallet(activeArea: boundary)
        mallet.position = position
        mallet.delegate = self
        addChild(mallet)
        return mallet;
    }
  11. Then add code that will use this function to create mallets at the top and bottom of the screen:
    func createMallets(){
        let southMalletArea = CGRect(x: 0, y: 0, width: size.width, height: size.height/2)
        let southMalletStartPoint = CGPoint(x: frame.midX, size.height/4)
            
        let northMalletArea = CGRect(x: 0, y: size.height/2, width: size.width, height: size.height)
        let northMalletStartPoint = CGPoint(x: frame.midX, y: size.height * 0.75)
            
        southMallet = malletAt(position: southMalletStartPoint, withBoundary: southMalletArea)
        northMallet = malletAt(position: northMalletStartPoint, withBoundary: northMalletArea)
    }
  12. Add two lines of code to the didMoveToView() function which will call the drawCenterLine() and createMallets() functions:
    drawCenterLine()
    createMallets()
  13. Run the game and check you can see the centre line and two red mallets, each in the middle of its own half of the scene. You should be able to drag them about, but only in their own half.
  14. The next stage is to add the puck, add the following method as shown below:
    func resetPuck(){
            
        if  puck == nil{
                
            //create puck object
            puck = SKShapeNode()
                
            //draw puck
            let radius : CGFloat = 30.0
            let puckPath = CGMutablePath()
            let π = CGFloat.pi
    
            puckPath.addArc(center: CGPoint(x: 0, y:0), radius: radius, startAngle: 0, endAngle: 2*π, clockwise: true)
    
            puck!.path = puckPath
            puck!.lineWidth = 0
            puck!.fillColor = UIColor.blue
                
            //set puck physics properties
            puck!.physicsBody = SKPhysicsBody(circleOfRadius: radius)
                
            //how heavy it is
            puck!.physicsBody!.mass = 0.02
            puck!.physicsBody!.affectedByGravity = false
                
            //how much momentum is maintained after it hits somthing
            puck!.physicsBody!.restitution = 0.85
                
            //how much friction affects it
            puck!.physicsBody!.linearDamping = 0.4
        }
            
        //set puck position at centre of screen
        puck!.position = CGPoint(size.width/2, size.height/2)
        puck!.physicsBody!.velocity = CGVector(dy: 0, dy: 0)
            
        //if not alreay in scene, add to scene
        if puck!.parent == nil{
            addChild(puck!)
        }
    }
    
  15. Add a call to the method you just created in the didMoveToView() method:
    resetPuck()
  16. Run the game and you should see two mallets and a puck, the mallets should be movable, but wont affect the puck quite as desired.
  17. The next stage is to implement the method that responds to the motion of the mallet (the ‘delegate’ method). Add the following code inside the force(_ force: fromMallet:) method, which responds to movement from a mallet, and if it is touching the puck, applies the appropriate force to it:
    //Be aware this is not the best way of checking the collision between two circles
    if mallet.frame.intersects(puck!.frame){
        puck!.physicsBody!.applyImpulse(force)
    }

    You should now be able to use a mallet to hit the puck, and it should behave as expected. The only problem is, once it goes of screen there's no retrieving it.

  18. Add the following function to the class, which will create the edges needed to constrain the puck
    func createEdges(){
            
        let bumperDepth = CGFloat(20.0)
            
        let leftEdge = SKSpriteNode(color: UIColor.blue, size: CGSize(width: bumperDepth, height: size.height))
        leftEdge.position = CGPoint(x: bumperDepth/2, y: frame.height/2)
            
        //setup physics for this edge
        leftEdge.physicsBody = SKPhysicsBody(rectangleOfSize: leftEdge.size)
        leftEdge.physicsBody!.dynamic = false
        addChild(leftEdge)
            
        //copy the left edge and position it as the right edge
        let rightEdge = leftEdge.copy() as! SKSpriteNode
        rightEdge.position = CGPoint(x: size.width - bumperDepth/2, y: frame.height/2)
        addChild(rightEdge)
            
        //calculate some values for the end bumpers (four needed to allow for goals)
        let endBumperWidth = (size.width / 2) - 150
        let endBumperSize = CGSize(width: endBumperWidth, height: bumperDepth)
        let endBumperPhysics = SKPhysicsBody(rectangleOfSize: endBumperSize)
        endBumperPhysics.dynamic = false;
            
        //create a bottom edge
        let bottomLeftEdge = SKSpriteNode(color: UIColor.blueColor(), size: endBumperSize)
        bottomLeftEdge.position = CGPoint(x: endBumperWidth/2, y: bumperDepth/2)
        bottomLeftEdge.physicsBody = endBumperPhysics
        addChild(bottomLeftEdge)
            
        //copy edge to other three locations
        let bottomRightEdge = bottomLeftEdge.copy() as! SKSpriteNode
        bottomRightEdge.position = CGPoint(x: size.width - endBumperWidth/2, y: bumperDepth/2)
        addChild(bottomRightEdge)
            
        let topLeftEdge = bottomLeftEdge.copy() as! SKSpriteNode
        topLeftEdge.position = CGPoint(x: endBumperWidth/2, y: size.height - bumperDepth/2)
        addChild(topLeftEdge)
            
        let topRightEdge = bottomRightEdge.copy() as! SKSpriteNode
        topRightEdge.position = CGPoint(x: size.width - endBumperWidth/2, y: size.height - bumperDepth/2 )
        addChild(topRightEdge)  
    }
    
  19. Now add a call to the createEdges() function in the didMoveToView() method:
    createEdges()
  20. Run the game and it should be playable until a goal is scored. Ideally we need to replace the puck if it goes off screen. Add the following helper function to do just that:
    func isOffScreen(node: SKShapeNode) -> Bool{
        return !frame.contains(node.position)
    }
    
  21. Finally call this method in the update(currentTime:) loop function to check if the puck has gone through a goal:
    if  isOffScreen(node: puck!){
        resetPuck()
    }

    You should now be able to play the game

A completed version of the game can be downloaded from GitHub

Further Tasks

Write your own code (which you will likely need to research first), to add the following features:

Advanced Task

Make the game a single player game, writing some artificial intelligence to move the opponent’s mallet. Ensure that it is still possible, but not too easy, to win.