Simplifying iOS Game Logic With Apple’s GameplayKit’s Rule Systems

About The Author

Lou Franco is an engineer at Atlassian working on Trello iOS. He has been developing for mobile devices since 2000 and co-authored a book for beginner … More about Lou ↬

Email Newsletter

Weekly tips on front-end & UX.
Trusted by 200,000+ folks.

Apple’s GameplayKit has several algorithms and data structures that make it easier to follow game development best practices. When you develop a game, you need to sprinkle conditionals everywhere. If Pac-Man eats a power pill, then ghosts should run away. GKRuleSystem, lets you build up complex conditional logic from smaller pieces. By structuring your code around it, you’ll create rules that are easier to change or reuse for new levels. In this article, we’re going to take typical game logic code and learn how to represent it as a rule system.

When you develop a game, you need to sprinkle conditionals everywhere. If Pac-Man eats a power pill, then ghosts should run away. If the player has low health, then enemies attack more aggressively. If the space invader hits the left edge, then it should start moving right. Usually, these bits of code are strewn around, embedded in larger functions, and the overall logic of the game is difficult to see or reuse to build up new levels.

Apple’s GameplayKit has several algorithms and data structures that make it easier to follow game development best practices. One of them, GKRuleSystem, lets you build up complex conditional logic from smaller pieces. By structuring your code around it, you’ll create rules that are easier to change or reuse for new levels. In this article, we’re going to take typical game logic code and learn how to represent it as a rule system.

Puzzle Games Are Made Of Lots Of Similar Levels

I love puzzle games. The good ones start by teaching you how the game world works. Then, along the way, you discover new capabilities and apply them to harder challenges. The developer needs to balance each level to make sure that you never get bored or want to give up. Two of my favorites are Monument Valley and Hundreds. A newer iOS game, Mini Metro, is a subway simulation game and looks perfect for me. The developers say players need to “constantly redesign their line layout to meet the needs of a rapidly-growing city,” which is a good description of the progressive challenges I’m looking for.

Screenshots of Hundreds, Monument Valley, and Mini Metro.
Screenshots of Hundreds, Monument Valley, and Mini Metro (Image credit: Finji, ustwo, and Dinosaur Polo Club)

These games have lots of levels, each one a little harder or with a new twist. You’d think that it would be easy to build up the code for successive levels from previous ones, but as you’ll see, it can be harder than it looks.

I recently started to make a game like this called Puz-o-Mat. The Puz-o-Mat is a box with five colored buttons on it. The goal of each level is to get all of the buttons to light up by discovering the pattern that satisfies the rules of the level. Puz-o-Mat will give you feedback if you are on the right track and buzz and flash its lights to reset the level if you make a mistake.

A level being reset in Puz-o-Mat

A Simple Game Gets Out Of Hand Quickly

To see why we might need a rule system, it’s useful to try to implement the game levels as a set of evaluation functions first. You can follow the code below in a Playground on GitHub.

The goal of the first level of Puz-o-Mat is to press each button once. When you press a button, it lights up to let you know that you are on the right track. If you press one that is lit up already, the game will reset and the lights will turn off. When you tap each button once, you win the level.

Since we have a finite and defined set of game outcomes, we can just list them in an enum called GameOutcome:

enum GameOutcome: String {
   case win
   case reset
}

And then, define an evaluation function that returns a GameOutcome given the buttons. If there is no outcome yet, it returns nil.

func evaluateLevel001(buttons: [Button]) -> GameOutcome? {
   var foundUntappedButton = false

   for button in buttons {
       if button.tapCount >= 2 {
           return .reset
       }
       else if button.tapCount == 0 {
           foundUntappedButton = true
       }
   }

   if !foundUntappedButton {
       return .win
   }

   return nil
}

It’s not too hard to understand, but it’s concerning that we need a loop and three conditionals to describe the easiest level.

The second level of the game is a little harder to complete. Instead of pressing any button you want, you have to press them in order. Here’s how to do that:

func evaluateLevel002(buttons: [Button]) -> GameOutcome? {
   var foundUntappedButton = false

   for button in buttons {
       if button.tapCount >= 2 {
           return .reset
       }
       else if button.tapCount == 0 {
           foundUntappedButton = true
       }
       else if button.tapCount == 1 && foundUntappedButton {
           return .reset
       }
   }

   if !foundUntappedButton {
       return .win
   }

   return nil
}

This code has just one extra else if statement. It would be nice to share some code with the previous evaluator.

As we go on from level to level, you’ll find that a line here or there may be duplicated, but it’s hard to come up with a function that handles all levels. You could do it by taking another parameter, but this game is going to have 100’s of levels; we can’t keep adding parameters and extra conditionals for each of the variations.

Moving on, the next level needs you to tap the buttons in reverse order. The function looks exactly like the last one, except the loop looks like this:

for button in buttons.reversed()
The reverse level being won in Puz-o-Mat

Again, a lot of code is the same, but it’s awkward to reuse.

There are some patterns, though.

  1. Each evaluator starts with some game state.
  2. Most of the work is checking conditionals against the game state to see what we should do next. There are many different conditions across the whole game, and each level seems to mix and match them.
  3. The main point of the evaluator is to decide if we need to reset or win.

Even though there is a pattern to the level game logic, all of the parts are mixed together and aren’t easily separated for reuse.

Refactoring Around The Conditionals

Using this insight we could try to restructure the code. A promising place to start is by pulling out the conditionals into separate functions. As we’ll see later, this is the design of GameplayKit’s rule system, but we can get part of the way there by playing with this code first. Then, we’ll convert our result to GameplayKit.

First, let’s use a type alias to define what a rule is:

typealias Rule = (_: [Button]) -> GameOutcome?

A rule is a function that takes the array of buttons and responds with an outcome if it knows what to do or, nil if not.

Many levels may limit the number of taps on a button, so we’ll make a function to return a rule that checks buttons against a tap limit:

func makeTapLimitRule(maxTaps: Int) -> Rule {
   return { buttons in
       if (buttons.first { $0.tapCount >= maxTaps }) != nil {
           return .reset
       }
       return nil
    }
}

makeTapLimitRule takes a parameter, which is the number of taps to check for, and returns a closure that tells you if the game should reset or not.

Here’s one that can check if all buttons have been tapped once.

func makeAllTappedRule() -> Rule {
   return { buttons in
       if (buttons.first { $0.tapCount != 1 }) == nil {
           return .win
       }
       return nil
   }
}

To use these buttons in our first level, we return an array of rules:

func rulesForLevel001() -> [Rule] {
   return [
       makeTapLimitRule(maxTaps: 2),
       makeAllTappedRule(),
   ]
}

Then, we need loop through all of the rules and run them so that they can check their conditional against the button state. If any of the rule functions return a GameOutcome, we’ll return it from the evaluator. If none are true, we’ll return nil.

Diagram of rule evaluation.
Diagram of rule evaluation

The code is:

func evaluate(buttons: [Button], rules: [Rule]) -> GameOutcome? {
   for rule in rules {
       if let outcome = rule(buttons) {
           return outcome
       }
   }
   return nil
}

Our first level is simply:

func evaluateLevel001WithRules(buttons: [Button]) -> GameOutcome? {
   return evaluate(buttons: buttons, rules: rulesForLevel001())
}

Following this train of thought, you could expand the rules to take in more parameters or check more states. Each level is expressed as a list of rules that produce some outcome. Rules are separately encapsulated and easily reused.

This is such a common way of structuring algorithms in game logic, that Apple provides a set of classes in GameplayKit that we can use to build games in this style. They are collectively known as the Rule System classes and are primarily implemented in GKRuleSystem and GKRule.

Using these Rule System classes hides all of the complexity of the evaluator, but more importantly, delivers much more power than our simple one.

GameplayKit’s Rules Systems

In the GameplayKit rule system, you need to define three things:

  1. The game state A dictionary that will represent your game state. It can have any keys and structure that you wish.
  2. A set of fact objects The possible results of evaluating the rules. Facts can be any object, but in Swift it makes sense for them to be an enum. We could use the GameOutcome enum we used in our examples above.
  3. A list of rules The GKRule objects to evaluate. They each provide a conditional to check against the state and an action to perform if the conditional is true.

Diagram of GKRuleSystem objects.
GKRuleSystem object relations

To run the algorithm, we need to:

  1. Construct a GKRuleSystem object.
  2. Copy all of the game state dictionary entries into the object.
  3. Add the rules array to the rule system.
  4. Call the rule system object’s evaluate function.
  5. After evaluating, check to see if any facts were created.
    • If any were, return the first fact as a game outcome.
    • If not, return nil.

This diagram shows how the individual objects interact over time:

Diagram of GKRuleSystem evaluation.
Diagram of GKRuleSystem evaluation

The code to complete this interaction is straightforward:

func evaluate(state: [String: Any], rules: [GKRule]) -> GameOutcome? {
   let rs = GKRuleSystem()
   rs.state.addEntries(from: state)
   rs.add(rules)
   rs.evaluate()

   if rs.facts.count > 0, let fact = rs.facts[0] as? NSString {
       return  GameOutcome(rawValue: fact)
   }
   return nil
}

Note: GameplayKit classes were designed for Objective-C, which is why I had to derive GameOutcome from String and use the enums as rawValues.

This is a very simple use of GKRuleSystem, and it’s fine if you don’t need to do this in the context of an action game (and need to keep up with a high frame rate). In that case, you can use the fact that the rule system state property is a mutable dictionary that you can alter directly rather than recreate. You could also reuse rule system objects with rules set up.

This is similar to our evaluator from the last section, but this one can operate over a more complex state object. Also, GKRuleSystem.evaluate() is more sophisticated than a loop over the rules. You can provide rule precedence, fact retraction (rules that reverse fact assertions), and find out which exact rules were invoked, among other features.

Converting Our Code To GKRules

The last step is converting our Rule functions to GKRule. You could derive GKRule subclasses for each rule, but for most applications, the GKRule constructors will be good enough.

One of the constructors takes two blocks:

init(blockPredicate predicate: @escaping (GKRuleSystem) -> Bool,
   action: @escaping (GKRuleSystem) -> Void)

The first block is a conditional that looks at the state, while the second block is called to assert a fact if the first block returns true.

It’s a little cumbersome to use, but here’s our two tap reset rule:

GKRule(
   blockPredicate: { rs in
       guard let buttons = rs.state["b"] as? [Button] else {
           return false
       }
       return (buttons.first { $0.tapCount >= 2 }) != nil
   },
   action: { rs in
       rs.assertFact(GameOutcome.reset.rawValue)
   })

During the GKRuleSystem evaluation, this object will have its first block called to see if the rule should be applied. The block could refer to external sources, but the most common thing is to look at the passed in rule system’s state property and check it for a condition. If the first block returns true, then the second one is called. It could also do anything, but it’s expected that it would assert or retract facts.

This is a good constructor to use if you have complex state and conditionals, or if you are using information outside of the rule system to implement rules. Since the expectation and normal case is to use the state and make facts, there is a more direct constructor to use.

If you can express your conditional as an NSPredicate against the state dictionary, GKRule has a constructor to assert facts directly based on them. It’s:

init(predicate: NSPredicate, assertingFact fact: NSObjectProtocol,
   grade: Float)

Let’s extend GameOutcome with a function that creates GKRule objects for us.

extension GameOutcome {
   func assertIf(_ predicateFormat: String) -> GKRule {
       let pred = NSPredicate(format: predicateFormat)
       return GKRule(
           predicate: pred, assertingFact: self.rawValue, grade: 1.0)
    }
}

Our rules function is just:

func predicateRulesForLevel001() -> [GKRule] {
   return [
       GameOutcome.reset.assertIf("ANY $b.tapCount == 2"),
       GameOutcome.win.assertIf("ALL $b.tapCount == 1"),
   ]
}

And the level evaluator becomes a one-liner:

func evaluateLevel001WithPredicateRules(buttons: [Button]) -> GameOutcome? {
   return evaluate(state: ["b": buttons],
          rules: predicateRulesForLevel001())
}

The only thing you need for a new level is a new list of GKRules. We have gone from an imperative level description where we need to give every step to a declarative one where we provide queries and outcomes.

Another benefit of this approach is that the predicates are serializable, so they can be stored in external files or sent over the network. With just a few more lines of code, we could move the predicate and enum to a .plist file and load them instead of hard-coding them. Then, a game designer on your team could make or tweak levels without editing code. The main coding work would be to add more state and outcomes.

Level definitions in a .plist.
Level definitions in a .plist (Large preview)

In this example, facts are mutually exclusive; you can either win or reset, but not both. But, this is not a restriction of rule systems. If it makes sense for multiple facts to be asserted, then you may do so, and the code that checks the facts will need to reconcile them. One example is that we could treat each button’s light as a fact (on or off) and there would be a fact asserted for each button as the game was played. In that case, there could certainly be many facts asserted at the same time.

Finally, you may have noticed that facts are asserted with a grade. This grade is a number from 0 to 1 that you can think of as a probability that this fact is correct. We used 1 to indicate certainty, but you could use lower numbers to indicate that the fact only has a probability of being true. These probabilities can be combined and, using random numbers, we could pick among the asserted facts and have emergent, rather than deterministic, game behavior. This is known as Fuzzy Logic, and I’ll cover that in a future article.

All of the code in this article is available in a three-page Playground on GitHub.

Final Notes

  • This article concentrated on just the game logic aspect of developing a game for iOS, but if you want to see how to construct the visual aspect of a game using SpriteKit, check out this series right here on Smashing: Part I, Part II, Part III.
  • This WWDC video (Safari required) covers GameplayKit. Go to 43:10 in the video to hear about Rule Systems.

Further Reading

Smashing Editorial (da, yk, aa, il, mrn)