How To Make a Card Game For iOS — Architecture Overview (Part 1)
- Card Game Engine with UIKit - You are here.
- Generic Card Game
- Card Game Internals - The Game Internals
- Part 4
While UIKit is not killed by SwiftUI and is still used at least somewhere, I decided to start a set of posts devoted to implementation of card game engine based on UIKit.
At the beginning of 2018 I was hired by an indie game studio for an implementation of a basement for a card game. That was one of my favourite examples of unexpected freelance type of projects. That was very intresting mainly because it was so different from 99% of jobs I was usually working on as an iOS dev.
Card game was more like just a tip of an iceberg, a playground. They had an intention to implement an extendable basement for other card games. A kind of card game engine.
Going forward, the playground was a plain old Solitaire!
Mr. Solitaire
Recently, I've shown that old thing to my dev mate. He just asked "how the hell does that shit work?!" And that's exactly how this publication started.
As for now, I'm thinking of several themes that might be interesting here:
- Core internals overview
- Scaling up extendability with protocols and generics
- Game specific features implementation
- Animations and perfomance tuning
So to be aware of what to expect let's start with videos, along with the app specs and requirements.
Specs and reqs
Extendability
Make everything as loose-coupled as possible to have a chance to plug-in another game with minimum pain.
In fact, development process required some extra extendability on different levels. Plugable game rules and pre-defined pre-ordered card decks for testing different game situations, animations and behaviours.
Solitaire-specific
- 1-Card/3-Cards Mode
It's a Solitaire-specific feature that affects the way, cards are layouted on waste-deck during the game.
- Left/Right handed mode
It's not just mirroring of the layout, because it also affects animation and cards behaviour.
Other game features:
- UIKit dynamics powered interactive drag-n-drop
- Automove
Generate and perform quick automove on tap. In fact, it's generation and performing game action, from the selected place.
- Autocomplete
When game is almost won, user may be suggested to play the game up to an end in automatic mode. Actualy, it's accelerated generation and presentation of a bunch of actions that allow to finish the game.
- Hints
Hint button tap would iterate over all possible moves that make some sense from the game rules' point of view. In fact it's generation and presentation of the game actions without actual change of the game state.
- Undo actions stack
All game actions are revertable with exact backwards animation.
- Game over
User should be informed when there is no possible moves that makes any sense.
- Rollercoaster
The most weird thing I've ever seen since windows 3.0, where rollercoaster could easily be caused by just a rendering bug that could also be spotted in all windows UI😂
Other
Deployment target: iOS 8 while iOS 11 was the most fresh at that time😭
Supported devices: iPad/iPhone. Thanks to iOS 8 deployment tardet I had a huge choice of weak old devices to test on and tune performance.
Solitaire Begins
To start with, let me introduce you to Mr. Solitaire
There are tree main domains in the UI.
- Tableau deck
Drag-in-drop allowed for faced up cards. Drop is possible according to game rules (black-red + descending card order)
- Foundation deck
Drag-in-drop is allowed. Drop is possible according to foundation game rules (same suit + ascending card order)
- Waste deck
Drag-in-drop is allowed only in the face-up section. Drop is only allowed in case of cancelled move. Tap on face-down section flips 1 or 3 cards, depending on game rules, and move them to face-up section.
Introducing C-A-K-E Architecture
C-A-K-E Architecture is the most appropriate design pattern where every letter stands for nothing.
The whole thing is divided into two parts
- Current game state static presentation
- Actions handling and animations
Game screen consists of several layers, like a cake:
In some way it resembles VIPER with the Game instead of Interactor. But in fact it happened to be a little bit worse, because game is passive and does not make any calls on Presenters.
Architecting UI
What if I say you that UIKit is already a card game engine.
That's funny, but it turns out that UIKit is all about rectangular views and it perfectly matches any card game!
Decisions avoided
One of the first ideas was to use a single UICollectionView and it's drag-n-drop API.
The idea was rejected almost immediately due to iOS 9 deployment target requirement (drag-n-drop API is available since iOS 11).
But let's imagine now what if we don't have to support iOS9 and iOS11 is fine. Would it be good and how the internals would look like.
In fact it may end up with a fat and complicated collectionView with many sections and much more complicated custom collectionViewLayout that will have to handle the whole game screen.
I suppose, it may be a candidate for the dirtiest place of the app, with all these cards' frame calculations. That's not good.
The most painful thing will be customization of that layout for another game with different set of decks. In fact it will require to rewrite collectionViewLayout for every game.
Decisions made
I've broken the whole screen into several collection views, corresponding to the deck types: Tableau, Waste, Foundation.
I've implemented my own drag-n-drop that lives on top of all underlying collectionViews and allows to move cards not only in bounds of a single collecionView, but also between different collection views.
C-A-K-E Overview
AnimationsController
That's magical thing that handles most of cards animation. It performs the illusion of cells to be picked, UIKit Dynamics powered cards behaviour, etc.
Here are some of its protocol methods:
func hide(gradually: Bool, withDuration: Double, moveComplete: (() -> Void)?)
func hide(gradually: Bool,
withDuration: Double,
moveComplete: (() -> Void)?,
hideComplete: (() -> Void)?)
func moveCellsTo(_ point: CGPoint)
func moveCellsBy(_ offset: CGPoint)
func pickCellsFromCollection(_ collection: UICollectionView,
startIndex: IndexPath,
withDraggingAnimation: Bool)
func shakePickedCells(complete: (() -> Void)?)
func moveCellFastFrom(_ from: (collection: UICollectionView,
index: IndexPath),
to: (collection: UICollectionView,
index: IndexPath),
useSourceZIndex: Bool,
duration: Double,
delay: Double,
complete: @escaping (() -> Void))
func removeMovedCells()
func moveCellFrom(_ from: (collection: UICollectionView, index: IndexPath),
to: (collection: UICollectionView, index: IndexPath),
useSourceZIndex: Bool,
duration: Double,
delay: Double,
hideOnComplete: Bool,
complete: @escaping (() -> Void))
func presentRollerCoaster(_ from: UICollectionView, indeces: [IndexPath],
delay: Double,
complete: @escaping (() -> Void))
You might have noticed that there is no single word about cards, decks or solitaire. That's because collectionView and its layout is enough for AnimationController to present everything.
BTW it doesn't handle gestures. Gestures are handled in viewController and it's viewController who tells AnimationController to perform moves or finish animation.
In one of the next publications I'll describe its work more detailed.
ViewController
It's just autolayout powered VC with three collectionViews on it. Autolayot makes it flexible for any device screen size. Knowing collectionView bounds size allows to calculate appropriate cards' sizes and their offsets.
ViewConroller has reload-related methods:
func reloadAllDecks()
func reloadDeck(_ deck: DeckViewType, animated: Bool)
func reloadDeckAfterCardDropWithInsertion(_ deck: DeckViewType,
insert: [IndexPath])
A bunch of methods for animated moves presentation:
func presentDeckWasteAnimation(_ update: DeckToWasteViewUpdate)
func presentDropCardsAutocompleteAnimationAt(_ deck: DeckViewType,
at: IndexPath,
moveCompletion: (()-> Void)?,
dropCompletion: (()-> Void)?)
func presentDropCardsAnimationAt(_ deck: DeckViewType,
at: IndexPath,
completion: (()-> Void)?)
func presentHintDropCardsAnimationAt(_ deck: DeckViewType,
at: IndexPath,
completion: (()-> Void)?)
func presentShakeHintAnimationFor(_ deck: DeckViewType, section: Int)
func presentPickCardsFromDeck(_ deck: DeckViewType,
at: IndexPath,
remove: [IndexPath])
func presentAutocompleteMoveCardAnimationFrom(_ moves:
[(from: (view: DeckViewType, index: IndexPath),
to: (view: DeckViewType, index: IndexPath))],
completion: (() -> Void)?)
func presentMoveCardAnimationFrom(_ deckView: DeckViewType,
toDeckView: DeckViewType,
indeces: [(from: IndexPath,
to: IndexPath)])
func presentWinAnimation()
func presentLoseGameAlert()
func presentAutocompleteEnabled(_ enabled: Bool)
func presentFlipActionForDeck(_ deck: DeckViewType, at: [IndexPath])
func presentNoAvailableActionForDeck(_ deck: DeckViewType, at: IndexPath)
func presentCancelMoveCardsAnimationWithDropOn(_ deck: DeckViewType,
insert at: [IndexPath])
CardsDecksCollectionViewLayout
CardsDecksCollectionViewLayout is a custom UICollectionViewLayout happened to be pretty simple.
With the help of its delegate's methods, it calculates cards frames, depending on cards sizes, sections insets and cards in-section offset in respect of previous card.
protocol CardsDecksCollectionViewLayoutDelegateProtocol {
func cardSize(_ layout: CardsDecksCollectionViewLayout) -> CGSize
func cardSectionInsets(_ layout: CardsDecksCollectionViewLayout) -> UIEdgeInsets
func cardOffsetFor(_ layout: CardsDecksCollectionViewLayout,
at indexPath: IndexPath) -> CGPoint
}
CardsDecksCollectionViewLayout makes it possible to layout any cards deck needed:
- vertical offset, like in Tableau,
- horizontal offset, like in Waste-Deck
- zero, like in Foundation.
You may guess that viewController turns out to be it's delegate, knowing configs of each collectionView layout.
DeckPresenter
Each card deck collection view has it's own DeckPresenter which is responsible for its static state with good old UIKit collection-styled methods:
func numberOfSections() -> Int
func numberOfItemsForSection(_ section: Int) -> Int
func itemViewModelFor(_ indexPath: IndexPath) -> CardViewModelType
GameActionsPresenter
Obviously, GameActionsPresenter is responsible for handling game interactions
func pickCardsFromDeckView(_ deckView: DeckViewType,
indexPath: IndexPath)
func dropPickedCardsOnDeckView(_ deckView: DeckViewType,
indexPath: IndexPath)
func tryToPerformAvailableAction(_ deckView: DeckViewType,
indexPath: IndexPath)
func dropPickedCardsInPlace(_ inPlace: Bool)
GamePresenter
GamePresenter is a facade in front of underlying DeckPresenters and GameActionPresenter. GamePresenter does almost nothing, except forwarding method calls, hiding internal complexity of several Presenters.
Similarly to DeckPresenter it has DeckViewType-specified methods for static game state presentation:
func numberOfSectionsFor(_ deckView: DeckViewType) -> Int
func numberOfItemsForSection(_ deckView: DeckViewType, section: Int) -> Int
func itemViewModelFor(_ deckView: DeckViewType,
indexPath: IndexPath) -> CardViewModelType
GameActionsPresenter methods calls are just forwaded to the underlying ActionsPresenter.
Game
At the beginning of the project, the Game was just a super-thin and plane game state providing access to cards by deck and index.
func numberOfDecksOfType(_ deckType: DeckType) -> Int
func numberOfCardsInDeck(_ deckType: DeckType, deck: Int) -> Int
func cardInDeck(_ deckType: DeckType, index: DeckIndexPath) -> Card?
Then, picked cards state was moved from GameActionsPresenter down to the game:
func canPickCardsFromDeck(_ deck: DeckType, index: DeckIndexPath) -> Bool
func pickCardsFromDeck(_ deck: DeckType, index: DeckIndexPath)
func dropPickedCards()
var pickedCards: (cards:[Card],
from: DeckType,
index: DeckIndexPath)? { get }
After some time Game became more and more complicated and turned into a kind of finite-state machine with methods, allowing to change state:
func canPerformAction(_ action: GameAction<DeckType>) -> Bool
func performAction(_ action: GameAction<DeckType>)
func performUndoAction(_ action: GameAction<DeckType>)
Later on, the game obtained
- pluggable rules,
- pluggable initial deck state
- ability to generate available actions,
- ability to check if game is lost
Stay tuned
The publication is an intro to the "Cards Game Engine with UIKit" series, that provided a little overview of the whole thing and future plans of publications.
Become a subscriber to be aware of all upcoming updates.
I'd be very gratefull if you share my stuff in your FB/Twitter/whatever.
Comments