Hacking with Swift: learning project 34, Four in a Row
Setting up
Create a new Xcode project, call it “Four in a row” and save it somewhere sensible. Set its deployment info to use only iPad and to be only in Landscape orientation.
Creating the interface with UIStackView
Open Main.storyboard, select the view controller and embed it into a Navigation Controller. Select the Navigation Controller, select its Navigation Bar in the document outline then, in the Attributes Inspector, uncheck the Translucent property so that we can avoid our game going behind the bar.
Drag a Horizontal Stack View from the Object Library to the view controller, make it fill all the available space (navigation bar excluded) then go to Editor > Resolve Auto Layout Issues >Reset to Suggested Constraints.
Drag 7 buttons to the stack view, then select the stack view, go to the Attributes Inspector and change its Distribution property to “Fill equally” and its Spacing property to “2”.
Select all buttons, change their background colour to be “White Color” and remove their text. Select each one of the buttons one after the other and give them increasing tag values from 0 to 6. Finally, select the View and give it a Light Grey background colour.
Switch to the Assistant Editor, Ctrl-drag from the first button to create an Outlet Collection called columnButtons
and then drag from the black dot in the Swift file to each of the other buttons to connect them. Ctrl-drag again from the first button and create an action called makeMove()
, change its sender type to UIButton
and then connect all the other buttons.
Preparing for basic play
Create a new Cocoa Touch Class file, subclass NSObject
and name it “Board”. Give it two static properties, width
and height
, respectively with 7 and 6 as value.
Back in ViewController.swift add an array of UIView
arrays called placedChips
property and an implicitly unwrapped Board
property.
Inside viewDidLoad()
set up a for-in
loop from 0 up to and excluding the value of the width of the board (7, so count from 0 to 6) so that for each run it would append an empty array of UIView
s to the placedChips
array. After the loop, call the yet unwritten resetBoard()
method.
Write the resetBoard()
method, assigning a new Board()
to the board
object then looping over each value from 0 up to and excluding the elements’ count of the placedChips
array (storing that value in a i
constant). Inside create another for-in
loop to loop over each element inside the [i]
indexed element of the placedChips
array and remove it from the superview. Then, outside of the first loop, remove all elements from that same indexed array while keeping its capacity (this should save memory).
Back in Board.swift create a new enumeration called ChipColor: Int
above the class definition and give it three cases: none = 0
, red
and black
to define what can go in a slot.
Below, declare a new property slots
of type [ChipColor]()
. Then override the init()
method with a loop from 0 up to and excluding the width and the height of the board multiplied together and then, for each of those elements, append a .none
enumeration object to the slots
array. After this call super.init()
. I wonder why this last thing is called after and not before … mysteries of coding.
Write two new methods: chip(inColumn:row:)
will return the element of the slots
array at the [row + column * Board.height]
index (I am very curious how he just got to that formula from a one-dimensional array without explaining to us where the array would start counting from, bottom, top, left, right…?) while set(chip:inColumn:row:)
to set the slot’s color.
Let’s try to understand the math here by reverse engineering his formula: if we want a chip placed in column 3 (fourth column!!) row 5 (sixth column!!) the element of the array to be accessed would be (5 + (3 x 6)), which is element 23. It is pretty obvious that the columns would go from left to right but what about the rows? Should we assume that, being in UIKit world, they go from top to bottom? Probably, but for such a game, I would count them from the bottom so our chip will be the one marked with the x in the following wonderful primitive drawing:
o o o x o o o
o o o o o o o
o o o o o o o
o o o o o o o
o o o o o o o
o o o o o o o
Or it could be, in the same column, the last one on the bottom. Who knows? If anyone knows the answer I would be more than happy to credit you here for your help.
Always in Board.swift write the nextEmptySlot(inColumn:)
method, which takes an integer parameter and returns an optional integer. Loop over the row from 0 up to and excluding the board’s height (so from 0 to 5) and for each of them call the chip(inColumn:row:)
method verifying if its return result is equal to .none
, i.e. the column is empty. If that is the case return the found row. Otherwise, at the end of the method, return nil
, to show that the column is full.
After this, write the canMove(inColumn:)
method, which accepts an Int
parameter and returns a Bool
, returning the result of a check against not being nil
of the just written nextEmptySlot(inColumn:)
method.
Declare the add(chip:inColumn:)
method. Inside, conditionally bind the return result of the nextEmptySlot(inColumn:)
method and, if that succeeds, call set(chip:inColumn)
on the column parameter and the just bound row.
Go to ViewController.swift and add the addChip(inColumn:row:color:)
method to the end of the class. Extract the button found at the column
index of the columnButtons
array and store it in a button
constant property. Declare a size
property which is the minimum of either the width of the button’s frame of 1/6 of its height. Create then a rectangle with x: 0, y: 0
and dimensions the just declared size
property. After this, if the count of the elements of the placedChips
array at the column
index (that is, if the array found in the super-array’s count) is less than the chosen row + 1 (that is, if the column is not full), create a new UIView
, set its frame to be the rectangle just created, make it non-user interactive, set its background color to be the color parameter, set its layer’s corner radius to the half of its size (so that it becomes a circle) then determine its center with the return result of the yet unwritten method positionForChip(inColumn:row:)
, transform its position to a y: -800
so that it is completely out of the screen and add it as a subview. Finally animate it during half a second with a “curveEaseIn” option (that is start slowly and accelerate) to move it from way out of the screen to its original place. Then, lastly, append it to the column
-indexed array of the placedChips
array.
Create now the positionForChip(inColumn:row:)
method, which will take two integers and return a CGPoint
. As before, declare a button and a size in the same way, then declare an xOffset
equal to the x-coordinate of the center of the frame’s rectangle and a yOffset
that is equal to the largest value of the y-coordinate of the button’s frame (that is, its bottom line) to which the half of the size will be subtracted (o__O?) — so we get to the bottom of the button and a bit up, ok… breath —. Now subtract from and assign the new value to the yOffset
the size multiplied by the row… so that we get progressively towards the top of the screen the higher the row gets … phew … FInally, return the point made up of these two last values.
The last thing to do for this part is to complete the makeMove()
action method. Set a constant column
equal to the sender’s tag than, conditionally bind a row
constant to the next empty slot in the board’s column (passing the column
constant as the argument), then call the add(chip:inColumn:)
on the board and the addChip(inColumn:row:color:)
of the view controller passing in column
, row
and .red
as arguments.
At two days after writing the rest of the code I sincerely do not remember how these things get together but I will trust this working code!
Adding in players: GKGameModelPlayer
Create a new Cocoa Touch Class called “Player” (subclassing NSObject) and give it 5 properties a chip: ChipColor
, a color: UIColor
, a name: String
, a playerId: Int
(be careful to write the ‘d’ as a lowercase letter…) and a static var allPlayers
that will be an array of players with an initialiser that will consider only its chip
property and have .red
and .black
as arguments.
Import GameplayKit
and make the class conform to the GKGameModelPlayer
protocol, thus described in the Documentation:
Summary
Implement this protocol to describe a player in your turn-based game so that a strategist object can plan game moves.
Declaration
Discussion
You adopt this protocol to describe the gameplay of your turn-based game for use by a
GKStrategist
object. The strategist uses your player class, along with other custom classes you implement (adopting theGKGameModel
andGKGameModelUpdate
protocols) to plan moves in your game.You use your custom class implementing this protocol in several places:
Your class that implements this protocol can also contain properties and methods relevant to the implementation of your game—for example, an identifying color or name.
For more information about describing your gameplay model and using a strategist, see The Minmax Strategist in GameplayKit Programming Guide.
Now write the initialiser for the class which will take only the chip
parameter. Set self.chip
equal to the chip
property and self.playerId
equal to the raw value of the chip property (it is an enumeration). Then, if the chip is red, set the color property to be .red
and the name one to be “Red”, otherwise set it to .black
and “Black”. Finally, call super.init()
.
Add the last bit of code to this class which is the computed property opponent: Player
. If the chip is red we will return the second object in the allPlayers
array, otherwise we will return the first one.
Go to Board.swift and write two stub method that just return false
, isFull()
and isWin(for player: Player)
.
Inside ViewController.swift write an updateUI()
method which will change the view controller’s title to show the turn of the current player using (board.currentPlayer.name)
as string interpolation. We don’t have such a property yet for the board but we’ll fix that pretty soon. Now write a continueGame()
method which will do several things: create an optional string for the game over title and setting its default value to nil
. Then, if the game is won for board’s current player, we set the title sensibly, otherwise if the board is full we do likewise. At this point, if the game over title is not nil, we present an alert controller with the game over title and we set an action to reset the board for a new game AND return from the method. If nothing of this is not happening we are still in the game so we change the board’s current player to his opponent and update the UI.
Go back to Board .swift and create a new property called currentPlayer
of type Player
. Being it non optional we need to initialise it at the top of the initialiser with a set to the first element of the allPlayers
array of the Player
class.
Back in ViewController.swift, change the makeMove
method so that the .add()
method has board.currentPlayer.chip
as first argument and the addChip()
method has board.currentPlayer.color
as last argument. Add also a call to continueGame()
.
Add a line at the beginning of the updateUI
method so that it resets the board also in code: board = Board()
.
Detecting wins and draws in Four in a Row
In Board.swift, change the isFull()
method so that, for every column from 0 up to and excluding the board’s width, if we can make a move in the column, we return false, otherwise, outside of the loop (so that we know that all of the board has been checked), we return true.
And now we write the most obscure method I have ever seen, no really … I have not understood a single line of its logic and why it makes any sense at all. I mean … all this project is really making me boil… I want to learn the logic, the reasoning behind things… Doing things this way doesn’t make me able to reproduce these steps or apply this “knowledge” to other future apps. But fine … we need to buy a book right? Not learn from it…
So, the method is called squaresMatch(initialChip:row:column:moveX:moveY)
, it takes 5 parameters, the first of type ChipColor
and the other four of type Int
and it returns a Bool
. First we need to check if we cannot win in the current position (I mean, already the name of the method, squares… which squares? We have circles…chips…). To do so we check if the row
argument plus the triple of the moveY
argument (the triple? Why? Explain this to me!) is either less than zero or greater that the board’s height. I get where we want to head, that we do not accept things going outside of the board, fine, but why is this code good and something else? Then we repeat the same thing for the (column + triple of moveX
) — why the triple for my lunch’s sake! If any of these checks returns true, we need to return false
from the method as this is not a valid move…
Then, if this so obvious code didn’t make us exit the method we check every square… the column
and row
parameter, that one adding the moveX
and moveY
parameter and then the double and triple of that … REALLY, THIS DOESN’T MAKE ANY SENSE TO ME!
Import GameplayKit
to Board.swift. Now modify the isWin()
method so that the for:
parameter is of type GKGameModelPlayer
, declare a chip
constant equal to the player parameter downcast with a force unwrap to a Player
object’s chip then enter a nested loop over each row and each column. Inside there will be an if-else if
statement which will call each time the squaresMatches
method and always pass in chip
as first parameter , row
as second, column
as third, then 1, 0, 1, 1 as fourth and 0, 1, 1, -1 as fifth. For every one of these cases it will return true, otherwise, at the end of the method, keep the return false
line.
I’m still not understanding this but well … I don’t really care too much at this point. I hate programming games without NO ONE explaining to me with calm and precision the logic and WHY it works like that… There is really no point doing things this way!
How GameplayKit
AI works: GKGameModel
, GKGameModelPlayer
and GKGameModelUpdate
.
Create a new Cocoa Touch Class subclassing NSObject
called “Move”. Import GameplayKit
into it, give it two integer properties, value
and column
, give the first one a value of 0
and initialise the second with the common class initialiser.
Implementing GKGameModel
: gameModelUpdates(for:)
and apply()
.
Make the Board
class conform to the GKGameModel
protocol. Add two methods: copy(with:)
and setGameModel(_:)
.
The first one will accept an optional NSZone
parameter set to nil
by default and return an Any
object. Inside, it creates a new Board()
object called copy
, then calls the setGameModel
method on it passing it self
(the board instance) before returning it.
The second one conditionally binds the gameModel
parameter to a board
constant after optionally down casting it to a Board
object, then sets the slots
array to the board’s slots and the current player to the board’s current player.
Implement the gameModelUpdates(for:)
method, which accepts a GKGameModelPlayer
object and returns an optional array of GKGameModelUpdate
objects. Inside, conditionally bind the player
parameter into a playerObject
constant after optionally down casting it as Player
. If this is possible check if it the game is a win for either this player or its opponent, in which case return nil
(no move available). Declare an empty array of [Move]
and, for each column from 0 up to and excluding the Board’s width, if we can make a move in such a column, append a new Move
object to the moves
array. After the loop, return the moves
object, which will give you an error and about which Paul says nothing … At the end of the method return nil
.
Implement now the apply(_:)
method which accepts a single parameter of type GKGameModelUpdate
. Conditionally bind that parameter to a constant and optionally downcast it to a Move
object. Call the add(chip:inColumn:)
passing as arguments the current player’s chip and the move’s column, then switch players.
Implement the score(for:)
object, which accepts a GKGameModelPlayer
parameter and returns an Int
. Conditionally bind such parameter in a constant and optionally downcast it as a Player
. If the move (but where is it taken from in here?) is a win for the AI, return 1000, else if the move is a win for the player return -1000, otherwise return 0.
At the top of this class create two computed properties (which you get from the protocol’s stubs). players
will return Player.allPlayers
while activePlayer
will return currentPlayer
.
We still have an error in our code and we are not told about it. No, it’s not me badly copying. Everything is as in Paul’s code… When we return moves
from the gameModelUpdates(for:)
method, we are asked to downcast this result as the type expected for the return type of the method. I will leave it as an error right now and then see if the following pages talk about this…
Creating a GameplayKit
AI using GKMinmaxStrategist
.
Open ViewController.swift, import GameplayKit
and create a new implicitly unwrapped property of type GKMinMaxStrategist
called “strategist”. Here is the description of this class:
Summary
An AI that chooses moves in turn-based games using a deterministic strategy.
Declaration
Discussion
To use this strategy, you provide scores that rate possible states of your game model for their desirability to a player, and the strategist exhaustively searches all possible game model states in order to make choices that maximize the rating for its own moves and minimize the rating for an opponent’s moves. You provide information about your game model to the strategist by implementing the GKGameModel, GKGameModelPlayer, and GKGameModelUpdate protocols in your custom classes, and then use the strategist’s methods to find optimal moves.
Inside viewDidLoad()
initialise the strategist, give it a max look ahead depth of 7 (that is, make it look 7 moves in the future!) and set its random source (i.e., what to do if two moves are equally good) to nil
, that is to just return the first move.
Insert inside the resetBoard()
method the setting of board
as the strategist’s game model, just before updating the UI.
Create a new method to let the AI figure out what’s the best next move: columnForAIMove()
, which just returns an Optional Integer. If we can conditionally bind the result of the bastMove(for:)
method (optionally downcast as a Move
object) in a aiMove
constant, return its column, then outside this call, return nil
.
Create a new makeAIMove(inColumn:)
method, which takes an integer parameter, that conditionally binds the board’s next empty slot in the passed column argument to a row
integer constant, then calls add(chip:inColumn:)
on the board and the addChip(inColumn:row:color:)
method, before calling the continueGame()
method in the end.
Write the startAIMove()
method, which invokes the global dispatch queue to execute the code asynchronously, weakly captures self
, then declares a new strategistTime
constant equal to the CoreFoundation instance of the current absolute time, check that we have a column for the AI to move into, then create a delta
equal to the absolute time minus the strategist Time (why?!), then create a time ceiling of one second to generate a sensible delay (which will be yet another constant equal to this ceiling minus the delta), then — finally — dispatch the work on the main thread with a deadline of .now() + delay
and execute the makeAIMove(inColumn:)
method.
Inside updateUI()
add a check to call startAIMove()
if the board’s current player’s chip is black.
Back in the startAIMove()
method, disable all buttons at the beginning of the method using a .forEach
closure on the columnButtons
array, create a spinner grey activity indicator view and start its animation before adding it as a custom view of the left bar button item. Inside the main dispatch queue, reenable the buttons and destroy the left bar button item.
The app is officially finished but the error in Board.swift remains so we will accept the solution provided by Xcode, just changing as!
in as?
. Doing this, though, makes the project build but the AI never stops thinking… Putting the force unwrap, instead, makes the app crash at the columnForAIMove
method… I added some breakpoints to see what was happening but… by now… no real helpful things.
So … I asked help but this just doesn’t work … It either crashes if I make a force downcast or it doesn’t load the next move if I make the optional one. Again, all this time completely lost for nothing …
I really wonder how much I can go on like this, reading tutorial, not understanding what is going on and then? Just not getting any explanation back…
Anyway … enjoy the rest of your day, and thank you for reading.
If you like what I’m doing here please consider liking this article and sharing it with some of your peers. If you are feeling like being really awesome, please consider making a small donation to support my studies and my writing (please appreciate that I am not using advertisement on my articles).
If you are interested in my music engraving and my publications don’t forget visit my Facebook page and the pages where I publish my scores (Gumroad, SheetMusicPlus, ScoreExchange and on Apple Books).
You can also support me by buying Paul Hudson’s books from this Affiliate Link.
Anyways, thank you so much for reading!
Till the next one!