Maze Game with SpriteKit
Do you remember days when maze games like Labyrinth 2 from Illusion Labs were one of the most popular titles on the App Store? In fact, it was a long time ago, when iPhone 3Gs and 4 had their glory time. I still think it was a really nice idea, so why not to create own Maze game, especially since iOS SDK has such a great framework like SpriteKit?
First of all, start Xcode (7.0+ is recommended, I used 7.2 in case of this project), create a new project (iOS > Application > Game), and setup like on the screenshot below. The most important is to choose Swift as a language and SpriteKit as used technology. Also we don’t need unit and UI tests right now, so you can uncheck these two checkboxes.
Let’s start from analysis sample project created by Apple. Run the app, and then you should see a chalkboarded ‘Hello, World!’ label, nodes counter, and current framerate. That’s the best moment to find out what node means. In fact it’s SKSpriteNode
class object that draws a textured image, a colored square, or a textured image blended with a color. Right now there’re two labels – chalkboarded one, and label with counters.
Tap your iPhone screen to add spaceships. Notice that nodes counter increases, and after a while your framerate should be sligthly lower.
It’s time to make some code cleanup. We don’t need spaceships anymore, so go to Assets.xcassets and remove Spaceship. Then open GameScene.swift and leave just what’s necessary:
1 2 3 4 5 6 7 8 9 10 11 | import SpriteKit class GameScene: SKScene { override func didMoveToView(view: SKView) { /* Setup your scene here */ } override func update(currentTime: CFTimeInterval) { /* Called before each frame is rendered */ } } |
If you have no previous experience with SpriteKit, you can consider didMoveToView(_:)
as viewDidLoad()
. Function update(_:)
is executed before each frame is rendered. That means you can’t be sure it happens i.e. every single 1/60 second. Maximum framerate is around 60 fps, but there can be some drops. Good practice is not to use there constant time intervals, because of that.
As we’re going to use a gyroscope to control the ball, we should import CoreMotion
framework. It can be done in a custom GyroManager class, but I added it at the top of GameScene.swift. Add some code to this source file to initialize CMMotionManager
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | import CoreMotion import SpriteKit class GameScene: SKScene { var manager: CMMotionManager? // MARK: - SpriteKit Methods override func didMoveToView(view: SKView) { manager = CMMotionManager() if let manager = manager where manager.deviceMotionAvailable { manager.deviceMotionUpdateInterval = 0.01 manager.startDeviceMotionUpdates() } … } } |
In theory it’s pretty safe to unwrap this using exclamation mark, because manager was initialized just line before unwrapping, but it’s always safer to use if let
structure (in this case with where
instead of nested condition).
When we use CoreMotion
for detecting device’s position, it’s recommended to support only one (i.e. Portrait) device orientation, because we don’t want the game to be rotated during device’s motion. To do that find your target’s settings and uncheck other available options in Device Orientation.
Right now we’ve got a motion manager instance, but there is still a long way to go: placing ball, detecting motions, walls, collisions, etc. Start from the beginning: the ball.
We need some assets: ball, black hole in which you would loose a ball, and place being your target. You can find it on your own or use ones from this project repository on GitHub. Add these and we can start.
Go back to GameScene.swift and add these in didMoveToView(_:)
1 2 3 4 5 6 7 8 9 10 | var ball: SKSpriteNode! override func didMoveToView(view: SKView) { ball = SKSpriteNode(imageNamed: "Ball") ball.position = CGPoint(x: CGRectGetMidX(frame), y: CGRectGetMidY(frame)) ball.physicsBody = SKPhysicsBody(circleOfRadius: CGRectGetHeight(ball.frame) / 2.0) addChild(ball) … } |
First create SKSpriteNode
, then position it to frame’s center, and add physics body to the ball – there are multiple initializers, but ball is round, so circle with radius equal to half of it’s size would be perfect. Soon we will use it to add more physics interaction like inertia.
You can run your app and then find the ball in the view’s center. But this level is pretty boring. Go to GameScene.sks (‘sks’ is an acronym for ‘SpriteKit Scene’), set the camera object resolution to 1080 × 1620, which would let your level be compatible with older devices thanks to proper proportions. Also change scene.ScaleMode
in GameViewController.swift to .AspectFit
.
1 | scene.scaleMode = .AspectFit |
Bring back SpriteKit Scene and add multiple color sprites as walls, and set frames to create an interesting level. It will be a really simple game, so I preferred setting sprite’s color than texture. Remember to left free space in the middle, because the ball will be there. Also add some black holes as obstacles and one finish hole. Your level could looks like this:
Now we should implement a device motion support. Doing that in update(_:)
makes the animation smooth.
1 2 3 4 5 | override func update(currentTime: CFTimeInterval) { if let gravityX = manager?.deviceMotion?.gravity.x, gravityY = manager?.deviceMotion?.gravity.y where ball != nil { ball.physicsBody?.applyImpulse(CGVector(dx: CGFloat(gravityX) * 200.0, dy: CGFloat(gravityY) * 200.0)) } } |
First use if let
to safely unwrap manager
and its deviceMotion
(both optional). To be sure, check if ball
is not nil
. Then using applyImpulse(_:)
we change the forces (with 200.0 multiplier) acting on the ball. There is also function named applyForce(_:)
, but in this case it would result in an unwanted effect.
applyForce(_:)
adds a new force vector, when applyImpulse(_:)
changes current. In fact we could use also SKAction.moveTo(_:duration:)
with duration 0.0
, but we need to interact with physicsBody
for inertia. I left these two implementations commented out in the source code on GitHub, so you can check it out.
It’s time to add collisions. Implementation is pretty straightforward, but I believe there is more than one way to improve this. Think of it as your homework. Collisions and contacts are supported by bit masks in SpriteKit. That’s not the simplest thing, especially if you have no previous experiences with them.
Bit masking is useful when you want to store different data within a single data value. Each flag is a bit position that can be set on or off. They should be successive powers of two starting from zero.
In this game we’ve got four kinds of objects: ball, wall, black hole, and finish hole. We don’t need to do anything special when collision between ball and wall happens, so there is no reason to support it in our bit mask.
1 2 3 4 5 | struct Collision { static let Ball: UInt32 = 0x1 << 0 // bin(001) = dec(1) static let BlackHole: UInt32 = 0x1 << 1 // bin(010) = dec(2) static let FinishHole: UInt32 = 0x1 << 2 // bin(100) = dec(4) } |
Then complete didMoveToView(_:)
function. In the end it should looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | override func didMoveToView(view: SKView) { physicsWorld.contactDelegate = self ball = SKSpriteNode(imageNamed: "Ball") ball.position = CGPoint(x: CGRectGetMidX(frame), y: CGRectGetMidY(frame)) ball.physicsBody = SKPhysicsBody(circleOfRadius: CGRectGetHeight(ball.frame) / 2.0) ball.physicsBody?.mass = 4.5 ball.physicsBody?.allowsRotation = false ball.physicsBody?.dynamic = true // necessary to detect collision ball.physicsBody?.categoryBitMask = Collision.Ball ball.physicsBody?.collisionBitMask = Collision.Ball ball.physicsBody?.contactTestBitMask = Collision.BlackHole | Collision.FinishHole ball.physicsBody?.affectedByGravity = false addChild(ball) manager = CMMotionManager() if let manager = manager where manager.deviceMotionAvailable { manager.deviceMotionUpdateInterval = 0.01 manager.startDeviceMotionUpdates() } } |
Now ball can collide with black holes and finish hole. Add collision support in a different way. Open GameScene.sks and select all black holes, then set them in Attributes Inspector Category and Collision Mask to 2, and Contact Mask to 1. Then select finish hole and accordingly set Category and Collision to 4, and Contact Mask to 1.
Last step is to add SKPhysicsContact
Delegate Method:
1 2 3 4 5 6 7 8 9 | extension GameScene: SKPhysicsContactDelegate { func didBeginContact(contact: SKPhysicsContact) { if contact.bodyA.categoryBitMask == Collision.BlackHole || contact.bodyB.categoryBitMask == Collision.BlackHole { print("You lost the ball") } else if contact.bodyA.categoryBitMask == Collision.FinishHole || contact.bodyB.categoryBitMask == Collision.FinishHole { print("You won") } } } |
This method fires when contact begins. It checks if bodyA
or bodyB
has desired categoryBitMask
. Right now there is no support for congratulations or punishment when ball is lost. Alert Controller with a kind word seems to be easy (and it’s done on GitHub), but how to bring the ball back to the screen’s center, when it’s lost? Just execute this function when one of the bodies’ categoryBitMask
is Collision.BlackHole
:
1 2 3 4 5 | func centerBall() { ball.physicsBody?.velocity = CGVector(dx: 0.0, dy: 0.0) let moveAction = SKAction.moveTo(CGPoint(x: CGRectGetMidX(frame), y: CGRectGetMidY(frame)), duration: 0.0) ball.runAction(moveAction) } |
It stops the ball from moving – ball’s velocity vector is set to (0.0, 0.0) – then create and run action that moves ball to the center. Pretty easy, huh?
Right now that’s all. Of course this game is not completed, but it was a nice start. Ball’s lost animations can be nicer (maybe instead of immediately moving the ball to the center, try to reduce its size first?), a shadow under the ball could also be terrific. Try different things and share with us the effect of your work.
You can find complete source code on Droids on Roids’s GitHub repository.
About the author
Ready to take your business to the next level with a digital product?
We'll be with you every step of the way, from idea to launch and beyond!