Create a simple 2D air hockey game in iOS using UIKit Dynamics part 1

Chandan Karmakar
8 min readSep 4, 2023

--

We will harness the power of the UIKit Dynamics library for our project. This library simplifies the process by imbuing UIViews with characteristics akin to physical objects in the real world. These “objects” possess attributes such as weight, velocity, acceleration, gravitational interaction, and collision behavior, allowing us to create realistic and engaging animations and interactions in our app.

Prerequisites

Before we begin, make sure you have the following:

  • Xcode installed on your Mac.
  • Basic knowledge of Swift programming.
  • Creativity and enthusiasm!

You should follow the steps and run each time to check its working properly before moving to the next step.

Step 1: Review the requirements

  1. Puck and Strikers which share similar properties. They are circular with the only different being their size, we can work with that. We can create a class can represent these objects.
  2. The Board can be represented by another class, which can hold Puck and Strikers. It also has boundaries on all four sides to contain the objects.
  3. Strikers cannot cross into the opponent’s space, while the Puck can freely move around the Board.
  4. If the Puck collides with the top or bottom sides, it should count as a score for the opposite player.

Let’s jump into code now.

Step 2: Set Up the project

Fire up Xcode and create a new project. Select UIKit instead of SwiftUI and choose only the Portrait mode of orientation.

Step 3: Create views and placements

Create two custom UIViews: CircularView to represent the puck and Strikers, and BoardView to represent the Board. Create 2 strikers, 1 puck, and a couple of items needed in the ViewController.swift file.

class CircularView: UIView {

// to make the view circular
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = bounds.height / 2
}
}
class BoardView: UIView { }
class ViewController: UIViewController {

let color1 = UIColor.red
let color2 = UIColor.blue

var lblMiddle: UILabel!
var lbl1: UILabel!
var lbl2: UILabel!

let strikerSize = 60
let ballSize = 40

var striker1: CircularView!
var striker2: CircularView!
var puck: CircularView!
var boardView: BoardView!

var player1SideView: UIView!
var player2SideView: UIView!

override func viewDidLoad() {
super.viewDidLoad()

player1SideView = UIView(frame: CGRect(x: 0, y: 0, width: view.frame.width, height: view.frame.height / 2))

player2SideView = UIView(frame: CGRect(x: 0.0, y: view.frame.height / 2, width: view.frame.width, height: view.frame.height / 2))

boardView = BoardView(frame: CGRect(x: 0, y: 0, width: view.frame.width, height: view.frame.height))

striker1 = CircularView(frame: CGRect(x: 0, y: 0, width: strikerSize, height: strikerSize))
striker1.backgroundColor = color1
striker1.center = player1SideView.center

striker2 = CircularView(frame: CGRect(x: 0, y: 0, width: strikerSize, height: strikerSize))
striker2.backgroundColor = color2
striker2.center = player2SideView.center

puck = CircularView(frame: CGRect(x: 0, y: 0, width: ballSize, height: ballSize))
puck.backgroundColor = .black
puck.center = CGPoint(x: boardView.frame.size.width / 2, y: boardView.frame.size.height / 2)

boardView.addSubview(striker1)
boardView.addSubview(striker2)
boardView.addSubview(player1SideView)
boardView.addSubview(player2SideView)
boardView.addSubview(puck)
view.addSubview(boardView)
}
}

Utilize the frame property to position the views in their respective locations. player1SideView and player2SideView are required for touch events for both players and do not require any dynamics. Currently, the BoardView class does not have any methods, but we will need it in the future. Run the project, and it will resemble the following.

Step 4: Control the strikers with touch

Add the following code to the ViewController. This code will enable the strikers to move when we drag them with our finger. Call addGestureRecognizers() at the end of the viewDidLoad method.

override func viewDidLoad() {
...
addGestureRecognizers()
}

var animator: UIDynamicAnimator!
var snap1: UISnapBehavior!
var snap2: UISnapBehavior!

func addGestureRecognizers() {
animator = UIDynamicAnimator(referenceView: boardView)

player1SideView.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(handlePan1(_:))))
player1SideView.isUserInteractionEnabled = true

player2SideView.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(handlePan2(_:))))
player2SideView.isUserInteractionEnabled = true
}

@objc func handlePan1(_ gesture: UIGestureRecognizer) {
if snap1 != nil {
animator.removeBehavior(snap1)
}
let locTo = gesture.location(in: boardView)
snap1 = UISnapBehavior(item: striker1, snapTo: locTo)
animator.addBehavior(snap1)
}

@objc func handlePan2(_ gesture: UIGestureRecognizer) {
if snap2 != nil {
animator.removeBehavior(snap2)
}
let locTo = gesture.location(in: boardView)
snap2 = UISnapBehavior(item: striker2, snapTo: locTo)
animator.addBehavior(snap2)
}

UIDynamicAnimator provides physics-related capabilities and animations, with the boardView serving as the reference coordinate system.

Each time the finger moves, a new location is sent to the gesture handler, and on each movement, we create a new UISnapBehaviour to snap the striker to the new touch location. It's important to remove the previous snap object, as failing to do so may cause confusion regarding where to snap.

Run the project, and you'll be able to move the strikers by dragging.

Step 5: Bring them to life

Enable collisions between them by adding UICollisionBehavior to our previously created UIDynamicAnimator object. UICollisionBehavior allows items to collide with each other. Call addCollisionBehavior() at the end of the viewDidLoad method.

override func viewDidLoad() {
...
addCollisionBehaviour()
}

var collision: UICollisionBehavior!

func addCollisionBehaviour() {
collision = UICollisionBehavior(items: [striker1, striker2, puck])
animator.addBehavior(collision)
}

That’s it! When you run the project, you’ll be able to hit the puck with the striker now. However, there’s one issue: the collision bounds of the CircularView are currently the square frames of the striker and puck, resulting in a visible gap when colliding.

To resolve this issue, override collisionBoundsType for CircularView and set it to UIDynamicItemCollisionBoundsType.ellipse. This adjustment will utilize the view's height and width to establish circular collision bounds, addressing the gap problem during collisions.

class CircularView: UIView {
...

public override var collisionBoundsType: UIDynamicItemCollisionBoundsType {
return .ellipse
}
}

Now, all the items are in contact with each other.

Step 6: Add boundaries

First, incorporate these helpful extensions that will simplify our tasks.

extension CGRect {
var end: CGPoint {
return CGPoint(x: origin.x + size.width, y: origin.y + size.height)
}
var topRight: CGPoint {
return CGPoint(x: origin.x + size.width, y: origin.y)
}
var botLeft: CGPoint {
return CGPoint(x: origin.x, y: origin.y + size.height)
}
}

extension CGPoint {
func offsetBy(x x_: CGFloat, y y_: CGFloat) -> CGPoint {
return CGPoint(x: x + x_, y: y + y_)
}
}

extension NSCopying {
var string: String {
return self as! String
}
}

extension String {
var nsCopying: NSCopying {
return self as NSCopying
}
}

Add the following code to establish boundaries and prevent objects from going outside the BoardView.

var collision: UICollisionBehavior!
var collision1: UICollisionBehavior!
var collision2: UICollisionBehavior!

func addCollisionBehaviour() {
collision = UICollisionBehavior(items: [striker1, striker2, puck])
animator.addBehavior(collision)

// add boundaries for puck
collision.addBoundary(withIdentifier: "boundary_side_top".nsCopying, from: boardView.bounds.origin, to: boardView.bounds.topRight)
collision.addBoundary(withIdentifier: "boundary_side_left".nsCopying, from: boardView.bounds.origin, to: boardView.bounds.botLeft)
collision.addBoundary(withIdentifier: "boundary_side_right".nsCopying, from: boardView.bounds.topRight, to: boardView.bounds.end)
collision.addBoundary(withIdentifier: "boundary_side_bot".nsCopying, from: boardView.bounds.botLeft, to: boardView.bounds.end)

collision1 = UICollisionBehavior(items: [striker1])
collision1.addBoundary(withIdentifier: "boundary_striker1".nsCopying, for: UIBezierPath(rect: player1SideView.frame))

collision2 = UICollisionBehavior(items: [striker2])
collision2.addBoundary(withIdentifier: "boundary_striker2".nsCopying, for: UIBezierPath(rect: player2SideView.frame))

animator.addBehavior(collision)
animator.addBehavior(collision2)
animator.addBehavior(collision1)
}

Now, as the puck can move through the board, we’ve added four edges to the collision as boundaries. We use different identifiers (boundary_side_top and boundary_side_bot) to detect a score when the puck collides with these boundaries. Additionally, to prevent strikers from entering the other player's space, we've added the playerSideView.frame as a boundary.

Step 7: Game logic

Now that all dynamics-related properties are functioning correctly, it’s time to conclude the game by implementing the score logic and resetting the objects to their initial positions. Before doing that, let’s display the score count on each player’s side and a central message for the final result, such as ‘Player 1/2 Wins’.

var lblMiddle: UILabel!
var lbl1: UILabel!
var lbl2: UILabel!

override func viewDidLoad() {
...
lbl1 = UILabel(frame: CGRect.zero)
lbl1.font = UIFont.boldSystemFont(ofSize: 120)
lbl1.textColor = color1.withAlphaComponent(0.2)
lbl1.textAlignment = .right
lbl1.frame = player1SideView.frame.insetBy(dx: 20, dy: 0)
lbl1.text = "0"

lbl2 = UILabel(frame: CGRect.zero)
lbl2.font = UIFont.boldSystemFont(ofSize: 120)
lbl2.textColor = color2.withAlphaComponent(0.2)
lbl2.textAlignment = .left
lbl2.frame = player2SideView.frame.insetBy(dx: 20, dy: 0)
lbl2.text = "0"

lblMiddle = UILabel(frame: boardView.bounds)
lblMiddle.textAlignment = .center
lblMiddle.text = ""
lblMiddle.font = UIFont.boldSystemFont(ofSize: 60)
lblMiddle.textColor = .black.withAlphaComponent(0.5)

boardView.addSubview(lbl1)
boardView.addSubview(lbl2)
boardView.addSubview(lblMiddle)
...
}

Let’s set up detection for when the Puck hits the top and bottom edges.



func addCollisionBehaviour() {
...
collision.collisionDelegate = self
}

extension ViewController: UICollisionBehaviorDelegate {
func collisionBehavior(_ behavior: UICollisionBehavior, beganContactFor item: UIDynamicItem, withBoundaryIdentifier identifier: NSCopying?, at p: CGPoint) {
if item === puck {
if identifier?.string == "side_top" {
print("score player 1")
score(player: 1)
} else if identifier?.string == "side_bot" {
print("score player 2")
score(player: 1)
}
}
}
}

Now we can increase score count and display the results.

func score(player: Int) {
// disable everything
boardView.isUserInteractionEnabled = false

// remove all behaviours
animator.removeAllBehaviors()

// hide puck after score
puck.alpha = 0

var lblAnim: UILabel!
if player == 1 {
lbl2.text = "\(Int(lbl2.text!)! + 1)"
lblAnim = lbl2
} else {
lbl1.text = "\(Int(lbl1.text!)! + 1)"
lblAnim = lbl1
}
lblMiddle.text = "Win player \(player)"

// add little scale animation to the labels
UIView.animate(withDuration: 1.0, delay: 0.0, options: [.curveEaseInOut], animations: {
lblAnim.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
self.lblMiddle.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
}, completion: { _ in
UIView.animate(withDuration: 1.0, delay: 0.0, options: [.curveEaseInOut], animations: {
lblAnim.transform = CGAffineTransform.identity
self.lblMiddle.transform = .identity
}, completion: nil)
})

// afte 2 seconds restart the game
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.restartGame()
}
}

The score() method takes an argument (1/2) to determine which player scores in the game. To prevent user interaction with the board during this time, we disable it. Then, we set the labels accordingly and apply a small animation effect to them. After a 2-second delay, the restartGame() method resets the middle label and removes any remaining behaviors. Finally, we use animation to return the Puck and Strikers to their respective locations.

func restartGame() {
// reset middle lable
lblMiddle.text = ""

// again remove behaviours if any left
animator.removeAllBehaviors()

// puck and striker reposition animated
UIView.animate(withDuration: 1.0, delay: 0.0, options: [.curveEaseInOut], animations: {
self.striker1.center = self.player1SideView.center
self.striker2.center = self.player2SideView.center
self.puck.center = self.boardView.center
self.puck.alpha = 1
}) { _ in
// add all behaviours again after animation
self.boardView.isUserInteractionEnabled = true
self.addCollisionBehaviour()
}
}

Congratulations! You’ve learned how to create a basic two-player air hockey game using Swift and UIKit Dynamics. You can now explore further enhancements, such as adding sound effects or improving the game’s visuals in the part 2 which is coming soon.

Here is the complete project link.

Thank you for following along with this tutorial. Feel free to leave comments or questions, and don’t forget to share your modified versions of the game or your own game development projects!

--

--