8 min read

How To Make a Card Game For iOS — Game Core (Part 3)

What's the game? In my case, the Game object can be considered as a finite state machine, that may perform transition in response to an input action that we apply to her.
How To Make a Card Game For iOS — Game Core (Part 3)
Photo by Unsplash / Unsplash

That's the third part of the series, devoted to implementation of the Card Game Engine. This time about game internals and some game features implementation.

  1. Card Game Engine with UIKit
  2. Generic Card Game
  3. Card Game Internals - The Game Internals   - you are here
  4. Part 4
  5. Part 5

As I've already told in the very first post of this series, I wanted the game to be as simple and thin as possible and just to keep current state. But eventually it turned out that we had better plans for it.

The Game started to attract more and more responsibility and I believe it made the Game even better.

Disclaimer👺

I do not provide super accurate details of implementation or code snippets/examples (though sometimes I do) intentionally because it will turn the post into boring unreadable BOOK OF CODE. Please consider the series as a story of implementation. A zoomed-out overview of what's going on in code and how it works.

The Game

What's the game? In my case, the Game object can be considered as a finite state machine, that may perform transition in response to an input action that we apply to her.

The State

The current game state can be easily obtained by calling on of these methods:

 func numberOfDecksOfType(_ deckType: DeckType) -> Int
 
 func numberOfCardsInDeck(_ deckType: DeckType, deck: Int) -> Int
  
 func cardInDeck(_ deckType: DeckType, indexPath: DeckIndexPath) -> Card?

Initial State

Initial state is generated as randomly shuffled array of cards placed into necessary array of decks.

There is a way to generate "special" arrays of cards placed into decks to simulate and test different game situations.

Actions

There are 3 types of Actions (that BTW works with generic deck type): flip, move and shuffle:

enum GameAction<Deck>: GameActionProtocol {
  case flip(deck: Deck, indexPath: DeckIndexPath)
  case move(from: (deck: Deck, indexPath: DeckIndexPath), to: (deck: Deck, indexPath: DeckIndexPath))
  case shuffle(deck: Deck, from: Int, to: Int, by: Int)    
}

Flip is simply flipping card. Move is when you move a card or several cards from one deck to another.

Shuffle is a special move that happens only on waste-deck. One or several cards change their deck section and flip at the same time like in carousel.

Perform Actions

Game actions could be easily performed by:

 func canPerformAction(_ action: GameAction<DeckType>) -> Bool  
  
 func performAction(_ action: GameAction<DeckType>)
 
 func performUndoAction(_ action: GameAction<DeckType>)

The Game does not decide itself if action is possible. It delegates all checks to rules object that is injected to game as dependency.

Looking ahead, it's a game fabric that instantiates game with correctly injected rules according the app settings.

When action is performed it also affects game scores and statistics. GameStatistics object is also injected into the game. It defines the scores and how they are counted from actions.

Actions stack

The Game has "undo" feature, so all actions are stacked in order to roll back the game state step by step.

Brunch in Bed
Photo by Brigitte Tohm / Unsplash

I especially like how smoothly "undo" feature works inside:

  • I just pop the last action from stack
  • Turn action into "opposite" action
  • Apply it to the game state without adding to stack and rule checks

"Apply action to the game state" piece of code remains the same, no matter if it is "undo" or straight action.

Is it Redux?

I think if I improve it one day and add subscriptions to the state, it will turn into Redux.  

In fact,  there are limitations for it. UI animations depend on action success/failure/cancellation that depend on user's gestures and game rules.

There are both sync and async cases of game state updates and UI updates that I have to manually control, depending on what's going on.

Congrats! UndoManager is reinvented!

I didn't use Foundation's UndoManager not just because its super ugly API but also... Wait but why?🤔

Cards picking

Cards picking is not considered as a game action, so it doesn't affect game scores and statistics and is not working with game actions stack.

Methods, for the Game API:

func canPickCardsFromDeck(_ deck: DeckType, index: DeckIndexPath) -> Bool 
  
func pickCardsFromDeck(_ deck: DeckType, index: DeckIndexPath)

func dropPickedCards()
  
var pickedCards: (cards:[Card], 
                   from: DeckType, 
                  index: DeckIndexPath)? { get }

But it affects game state: picked cards are stored as "picked" and temporarily do not belong to any deck until dropped or placed somewhere.

The picking itself works the following way:

  1. Pick is triggered at ViewController
  2. GamePresenter's pickCardsFromDeckView(...) is called with corresponding deckType, indexPath, etc.
  3. GamePresenter delegates call to GameActionsPresenter
  4. GameActionsPresenter will try to pick cards - calls Game's pickCardsFromDeck(...)
  5. if any card is picked, GameActionsPresenter calls ViewController's presentPickCardsFromDeck(...)
  6. At presentPickCardsFromDeck(...) ViewController
    1. asks AnimationsController to create snapshot for the cards and present it as overlaying dragable animated views
    2. reloads corresponding collectionView's section.

6.1 I think I'll get into more details of AnimationsController in later posts, devoted to animations implementation. How it creates snapshots, behaves, etc.

6.2 As soon as game's pickCardsFromDeck(...) method is called, picked cards do not belong to the any deck any more. They are picked. So at this moment we may just reload UI and they will disappear, as they are considered to be picked.

That fully expresses the philisofy I've mentioned in the very first post related to Cards Game:

All cards' decks CollectionViews are responsible to represent the exact current game state. We can apply game actions, pick or drop cards, anything. We call reload() and all card decks are exactly what the game state is now.

Cards dropping

It's very similar to cards picking.

  1. Drop is triggered at ViewController via gesture recognizer cancellation or finish

  2. ViewController checks if there is any deck under current coordinates

  3. GamePresenter's dropPickedCards(...) is called with corresponding deckType, indexPath, if needed

  4. GamePresenter delegates call to GameActionsPresenter

  5. GameActionsPresenter will try to drop cards

  6. Move Action is created and is checked against that Game (its rules):

    1. if action is possible,
      1. game's picked cards are put back in their origin deck
      2. action is applied
      3. GameActionsPresenter calls ViewController's presentDropCardsAnimationAt(...)
    2. if not,
      1. game's picked cards are put back in their origin deck
      2. GameActionsPresenter calls ViewController's presentCancelMoveCardsAnimation(...)
  7. Both at presentDropCardsAnimationAt(...) and presentCancelMoveCardsAnimation() ViewController

    1. asks '''AnimationsController''' to move the overlaying dragable animated views to necessary coordinateds
    2. reloads corresponding collectionView's section.
    3. asks '''AnimationsEngine''' to hide overlaying dragable animated views and release resources.

Both block-schemes above are simplified to be readable. In real code it's a bit more complicated because of animations' and other actions'  sync and async calls, callbacks, etc. But the main idea is preserved.

How hard is Klondike

Alaska Highway
Photo by Greg Sellentin / Unsplash

According to wiki (I'm too lazy to check the numbers myself) Solitaire "Klondike" has about 8 × 10 67 possible combinations of cards layout, 79% of which are theoretically winnable.

On practice, users win much less of them, by just making wrong moves.

How to know that the current game is theoretically winnable?

The only way is to brute force all possible game actions that will take ages. That's why we won't do that.

I didn't need to generate exactly winnable combinations. At least, this time. But If I would, I would do the following thing: take a won state of a game and roll it back with randomised "undo" moves to initial state. On condition that reversal moves are allowed.

How to know that the current game is lost?

That's tricky. The game may still have available moves, that are not restricted by rules, like moving the some card back and forth from one deck to another. But these moves may have no sense.

Solitaire "Klondike" AI

The Game have some signs of intelligence.  

It can generate action for desired deck at IndexPath that makes sense or not.

It works rather straightforwardly.

  • It takes desired deck with its indexPath
  • Iterates over all other decks and generates move or shuffle Action for them
  • Checks if that action is possible
  • Checks if that action makes sense

How do I check the "sense"?

It is delegated to game rules. Actions that make sense are those that are considered helpful to push the game progress forward. So it depends on decks where they are performed.

Actions that make sense may allow to open new face down cards, or cards that have a place to be moved in next actions. So it all depends on decks where they are performed.

Automove

Automove is for lazy players that don't wanna drag cards. They just tap on specific card in deck and some move occurs.

It utilises "Klondike intelligence" generating some Action, not necessary with sense, and applying it to the Game state and asking presentation layer to present it.

Frankly speaking, a little bit more complicated:

  1. Generate Action
  2. Ask UI to present "pick" via AnimationController
  3. Ask UI to reload
  4. Ask UI to present move via AnimationController
  5. Ask UI to present drop via AnimationController
  6. Apply Action to game state
  7. Ask to reload
  8. Hide animated overlay of AnimationController

Hints

Hints work similar to Automove.

It iterates over possible source of move (deck + card index path) and generates move actions or shuffle actions.

The action generated is required to have sense. Action is not applied to the game state so it keeps still.

The presentation layer is asked to present the action with more smooth animation at slower animation speed.

Autocomplete (auto win)

Autowin is an option for lazy players. It's available when game decides that user has almost won. It happens when all cards are opened and the only thing is to place them to foundation deck.

The common generate action -> perform action -> present animation approach doesn't work in that case because the resulting animation isn't fast enough and looks boring.

That's why I decided to the following thing:

  1. Make the Game generate all actions needed to finish the game as a batch in background.
  2. Based on these actions I get decks and indexPaths for each move
  3. All actions are performed as a batch to the game state
  4. AnimationsController shows overlaying animation of views that is generated basing on decks and indexPaths above and present all actions with very small delay, so it looks showy

There is a precondition that allows me to be sure that the game can be exactly won and will be able to generate all actions to win. Otherwise it can get stuck.

Game Over

Game over also uses intelligent moves generation. When user shuffles waste-deck, the game, internally tries to generate game action that makes sense. If there is no, it triggers delegate's call back that the game is lost.

The post is over

Congrats! You've read up to an end.