Card Game Mechanics in Sprite Kit with Swift

Brian Broom

Update 10/8/2016: This tutorial has been updated for Xcode 8.0 and Swift 3.

Learn how to implement basic card game mechanics and animation.

Learn how to implement basic card game mechanics and animation.

For more than 20 years, people have played Collectible Card Games (CCGs). The Wikipedia entry gives a fairly thorough recount of how these games evolved, which seems were inspired by role playing games like Dungeons and Dragons; Magic the Gathering is one example of a modern CCG.

At their core, CCGs are a set of custom cards representing characters, locations, abilities, events, etc. To play the game, players must first build their own decks, then use their individual decks to play. Most players create decks that accentuate certain factions, creatures or abilities.

In this tutorial, you’ll use Sprite Kit to manipulate images that serve as cards in a CCG app. You’ll move cards on the screen, animate them to show which cards are active, flip them over and enlarge them so you can read the text — or just admire the artwork.

If you’re new to SpriteKit, you may want to read through a beginner tutorial or indulge yourself with the 2D iOS & tvOS Games by Tutorials book. If you’re new to Swift, make sure you check out the Swift Quick Start series.

Getting Started

Since this is a card game, the best place to start is with the actual cards. Download the starter project which provides a SpriteKit project preset for an iPad in landscape mode, as well as all the images, fonts and sound files you’ll need to create a functional sample game.

Take a minute to look around the project to acquaint yourself with its file structure and content. You should see the following project folders:

  1. System: Contains the basic files to set up a SpriteKit project. This includes AppDelegate.swift, GameViewController.swift, and the storyboard files.
  2. Game: Contains an empty main scene GameScene.swift, and an empty class Card.swift which will manage the game content.
  3. Assets: Contains all of the images, fonts, and sound files you’ll use in the tutorial.

This game just wouldn’t be as cool without the art, so I’d like to give special thanks to Vicki from gameartguppy.com for the beautiful card artwork.

The Card Class

Since you can’t play a card game without cards, start by making a class to represent them. Card.swift is currently a blank Swift file, so find it and add:

import SpriteKit

enum CardType :Int {
  case wolf,
  bear,
  dragon
}

class Card : SKSpriteNode {
  let cardType :CardType
  let frontTexture :SKTexture
  let backTexture :SKTexture
  
  required init?(coder aDecoder: NSCoder) {
    fatalError("NSCoding not supported")
  }
  
  init(cardType: CardType) {
    self.cardType = cardType
    backTexture = SKTexture(imageNamed: "card_back")
    
    switch cardType {
    case .wolf:
      frontTexture = SKTexture(imageNamed: "card_creature_wolf")
    case .bear:
      frontTexture = SKTexture(imageNamed: "card_creature_bear")
    case .dragon:
      frontTexture = SKTexture(imageNamed: "card_creature_dragon")
    }
    
    super.init(texture: frontTexture, color: .clearColor(), size: frontTexture.size())
  }
}

You’re declaring Card as a subclass of SKSpriteNode. Since a card can be one of several different cards with their own statistics and image, it makes sense to define an enum. This way you can define a card as a wolf or a bear, and the appropriate values can be set. For now, the only thing different is the image for the card face.

To create a simple sprite with an image, you would use SKSpriteNode(imageNamed:). In order to keep this behavior, you use the inherited initializer which must call the designated initializer of the super class, init(texture:color:size:). The color value used does not matter since you use an image, but since it’s not defined as optional, you have to specify a value. You do not support NSCoding in this game, which is used by the visual designer for sks files.

Properties are defined to store the image for the card front and back. Since these are not optional, they must be initialized by the end of the init(texture:color:size:) method. The switch statement lets you set the image based on the CardType value.

To put some sprites on the screen, open GameScene.swift, and add the following code to the end of didMoveToView(_:):

let wolf = Card(cardType: .wolf)
wolf.position = CGPoint(x: 100, y: 200)
addChild(wolf)

let bear = Card(cardType: .bear)
bear.position = CGPoint(x: 300, y: 200)
addChild(bear)

Build and run the project, and take a moment to admire the wolf and bear.

Card Images on iPad Screen

A good start…

Rule #1 for creating card games: start with creative, imaginative art. Looks like your app is shaping up nicely!

Note: Depending on screen size, you may want to zoom the simulator window, using Window\Scale\50% to fit on the screen. You can also use the iPad 2 simulator, since its non-retina screen is smaller.

Looking at a couple of cards is fun and all, but the UI will be much cooler if you can actually move the cards. You’ll do that next.

Moving The Cards Around The Board

No matter the quality of the art, cards sitting on a screen won’t earn your app any rave reviews, because you need be able to drag them around like you can do with real paper cards. The simplest way to do this is to handle touches in the scene itself.

Still in GameScene.swift, add this new function to the class:

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
  for touch in touches {
    let location = touch.location(in: self)           // 1
    if let card = atPoint(location) as? Card {        // 2
      card.position = location
    }
  }
}

There are two parts here.

  1. The location(in:) method converts the touch location into the scene coordinates.
  2. Use atPoint(_:) to determine which node was touched. This method will always return a value, even if you didn’t touch any of the cards. In that case, you get some other SKNode class. The as? Card keyword then either returns a Card object, or nil if that node isn’t a Card. The if let then guarantees that card contains an actual Card, and not anything else. Without this step, you might accidentally move the background node, which is amusing, but not what you want.

Build and run the project, and drag those two cards around the display.

Cards move, but sometimes slide under other cards

The cards now move, but sometimes slide behind other cards. Read on to fix the problem.

As you play around with this, you’ll notice several issues:

  1. First, since the sprites are at the same zPosition, they are arranged in the same order they are added to the scene. This means the bear card is “above” the wolf card. If you’re dragging the wolf, it appears to slide beneath the bear.
  2. Second, atPoint(_:) returns the topmost sprite at that point. So when you drag the wolf under the bear, atPoint(_:) returns the bear sprite and start changes its position, so you might find yourself moving the bear even though you originally moved the wolf.

While this effect is almost magical, it’s not the kind of magic you want in the final app.

To fix this, you’ll modify the card’s zPosition while dragging. Instead of hardcoding values, add the following to GameScene.swift, before the class definition.

enum CardLevel :CGFloat {
  case board = 10
  case moving = 100
  case enlarged = 200
}

Make sure you pick a zPosition value that is greater than other cards will be, as well as less than any interface elements that should be displayed on top of the card layer.

Your first inclination might be to change the zPosition of the sprite in touchesMoved(_:with:), but this isn’t a good approach if you want to change it back later.

Using touchesBegan(_:with:) and touchesEnded(_:with:) is a better strategy. Still in GameScene.swift, add the following two methods:

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
  for touch in touches {
    let location = touch.location(in: self)
    if let card = atPoint(location) as? Card {
      card.zPosition = CardLevel.moving.rawValue
    }
  }
}

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
  for touch in touches {
    let location = touch.location(in: self)
    if let card = atPoint(location) as? Card {
      card.zPosition = CardLevel.board.rawValue
      card.removeFromParent()
      addChild(card)
    }
  }
}

The line to remove and add the card to the scene may look strange, but it prevents the wolf from jumping underneath the card for the bear if they overlap when the touch ends. The SKNodes are displayed in reverse order that they were added to the scene, with the later nodes on top. You can’t rearrange the order of nodes in SKNode‘s internal storage, but removing and adding the node again has the desired effect.

Build and run the project again, and you’ll see the cards sliding over each other as you would expect.

Cards now correctly move over each other

Cards now correctly move over each other, but looks a little plain. You’ll fix that next.

Pros & Cons Of Scene Touch Handling

Before going any further, let’s stop for a moment and muse upon some of the advantages and disadvantages of handling the touches at the scene level.

Touch handling at the scene level is a good place to start working with a project because it’s the simplest, easiest approach. In fact, if your sprites have transparent regions that should be ignored, such as hex grids, this may be the only reasonable solution.

However, it starts to fall apart when you have composite sprites. For example, these could contain multiple images, labels or even a health bar. It can also be unwieldy and complicated if you have different rules for different sprites.

One gotcha that comes into play when you use atPoint(_:) is that it always returns a node.

What if you drag outside of one of the card sprites? Because SKScene is a subclass of SKNode, if the touch location intersects no other node, the scene itself returns as a SKNode.

This tutorial will continue to do touch handling in the scene, but in your games, you might find it better to have the SKNode subclasses handle their own touches.

Tracking Damage

In many collectible card games, monsters like these have hit points associated with them, and they can fight each other.

To implement this, you’ll need a label on top of the cards so the user can track damage inflicted on each creature. In Card.swift, add the following to the property section at the top of the class:

var damage = 0
let damageLabel :SKLabelNode

Then, add the following to the init(texture:color:size:) method, before the call to super.init(texture:color:size:)

damageLabel = SKLabelNode(fontNamed: "OpenSans-Bold")
damageLabel.name = "damageLabel"
damageLabel.fontSize = 12
damageLabel.fontColor = SKColor(red: 0.47, green: 0.0, blue: 0.0, alpha: 1.0)
damageLabel.text = "0"
damageLabel.position = CGPoint(x: 25, y: 40)

Finally, add this line after super.init(texture:color:size:) in the same method

addChild(damageLabel)

Since the damageLabel property is not optional, it has to be initialized before calling super.init(texture:color:size:). You can’t call instance methods until after super.init(texture:color:size:), so the addChild(_:) call has to be at the end.

This is the general pattern for initializing Swift objects. First, initialize all non-optional properties; then, call super.init(texture:color:size:); finally, call any instance methods needed to complete initialization.

Note: For more information on installing custom fonts, refer to Chapter 6 in 2D iOS & tvOS Games by Tutorials, “Labels”.

Are you wondering how the position works in this example?

Since the label is a child of the card sprite, the position is relative to the sprite’s anchor point and that is the center, by default.

Build and run the project. You will now see a red ‘0’ within each card.

Card with a label for damage taken.

Cards now have a label that shows how much damage they’ve taken.

Card Animations

Now that the card is moving properly over other cards, it’s time to add some satisfying depth — in this case, a visual indication that the card has been lifted up.

Still in GameScene.swift, add the following to the end of the code inside the if let portion of touchesBegan(_:with:)

card.removeAction(forKey: "drop")
card.run(SKAction.scale(to: 1.3, duration: 0.25), withKey: "pickup")

and similarly in touchesEnded(_:with:)

card.removeAction(forKey: "pickup")
card.run(SKAction.scale(to: 1.0, duration: 0.25), withKey: "drop")

Here you’re using the scale(to:duration:) method of SKAction to grow the width and height of the card to 1.2x its original size when clicked and back down to 1.0 when released. The removeAction(forKey:) call is to stop currently running pickup or drop animations if you tap and release quickly.

Build and run the project to see how this looks.

Moving cards with pickup and drop down animation.

This simple animation gives the appearance of picking up a card and putting it back down. Sometimes the simplest animations are the most effective.

Tinker with the scale and duration values to find what the levels that look best to you. If you set the lift and drop durations as different values you can make it appear as though that card lifts slowly, then drops quickly when released.

Making The Card Wiggle

Dragging cards around now works pretty well, but you should add a bit of flair. Making the cards appear to flutter around their y-axis certainly qualifies as flair.

Since SpriteKit is a pure 2D framework, there doesn’t seem to be any way to do a partial rotation effect on a sprite. What you can do, however, is change the xScale property to give the illusion of rotation.

Again, you’ll add code to the touchesBegan(_:with:) and touchesEnded(_:with:) pair of functions. In touchesBegan(_:with:) add the following code to the beginning of the if let card section:

let wiggleIn = SKAction.scaleX(to: 1.0, duration: 0.2)
let wiggleOut = SKAction.scaleX(to: 1.2, duration: 0.2)
let wiggle = SKAction.sequence([wiggleIn, wiggleOut])

card.run(SKAction.repeatForever(wiggle), withKey: "wiggle")

And similarly, in touchesEnded(_:with:) add the following before removing the pickup action:

card.removeAction(forKey: "wiggle")

This code makes the card appear to rotate back and forth — just a tad — as it moves around. This effect makes use of the run(_:) method to add a string name to the action so that you can cancel it later.

There is a small caveat to this approach: when you remove the animation, it leaves the sprite wherever it is in the animation cycle.

You already have an action to return the card to its initial scale value of 1.0. Since scale sets both the x and y scale, that part is taken care of, but if you use another property, remember to return the initial value in the touchesEnded(_:with:) function.

Build and run the project, so you can see the cards now flutter when you drag them around.

Card with scaling animation to fake 3d rotation.

A simple animation to show that this card is currently active.

Challenge: In the bonus example game at the end of the tutorial, you’ll learn about using zRotation to make the cards wobble back and forth.

Try replacing the scaleXTo(_:duration:) actions with rotateByAngle(_:duration) to replace the “wiggle” animation with a “rocking” animation. Remember to make it a cycle, which means it needs to return to its starting point before repeating.

Card rotates slightly back and forth.

Try to reproduce this effect for the wiggle animation.

Solution Inside SelectShow

Flipping The Card

Finally, add some card-like actions to make the game more realistic. Since the basic premise is that two players will share an iPad, the cards need to be able to turn face down so the other player cannot see them.

An easy way to do this is to make the card flip over when you double tap it. However, you need a property to keep track of the card state to make this possible.

Open Card.swift and add the following property below the other properties:

var faceUp = true

Next, add a function to swap the textures that will make a card appears flipped:

func flip() {
  if faceUp {
    self.texture = backTexture
    damageLabel.isHidden = true
  } else {
    self.texture = frontTexture
    damageLabel.isHidden = false
  }
  faceUp = !faceUp
}

Finally, in GameScene.swift, add the following to the beginning of touchesBegan(_:with:), just inside the if let card section.

if touch.tapCount > 1 {
  card.flip()
}

Now you understand why you saved the front and back card images as textures earlier — it makes flipping the cards delightfully easy. You also hide damageLabel so the number is not shown when the card is face down.

Build and run the project and flip those cards by double-tapping.

Card flip

Simple card flip by swapping out the texture. The little bounce is the pick-up animation triggered by the first touch.

The effect is ok, but you can do better. One trick is to use the scaleX(to:duration:) animation to make it look as though it actually flips.

In Card.swift, replace flip() with:

func flip() {
  let firstHalfFlip = SKAction.scaleX(to: 0.0, duration: 0.4)
  let secondHalfFlip = SKAction.scaleX(to: 1.0, duration: 0.4)
  
  setScale(1.0)
  
  if faceUp {
    run(firstHalfFlip) {
      self.texture = self.backTexture
      self.damageLabel.isHidden = true
      
      self.run(secondHalfFlip)
    }
  } else {
    run(firstHalfFlip) {
      self.texture = self.frontTexture
      self.damageLabel.isHidden = false
      
      self.run(secondHalfFlip)
    }
  }
  faceUp = !faceUp
}

The scaleX(to:duration) action shrinks only the horizontal direction and gives it a pretty cool 2D flip animation. The animation splits into two halves so that you can swap the texture halfway. The setScale(_:) function makes sure the other scale animations don’t get in the way.

Build and run the project to see the new “flip” effect in action.

Card flip with animation

Now you have a nice looking flip animation.

Things are looking great, but you can’t fully appreciate the bear’s goofy grin when the cards are so small. If only you could enlarge a selected card to see its details…

Enlarging The Card

The last effect you’ll work with in this tutorial is modifying the double tap action so that it enlarges the card. Add these two properties to the beginning of Card.swift with the other properties:

var enlarged = false
var savedPosition = CGPoint.zero

Add the following method to perform the enlarge action:

func enlarge() {
  if enlarged {
    enlarged = false
    zPosition = CardLevel.board.rawValue
    position = savedPosition
    removeAllActions()
    setScale(1.0)
    zRotation = 0
  } else {
    enlarged = true
    savedPosition = position
    zPosition = CardLevel.enlarged.rawValue
    
    if let parent = parent {
      position = CGPoint(x: parent.frame.midX, y: parent.frame.midY)
    }
    
    removeAllActions()
    setScale(5.0)
    zRotation = 0
  }
}

Remember to update touchesBegan(_:with:), in GameScene.swift, to call the new function, instead of flip()

if touch.tapCount > 1 {
  card.enlarge()
  return
}

if card.enlarged { return }

Finally, make a small update to touchesMoved(_:with:) and touchesEnded(_:with:) by adding the following line to each, just inside the if let card section:

if card.enlarged { return }

You need to add the extra property savedPosition so the card can be moved back to its original position. This is the point when touch-handling logic becomes a bit tricky, as mentioned earlier.

The tapCount check at the beginning of the function prevents glitches when the card is enlarged and then tapped again. Without the early return, the large image would shrink and start the wiggle animation.

It also doesn’t make sense to move the enlarged image, and there is nothing to do when the touch ends, so both functions return early when the card is enlarged.

Build and run the app to see the card grow and grow to fill the screen.

Basic card enlarging.

Basic card enlarging. Would look much better with some animation, and the enlarged image is fuzzy.

But why is it all pixelated? Vicki’s artwork is much too nice to place under such duress. You’re enlarging this way because you’re not using the large versions of the card images.

Using Large Images

Because loading the large images for all the cards at the beginning can waste memory, it’s best to make it so they don’t load until the user needs them.

Add this property to Card.swift at the end of the property section

let largeTextureFilename :String
var largeTexture :SKTexture?

Next, add the initializer for largeTextureFileName by making your switch statement in init(texture:color:size) look like

switch cardType {
case .wolf:
  frontTexture = SKTexture(imageNamed: "card_creature_wolf")
  largeTextureFilename = "card_creature_wolf_large"
case .bear:
  frontTexture = SKTexture(imageNamed: "card_creature_bear")
  largeTextureFilename = "card_creature_bear_large"
case .dragon:
  frontTexture = SKTexture(imageNamed: "card_creature_dragon")
  largeTextureFilename = "card_creature_dragon_large"
}

The final version of the enlarge() function is as follows:

func enlarge() {
  if enlarged {
    let slide = SKAction.moveTo(savedPosition, duration:0.3)
    let scaleDown = SKAction.scaleTo(1.0, duration:0.3)
    runAction(SKAction.group([slide, scaleDown])) {
      self.enlarged = false
      self.zPosition = CardLevel.board.rawValue
    }
  } else {
    enlarged = true
    savedPosition = position
    
    if largeTexture != nil {
      texture = largeTexture
    } else {
      largeTexture = SKTexture(imageNamed: largeTextureFilename)
      texture = largeTexture
    }
    
    zPosition = CardLevel.enlarged.rawValue
    
    if let parent = parent {
      removeAllActions()
      zRotation = 0
      let newPosition = CGPoint(x: parent.frame.midX, y: parent.frame.midY)
      let slide = SKAction.moveTo(newPosition, duration:0.3)
      let scaleUp = SKAction.scaleTo(5.0, duration:0.3)
      runAction(SKAction.group([slide, scaleUp]))
    }
  }
}

The animations are fairly straightforward at this point.

The card’s position saves before running an animation, so it returns to its original position. To prevent the pickup and drop animations from interfering with the animation as it scales up, you add the removeAllActions() function.

When the scale down animations run, the enlarged and zPosition properties don’t set until the animation completes. If these values change earlier, an enlarged card sitting behind another card will appear to slide underneath as it returns to its previous position.

Since largeTexture is defined as an optional, it can have a value of nil, or “no value”. The if statement tests to see if it has a value, and loads the texture if it doesn’t.

Note: Optionals are a core part of learning Swift, especially since it works differently than nil values in Objective-C.

Build and run the app once again. You should now see a nice, smooth animation from the card’s initial position to the final enlarged position. You’ll also see the cards in full, clean, unpixelated splendor.

Card enlargement with animation.

Animating the card enlargement, and swapping to the large image make this look much nicer.

Final Challenge: Sound effects are an important part of any game, and there are some sound files included in the starter project. See if you can use SKAction.playSoundFileNamed(_:waitForCompletion:) to add a sound effect to the card flip, and the enlarge action.

Where To Go from Here?

The final project for this tutorial can be found here.

At this point, you understand the basic — and some not so basic — card mechanics that you can put to use in your own card game.

This sample project has many subtle animations that you can tweak, so make sure you play around with the different values to find what you like and what works for you.

Use the forum below to comment, ask questions or share your ideas for animating cards with Swift. Thanks for taking the time to work through this tutorial.

Brian Broom

Brian has been tinkering with computers since writing basic programs on IBM PC (with Two! floppy drives). He has done web, database, C++, ruby, and now iOS development. Brian has spent the last few years as a computer science teacher and trainer.

You can reach him by email or on Twitter.

Other Items of Interest

Big Book SaleAll raywenderlich.com iOS 11 books on sale for a limited time!

raywenderlich.com Weekly

Sign up to receive the latest tutorials from raywenderlich.com each week, and receive a free epic-length tutorial as a bonus!

Advertise with Us!

PragmaConf 2016 Come check out Alt U

Our Books

Our Team

Video Team

... 19 total!

iOS Team

... 71 total!

Android Team

... 15 total!

Unity Team

... 11 total!

Articles Team

... 15 total!

Resident Authors Team

... 17 total!

Podcast Team

... 7 total!

Recruitment Team

... 9 total!