How To Make a Card Game For iOS — Game Core (Part 3)
#iOS App Development
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.
- Card Game Engine with UIKit
- Generic Card Game
- Card Game Internals - The Game Internals - you are here
- Part 4
- 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.
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:
- Pick is triggered at
ViewController
GamePresenter
'spickCardsFromDeckView(...)
is called with corresponding deckType, indexPath, etc.GamePresenter
delegates call toGameActionsPresenter
GameActionsPresenter
will try to pick cards - calls Game'spickCardsFromDeck(...)
- if any card is picked,
GameActionsPresenter
callsViewController
'spresentPickCardsFromDeck(...)
- At
presentPickCardsFromDeck(...)
ViewController
- asks
AnimationsController
to create snapshot for the cards and present it as overlaying dragable animated views - reloads corresponding
collectionView
's section.
- asks
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.
-
Drop is triggered at
ViewController
via gesture recognizer cancellation or finish -
ViewController
checks if there is any deck under current coordinates -
GamePresenter
'sdropPickedCards(...)
is called with corresponding deckType, indexPath, if needed -
GamePresenter
delegates call toGameActionsPresenter
-
GameActionsPresenter
will try to drop cards -
Move Action is created and is checked against that Game (its rules):
- if action is possible,
- game's picked cards are put back in their origin deck
- action is applied
GameActionsPresenter
callsViewController
'spresentDropCardsAnimationAt(...)
- if not,
- game's picked cards are put back in their origin deck
GameActionsPresenter
callsViewController
'spresentCancelMoveCardsAnimation(...)
- if action is possible,
-
Both at
presentDropCardsAnimationAt(...)
andpresentCancelMoveCardsAnimation()
ViewController
- asks '''AnimationsController''' to move the overlaying dragable animated views to necessary coordinateds
- reloads corresponding
collectionView
's section. - 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
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:
- Generate Action
- Ask UI to present "pick" via
AnimationController
- Ask UI to reload
- Ask UI to present move via
AnimationController
- Ask UI to present drop via
AnimationController
- Apply Action to game state
- Ask to reload
- 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:
- Make the Game generate all actions needed to finish the game as a batch in background.
- Based on these actions I get decks and indexPaths for each move
- All actions are performed as a batch to the game state
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.
Comments