GameplayKit Tutorial: Artificial Intelligence

In this tutorial, you’ll learn how to implement artificial intelligence (AI) in a SpriteKit game using GameplayKit and Swift. By Ryan Ackermann.

4.5 (4) · 1 Review

Save for later
Share

One of the best uses for GameplayKit is providing artificial intelligence for your games. Since GameplayKit is game-engine agnostic, it works with your games whether you’re using SpriteKit, SceneKit or even UIKit to power your development journey.

In this tutorial, you will learn how to incorporate an artificial intelligence into an existing game using GameplayKit.

This tutorial assumes you’re familiar with Swift and SpriteKit. If you need a refresher of Swift, check out our beginner series. If you are new to SpriteKit check out this tutorial.

Get ready to defeat pesky zombies — in a classic game of Tic-Tac-Toe.

Getting Started

Download the starter project. This project is already organized into the following folders:

  • Model: Contains the blank files to which you’ll add the GameplayKit methods to get the AI up and running.
  • Game: Contains the SpriteKit code for the game scene lifecycle.
  • Supporting Files: Contains the assets folder and all the standard files for an iOS app, such as the app delegate.

Build and run your game; it’s fully playable in its current state.

Initial Game

Time to bring some “life” into this game!

What is Artificial Intelligence?

Asking the real questions..

Asking the real questions..

Quite simply, an artificial intelligence (AI) is an algorithm that rationally analyzes data to make decisions. In video games, AI is used to bring realistic behavior to non-player characters. The best AIs will blur the line between non-player characters and human players.

The goal of any AI is to give the appearance of intelligence. However, if you gave the AI knowledge of all the rules of the game, it could easily win almost every match. Therefore, to avoid creating an undefeatable monster, it’s a good idea to introduce intentional errors.

A good example of restricting AI to make the game fair is the logic in first-person shooters (FPS). You wouldn’t want to play a game where the non-player characters have perfect aim and always know where you are.

The Strategist

The star of the show is the GKStrategist protocol. This protocol defines the general logic to decide which moves are the best ones to play.

Since this is a general purpose protocol, you can use it as a foundation to create your own custom strategist that will decide on moves according to your game’s needs. However, GameplayKit provides two specific strategist classes based on the GKStrategist protocol you can use in your games:

  • The GKMinmaxStrategist class ranks every possible move to find the best one. The best move is the one that results in the AI player winning the game, or the one that minimizes the chances of other players winning. However, since this strategist runs through every possible move to assign it a rank, the performance toll increases as the game increases in complexity.
  • The GKMonteCarloStrategist class chooses its moves by making a probabilistic guess of a move that could likely result in the AI player winning the game. This is a more efficient approach and is likely to choose a good move, but not always the best one. Even in complex games, this strategist can still perform well since it doesn’t need to rank every move from best to worst.

For this game, you’ll be using the GKMinmaxStrategist. Given that Tic-Tac-Toe is a straightforward game with a limited set of outcomes, calculating the rank of any particular move is a trivial task.

For a strategist to work its magic, it has to know the structure of your game. That’s where the game model comes into play.

The Game Model

What a pun-ch line..

What a pun-ch line..

The game model is a representation of your gameplay that GameplayKit can understand. GameplayKit uses your game model to do the following:

  • Analyze the game state by asking your model for the available moves for a given player.
  • Test future moves by using copies of your game state to test the results of hypothetical moves.
  • Rate each move to determine the optimal one. Since GameplayKit can simulate future moves, it will favor a move that provides better future results for the AI player.

Your model keeps track of all the players in the game. To GameplayKit, a player is simply a class that conforms to the GKGameModelPlayer protocol. This protocol is very easy to implement, as it only requires the player class to have a unique playerId property so your model can distinguish between players.

A move is simply a class that implements the GKGameModelUpdate protocol. According to this protocol, this class must contain a value property so it can assign the move a score based on its calculation. You’re free to design the rest of your move class according to your game’s needs.

Setting up the Model

Since this is TicTacToe, your model will consist of a 3×3 board and 2 players: the brain and the zombie. Your move class will keep track of the position of the player. You will make the AI only smart enough to determine if the next move will win the game.

Start off by opening Player.swift from the Model folder and add the GKGameModelPlayer protocol to the class declaration:

class Player: NSObject, GKGameModelPlayer {

You’ll see that Xcode warns you that Player doesn’t conform to the GKGameModelPlayer protocol.

To fix this, add the playerId property to the variable declarations below the value and name variables:

var playerId: Int

Scroll down to the init(_:) method and assign playerId an initial value:

playerId = value.rawValue

Your player class is now ready to be used by GameplayKit.

Open Move.swift and replace the contents with the following:

class Move: NSObject, GKGameModelUpdate {
  
  enum Score: Int {
    case none
    case win
  }
  
  var value: Int = 0
  var coordinate: CGPoint
  
  init(_ coordinate: CGPoint) {
    self.coordinate = coordinate
  }
  
}

Because this is the move class, you first declare conformance to the GKGameModelUpdate protocol. You will use the Score enum to define the score of a move based on whether it results in a win. Then, you provide a value property to help GameplayKit rate a move. You should never modify this property yourself during gameplay. Finally, the coordinate property is a CGPoint that contains the location on the board.

Now you can put these pieces together in your model class. Open Board.Swift and add the following class extension to the bottom of the file:

extension Board: GKGameModel {
  
  // MARK: - NSCopying
  
  func copy(with zone: NSZone? = nil) -> Any {
    let copy = Board()
    copy.setGameModel(self)
    return copy
  }
  
  // MARK: - GKGameModel
  
  var players: [GKGameModelPlayer]? {
    return Player.allPlayers
  }
  
  var activePlayer: GKGameModelPlayer? {
    return currentPlayer
  }
  
  func setGameModel(_ gameModel: GKGameModel) {
    if let board = gameModel as? Board {
      values = board.values
    }
  }
  
}

GKGameModel requires conformance to NSCopying because the strategist evaluates moves against copies of the game. The players property stores a list of all the players in the match and the activePlayer property keeps track of the player in turn. setGameModel(_:) lets GameplayKit update your game model with the new state after it makes a decision.

Next, add the following below setGameModel(_:):

func isWin(for player: GKGameModelPlayer) -> Bool {
  guard let player = player as? Player else {
    return false
  }
  
  if let winner = winningPlayer {
    return player == winner
  } else {
    return false
  }
}

This method, which is part of the starter project, determines whether a player wins the game via winningPlayer. It loops through the board to find whether either player has won and, if so, returns the winning player. You then use this result to compare it to the player that was passed onto this method.

Next, add this code right below isWin(for:):

func gameModelUpdates(for player: GKGameModelPlayer) -> [GKGameModelUpdate]? {
  // 1
  guard let player = player as? Player else {
    return nil
  }
  
  if isWin(for: player) {
    return nil
  }
  
  var moves = [Move]()
  
  // 2
  for x in 0..<values.count {
    for y in 0..<values[x].count {
      let position = CGPoint(x: x, y: y)
      if canMove(at: position) {
        moves.append(Move(position))
      }
    }
  }
  
  return moves
}

func apply(_ gameModelUpdate: GKGameModelUpdate) {
  guard  let move = gameModelUpdate as? Move else {
    return
  }
  
  // 3
  self[Int(move.coordinate.x), Int(move.coordinate.y)] = currentPlayer.value
  currentPlayer = currentPlayer.opponent
}

Here’s what’s going on in the code above:

  1. gameModelUpdates(for:) tells GameplayKit about all the possible moves in the current state of the game.
  2. You loop over all of the board’s positions and add a position to the possible moves array if it is not already occupied.
  3. GameplayKit calls apply(_:) after each move selected by the strategist so you have the chance to update the game state. After a player makes a move, it is now the opponent’s turn.

At the bottom of the extension add this new method:

func score(for player: GKGameModelPlayer) -> Int {
  guard let player = player as? Player else {
    return Move.Score.none.rawValue
  }
  
  if isWin(for: player) {
    return Move.Score.win.rawValue
  } else {
    return Move.Score.none.rawValue
  }
}

The AI uses score(for:) to calculate it’s best move. When GameplayKit creates its move tree, it will select the shortest path to a winning outcome.

That’s it — the model is done. Now your brain is ready to come alive!

Zombies love some healthy brains..

Zombies love some healthy brains..

Setting up the Strategist

This is where everything comes together! You’ll use the model you defined to drive the strategist.

Open Strategist.swift and replace the contents of the struct with the following:

struct Strategist {
  
  // 1
  private let strategist: GKMinmaxStrategist = {
    let strategist = GKMinmaxStrategist()
    
    strategist.maxLookAheadDepth = 5
    strategist.randomSource = GKARC4RandomSource()
    
    return strategist
  }()
  
  // 2
  var board: Board {
    didSet {
      strategist.gameModel = board
    }
  }
  
  // 3
  var bestCoordinate: CGPoint? {
    if let move = strategist.bestMove(for: board.currentPlayer) as? Move {
      return move.coordinate
    }
    
    return nil
  }
  
}

Inside of this struct:

  1. You instantiate a GKMinmaxStrategist with a maxLookAheadDepth of 5. The look ahead depth is the constraint you give a strategist to limit the number of future moves it can simulate. You also provide a random source to be the deciding factor when the strategist selects multiple moves as the best move.
  2. You keep a reference to the game model you defined and supply that to the strategist.
  3. The best coordinate is a CGPoint representing the strategist’s best move. The bestMove(for:) method will return nil if the player is in an invalid state or nonexistent.

Now only one thing remains: adding the strategist to the game.

Switch to the GameScene.swift file located inside the Game folder and add the following to the properties section near the top:

var strategist: Strategist!

With this property you keep a reference to the strategist used by the game.

Next, add following inside didMove(to:) towards the bottom, above resetGame():

strategist = Strategist(board: board)

Here you initialize the strategist with the model driving the game.

Next, add the following at the bottom of resetGame():

strategist.board = board

This ensures that every time you reset the game you also provide the strategist with a fresh game model.

Scroll down to the Touches section and add this new method right above:

fileprivate func processAIMove() {
  // 1
  DispatchQueue.global().async { [unowned self] in
    // 2
    let strategistTime = CFAbsoluteTimeGetCurrent()
    guard let bestCoordinate = self.strategist.bestCoordinate else {
      return
    }
    // 3
    let delta = CFAbsoluteTimeGetCurrent() - strategistTime
    
    let aiTimeCeiling = 0.75
    // 4
    let delay = max(delta, aiTimeCeiling)
    
    // 5
    DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
      self.updateBoard(with: Int(bestCoordinate.x), y: Int(bestCoordinate.y))
    }
  }
}

In this function you do the following:

  1. Create a dispatch queue to process the AI’s move since it could take a while.
  2. Record the starting time before the strategist decides on the best move.
  3. Calculate the time it took for the strategist to make its decision.
  4. Create a delay based on a constant so the AI will appear to take a bit of time to make a decision. On modern hardware, the AI might return a decision almost immediately so the delay gives the AI a human-like pause.
  5. Finally, you update the board on the main queue to reflect the new move.

Now you only have to call this method when it’s the AI’s turn. To do this, add the following to the bottom of updateGame():

if board.currentPlayer.value == .brain {
  processAIMove()
}

Build and run. Now the game has a mind of its own!

The Brain is Alive

If you play the game fast enough, you’ll soon notice that you can make the move for the AI. That’s not good.

To prevent this, add the following at the beginning of handleTouchEnd(_:with:):

guard board.currentPlayer.value == .zombie else {
  return
}

Build and run. Now the game will work as expected. Great job!

Game Complete

Where To Go From Here?

You can find the final project for this tutorial here.

Now you’re able to take this new knowledge you learned from this GameplayKit tutorial and add AI to your own games!

To further your knowledge of GameplayKit, check out Apple’s developer videos covering GameplayKit and its advances over time.

As an extra challenge, I encourage you to try and build more intelligence into the AI. Try adding detection of the opponent being one move away from winning and give a blocking move a higher rank. You’d only need to extend the Board.swift class and the enum in Move.swift.

We’d love you see what you can come up with. Join the discussion below to comment, ask questions or share your ideas!