On March 13 2018, Apple announced WWDC 2018. As in previous years, tickets were available via the ticket lottery, as well as Apple's scholarship program, where 350 students apply to receive a free ticket and lodging for WWDC.
Although I had just started learning Swift, I decided to try my luck and apply for a scholarship anyways. I was incredibly ecstatic to be awarded a scholarship, and in June 2018 I attended WWDC and learned a ton as well as met tons of other scholars and Apple engineers.
In this blog post, I will walk through how I created DoublePong, my WWDC18 scholarship application Swift Playground. DoublePong is an adaptation of the classic Pong game, with paddles on all four sides of the screen.
On the original Mac version, DoublePong is played by moving your mouse horizontally or vertically to move the paddles. On the adapted iOS version, there is support for tilting your device or using your finger to control the paddles, but this tutorial will only cover the original macOS version.
⚠️ Note: I have not modified most of the code used in this blog post since my original WWDC scholarship submission except to update it to work on the latest versions of Swift and Xcode. Some of this code does not exhibit best practices, and shouldn't be reused, but it has been left to demonstrate how I created my original winning scholarship submission. To assist you, I have placed notices in some places where I feel this is prevalent.
Getting ready
Get started by creating an empty macOS Playground in Xcode (File → New → Playground). Then, press Command-1 or select View → Navigators → Show Project Navigator to show Xcode's project navigator sidebar on the left with your Playground's structure.
To simplify some of the code in this post, I've created a small Swift file with some useful extensions, such as a convenient way to add multiple subviews. Click below to download this file, which is required for some of the code we'll write later.
After downloading and extracting the archive, put Extensions.swift
into your Playground's Sources
folder.
Setting up the scene
Under the hood, DoublePong uses SpriteKit, Apple's 2D game engine. To use it, we will create a custom SKScene
and present it in our playground's 'live view'. Enable the playground's live view now by pressing Option-Command-Enter or clicking the overlapping circles on the top right of Xcode's toolbar to show the Assistant Editor.
Now that our playground is ready, navigate to the main playground file by clicking it in the project navigator, then add this code, which will set up our scene and show it in the live view in the Assistant Editor:
import AppKit
import SpriteKit
import PlaygroundSupport
scene.scaleMode = .aspectFit
let view = NSView(frame: CGRect(x: 0, y: 0, width: 640, height: 360))
let skView = SKView(frame: CGRect(x: 0, y: 0, width: 640, height: 360))
skView.presentScene(scene)
view.addSubview(skView)
PlaygroundPage.current.liveView = view
The above code sets the scale mode of our scene, then embeds it within a SKView
which is embedded in an NSView
, which we then display in the live view. You'll notice that this code won't compile yet, as we haven't created the scene that we are using.
To allow us to use the scene everywhere, create a new file named Global.swift
in the Sources
folder, where we'll place all our global variables.
⚠️ Note: As per the note at the top of this post, some of the code shown here was written by me a long time ago, and isn't the best. In particular, you should avoid creating global variables as this can clutter your code. They have been used here because of the relatively small size of a playground and to simplify the code.
In the new file you created, make sure to import the AppKit
and SpriteKit
frameworks, then create a private constant for our scene:
import AppKit
import SpriteKit
public let scene = Scene()
We're getting closer, but our playground will still fail to run, because Xcode doesn't know what class Scene
is.
To fix the issue, create a new file named Scene.swift
in Sources
, where we'll place all our custom scene code, where we'll override SKScene
's default didMove(to:)
function to set up our scene:
public class Scene: SKScene, SKPhysicsContactDelegate {
// The scene was created. Setup the game elements and start the game.
override public func didMove(to view: SKView) {
// Setup the world physics
physicsWorld.gravity = CGVector(dx: 0, dy: 0)
physicsWorld.contactDelegate = self
// Create a border in the view to keep the ball inside
size = CGSize(width: 1920, height: 1080)
let margin: CGFloat = 50
let physicsBody = SKPhysicsBody(edgeLoopFrom: CGRect(x: margin, y: margin, width: size.width - margin * 2, height: size.height - margin * 2))
physicsBody.friction = 0
physicsBody.restitution = 0
self.physicsBody = physicsBody
}
}
As explained in the comments, the above code sets up our physics and 'contact delegate', which will let us receive notifications whenever two of our sprites, such as the ball and paddles we'll add later, collide (touch).
Your playground will now run - but it's just an empty scene! In the next section, we'll add our sprites and allow the user to move their mouse to control the paddles.
Adding the sprites
Now that our playground runs, we need to add our ball and paddles, or we'll be stuck with an empty screen!
Go back to Global.swift
and add the following variables, which will hold our balls and each of our paddles. The additional constant, themeColor
, can be any color you'd like and will be the color of all the sprites.
public let themeColor = NSColor.red
public var ball = SKShapeNode(circleOfRadius: 30)
public var topPaddle = SKSpriteNode()
public var leftPaddle = SKSpriteNode()
public var rightPaddle = SKSpriteNode()
public var bottomPaddle = SKSpriteNode()
public var randomObstacle = SKSpriteNode()
We also need to add some bit mask variables, so that SpriteKit knows which sprite is which and can tell us when they collide. Later on, we'll set up the paddles and balls with their bit mask and tell SpriteKit to notify us when the sprite collides with another bit mask.
public let Ball: UInt32 = 0x1 << 0
public let topPaddleI: UInt32 = 0x1 << 1
public let leftPaddleI: UInt32 = 0x1 << 2
public let rightPaddleI: UInt32 = 0x1 << 3
public let bottomPaddleI: UInt32 = 0x1 << 4
public let randomObstacleI: UInt32 = 0x1 << 5
Note that these are let
constants as we don't want them to change, while our sprites are variables as we'll set them up separately in Scene.swift
. Go to it now and we'll add a new function in which we'll set up our ball sprite:
func setupBall() {
ball.name = "ball"
ball.fillColor = themeColor
ball.strokeColor = themeColor
ball.position = CGPoint(x: CGFloat.random(in: 325...1595), y: CGFloat.random(in: 325...755))
let physicsBody = SKPhysicsBody(circleOfRadius: 30)
physicsBody.velocity = CGVector(dx: 400, dy: 400)
physicsBody.friction = 0
physicsBody.restitution = 1
physicsBody.linearDamping = 0
physicsBody.allowsRotation = false
physicsBody.categoryBitMask = Ball
physicsBody.contactTestBitMask = randomObstacleI
ball.physicsBody = physicsBody
}
In the function, we're setting the name of the sprite so we can easily identify it later when detecting collisions. We're also positioning the ball at a random position within our game grid using the new CGFloat.random(in:)
function introduced in Swift 4.2.
We then set the ball's SKPhysicsBody
, which determines how the ball bounces and acts in the scene, specifically: - the friction
is set to 0 to avoid it slowing down when it bounces, making the game fun - the restitution
is how much energy lost when the ball bounces off another sprite - the linearDamping
is set to 0 so that no damping, which simulates air friction, is applied
We have now set up our ball, so if you add setupBall()
to our didMove(to:)
function from earlier and run the playground...... nothing happens!
This is because while we have now created and set up our ball we still haven't added it to our actual scene. We'll set up our paddles and then return to this later, so we can add all our sprites together.
To set up the paddles, we'll add a new function named setupPaddles()
where we'll set up all of our paddles:
func setupPaddles() {
let randomHorizontalPosition = CGFloat.random(in: 325...1595)
let horizontalPaddleSize = CGSize(width: 550, height: 50)
let randomVerticalPosition = CGFloat.random(in: 325...755)
let verticalPaddleSize = CGSize(width: 50, height: 550)
topPaddle = createNode(color: themeColor, size: horizontalPaddleSize, name: "topPaddle", dynamic: false, friction: 0, restitution: 1, cBM: topPaddleI, cTBM: Ball, position: CGPoint(x: randomHorizontalPosition, y: frame.maxY - 50))
bottomPaddle = createNode(color: themeColor, size: horizontalPaddleSize, name: "bottomPaddle", dynamic: false, friction: 0, restitution: 1, cBM: bottomPaddleI, cTBM: Ball, position: CGPoint(x: randomHorizontalPosition, y: frame.minY + 50))
leftPaddle = createNode(color: themeColor, size: verticalPaddleSize, name: "leftPaddle", dynamic: false, friction: 0, restitution: 1, cBM: leftPaddleI, cTBM: Ball, position: CGPoint(x: frame.minX + 50, y: randomVerticalPosition))
rightPaddle = createNode(color: themeColor, size: verticalPaddleSize, name: "rightPaddle", dynamic: false, friction: 0, restitution: 1, cBM: rightPaddleI, cTBM: Ball, position: CGPoint(x: frame.maxX - 50, y: randomVerticalPosition))
}
As you can see, we are first defining our sizes and some random positions for our paddles. Then, we use the helper method that was included in Extensions.swift
(which you should have downloaded earlier in this tutorial) to quickly create each paddle with the size and theme color we have set earlier.
The cBM
is the contactBitMask
, and it tells SpriteKit which of the bit masks (that we created earlier) identifies this sprite. It goes hand in hand with the cTBM
, the contactTestBitMask
, which tells SpriteKit which sprites it should send a collision notification for. For example, if our cTBM
for the right paddle was leftPaddle
, it would send a notification (which we haven't set up yet) when it collides with the left paddle. Here, we are setting all of the test bit maps to Ball
, because they will all collide with the ball.
Now that we have set up all of the required sprites, go ahead and add the following line to the bottom of didMove(to:)
, which will add all the sprites to the scene.
addChilds(ball, topPaddle, bottomPaddle, leftPaddle, rightPaddle)
If you now run the playground, you'll see the sprites are all created and the ball will start bouncing around!
Mouse control
Before we set up our collision detection, we need to register for mouse events, which will allow us to move the paddles based on where the user moves their mouse.
We'll create a new function named registerForMouseEvents(on:)
, which we can call to register for mouse events on our view:
func registerForMouseEvents(on view: SKView) {
let options: NSTrackingArea.Options = [.activeAlways, .inVisibleRect, .mouseEnteredAndExited, .mouseMoved] as NSTrackingArea.Options
let trackingArea = NSTrackingArea(rect: view.frame, options: options, owner: self, userInfo: nil)
view.addTrackingArea(trackingArea)
}
We are setting up our tracking options, specifically, to only get events in the visible rect, and get mouse entered, exited, and moved events. We are then using NSTrackingArea
to add the tracking area to our view based on it's frame size.
Call the new function at the top of didMove(to:)
:
registerForMouseEvents(on: view)
We will now create a new function where we handle the mouse events and move the paddles accordingly. This involves quite a bit of maths and calculations to make the paddles "snap" on the edges and avoid the paddles from exiting our scene!
Override the mouseMoved(with:)
function, where we'll first set up some variables to make it easier to calculate the new positions of the paddles.
override public func mouseMoved(with event: NSEvent) {
super.mouseMoved(with: event)
/// Using different padding sizes creates a "click" when the user moves the paddle to the screen edge
/// Minimum padding for "click" to activate
let clickPadding: CGFloat = 65
/// Bare minimum padding for horizontal paddles
let horizontalPadding: CGFloat = 25
/// Bare minimum padding for vertical paddles
let verticalPadding: CGFloat = 27
/// Half the length of the paddles
let halfPaddleLength: CGFloat = 275 // 550 divided by 2
/// The size of the screen
let screenSize = CGSize(width: 1920, height: 1080)
/// The location of the mouse
let location = event.location(in: self)
}
I have added comments to each variable to make it clear what they all do, so you don't get confused when calculating the paddle locations.
Now, we'll add the actual calculations below the new variables, starting with the top and bottom paddles:
if location.x < screenSize.width - clickPadding - halfPaddleLength, location.x > halfPaddleLength + clickPadding {
topPaddle.position.x = location.x
bottomPaddle.position.x = location.x
} else if location.x > screenSize.width - clickPadding - halfPaddleLength {
topPaddle.position.x = screenSize.width - halfPaddleLength - horizontalPadding
bottomPaddle.position.x = screenSize.width - halfPaddleLength - horizontalPadding
} else if location.x < halfPaddleLength + clickPadding {
topPaddle.position.x = halfPaddleLength + horizontalPadding
bottomPaddle.position.x = halfPaddleLength + horizontalPadding
}
We are using an if statement to calculate the position based on the variables we defined earlier. If the mouse location is within the scene size (with the padding included), we'll simply use the location for the paddles. Note how we are adding the paddle length to make sure the entire paddle is within the area. Otherwise, if the mouse location is outside the padding, we'll use the "maximum location", or the "minimum location" if it's outside the padding on the left side.
Using this padding system means that when the user moves their mouse to the very edge of the screen, it will snap the paddles to the max/min location, making it feel nice and clicky.
We'll do the same for the left and right paddles, following the same patterns:
if location.y < screenSize.height - clickPadding - halfPaddleLength, location.y > clickPadding + halfPaddleLength {
leftPaddle.position.y = location.y
rightPaddle.position.y = location.y
} else if location.y > screenSize.height - clickPadding - halfPaddleLength {
leftPaddle.position.y = screenSize.height - verticalPadding - halfPaddleLength
rightPaddle.position.y = screenSize.height - verticalPadding - halfPaddleLength
} else if location.y < clickPadding + halfPaddleLength {
leftPaddle.position.y = halfPaddleLength + verticalPadding
rightPaddle.position.y = halfPaddleLength + verticalPadding
}
If you now run your playground, you will notice that moving your mouse now moves the paddles as you expected!
Detecting collisions
Next, we will detect collisions to increase the score when the ball hits the paddles without hitting the edges of the screen.
Add a new variable to Global.swift
where we can track the score:
public var score = 0
We'll then add a new function which detects collisions and acts accordingly.
public func didBegin(_ contact: SKPhysicsContact) {
let firstContactedBody = contact.bodyA.node?.name
let secondContactedBody = contact.bodyB.node?.name
// If the ball is not one of the bodies that contacted, skip everything else
guard secondContactedBody == "ball" else { return }
// If the ball's physics body doesn't exist, there's nothing we can do except exit
guard let ballVelocity = ball.physicsBody?.velocity else { fatalError("The ball must have a physics body!") }
}
First, we will simply set our first and second body's names to a variable so we can use them more easily. We'll then make sure that one of the bodies is a ball and that is has a physics body, otherwise we can't continue to use the collision.
Then, if the first body is one of the paddles, we'll increase the score and velocity of the ball accordingly:
if firstContactedBody == "topPaddle" || firstContactedBody == "bottomPaddle" || firstContactedBody == "leftPaddle" || firstContactedBody == "rightPaddle" {
let divisor: CGFloat = 40
score += Int(abs(ballVelocity.dy / divisor))
if -100...0 ~= ballVelocity.dx || -100...0 ~= ballVelocity.dy {
ball.physicsBody?.velocity.dx += -300
ball.physicsBody?.velocity.dy += -300
} else if 0...100 ~= ballVelocity.dx || 0...100 ~= ballVelocity.dy {
ball.physicsBody?.velocity.dx += 300
ball.physicsBody?.velocity.dy += 300
} else {
let increase = CGFloat.random(in: 5...10)
// Increase the velocity based on whether it's negative or not
ball.physicsBody?.velocity.dx += (ballVelocity.dx < CGFloat(0)) ? -increase : increase
ball.physicsBody?.velocity.dy += (ballVelocity.dy < CGFloat(0)) ? -increase : increase
}
}
The new score is based on the current velocity of the ball (so you get more points as the score increases). We'll also make sure the ball is going fast enough and fix it's velocity or increase it if it is already fast enough. As you can see, we're using ranges, such as 0...100
to make the velocity normal if the ball is going very slowly. It is required to keep the negative velocity to avoid the ball changing direction.
Your playground will now run successfully, and the ball will start speeding up as you play! However, although the score is already updating, you can't see it because we aren't displaying it anywhere.
Labels
We will now add a score label to easily see our score while playing. Add a score label to our list in Global.swift
:
public var scoreLabel = NSTextField()
We'll also add a didSet
item to our original score variable, which will automatically update the score label whenever the score changes. This lets us avoid redundant code.
public var score = 0 {
didSet {
scoreLabel.stringValue = String(score)
}
}
We now need to set up the new label and add it to our game view, so we'll add a new function to Scene.swift
which creates our label. We're doing this in a separate function so we can add more labels later on.
func setupLabels() {
guard let frame = view.frame else { return }
scoreLabel = createLabel(title: String(score), alignment: .left, size: 20.0, color: .white, hidden: false, x: 9, y: Double(frame.maxY - 24 - 9), width: 100, height: 24)
}
As you can see, we're using our convenient method from Extensions.swift
to create the label and set it's properties, such as position, text alignment, text size, and color.
⚠️ Note: As per the note at the top of this post, some of the code shown here was written by me a long time ago, and isn't the best. In particular, you should really avoid hard coding the position of elements on the screen. It works for this playground because of the fixed size of the playground.
Now, we just need to call our new function and add the new label we created as a subview. Add this code to didMove(to:)
:
setupLabels()
addSubviews(scoreLabel)
The playground now runs successfully, and the game now works and displays your score, updating it whenever the ball collides with one of the paddles!
Lives
We're almost done building a completely functional copy of DoublePong! The last thing we need to add is lives, so that the game is over if the ball touches the edges of the screen too many times.
Add a new livesLabel
and it's corresponding lives
variable to Global.swift
:
public var livesLabel = NSTextField()
public var lives = 5 {
didSet {
livesLabel.stringValue = String(repeating: "❤️", count: lives)
}
}
Whenever the lives are updated, we use a special method built in to String
to show a heart emoji for each of the remaining lives.
As before, add a new line to setupLabels()
where we'll set up our new lives label:
livesLabel = createLabel(title: String(repeating: "❤️", count: lives), alignment: .right, size: 15.0, color: .white, hidden: false, x: Double(frame.maxX - 113 - 9), y: Double(frame.maxY - 19 - 9), width: 113, height: 19)
The setup is the same as we did before for the score label, using the convenient method from Global.swift
.
Next, we'll add a new check to our collision detection to remove lives when the ball collides with the edge:
if firstContactedBody == nil {
if lives > 1 {
lives = lives - 1
} else {
// End game here
}
}
If the user has more than 1 life left, we remove a life. However, if all the lives have been used up, we should end the game. However, this blog post won't cover that.
Conclusion
You now have a working version of DoublePong, my WWDC18 scholarship submission! I chose to remove some features to make this blog post shorter, but I've added them to the example playground, available below. If you'd like, you can try to implement some features yourself such as a game over screen/restart button, then check out my code to see how I chose to implement it (which may be different from your implementation).
Although DoublePong is a relatively good SpriteKit game, I also made this blog post in an attempt to encourage and help out students considering applying for a WWDC 2019 scholarship! I had an incredible experience last year and I strongly recommend anyone considering an application to go for it!
I hope you enjoyed this in depth tutorial into how I built DoublePong! Make sure to subscribe below to receive future blog posts in your inbox!
Have any questions or comments? Applying for a WWDC scholarship? I'd love to answer any questions you have or help you out! Just email [email protected] and I'll try my best. Thanks for reading 🙌