In the previous articles, we predominantly focused on the network aspect of the game. In this final installment, it is time to zoom in on the game itself. We will implement the game and leverage the foundation we laid in the previous articles to create a multiplayer game.
Introduction
In this article, we will discuss two topics, (1) creating the game and (2) leveraging the foundation that we created in the previous articles. In the previous article, a bug has found its way into our project. I must admit that it took me quite some time to discover this nasty, little creature. Don't worry, though. We will squash this bug as we go and I will show you exactly where it is causing havoc. Even though I could have updated the previous article to get rid of the bug, I prefer to show you how to find and fix the bug as it will help understand how the CocoaAsyncSocket library works. We have quite a bit of work ahead of us so let's get started.
1. Updating the User Interface
Let me start this article by briefly talking about the game, Four in a Row. If you haven't heard of Four in a Row, then I suggest you pay Wikipedia a visit. By the way, Four in a Row is known by many names, such as as Connect Four, Find Four, and Plot Four. The concept is simple. We have a board or grid with seven columns each containing six cells. The user can tap a column to add a disc to that column. Each time a player adds a disc to a column, we invoke a method to check if the player has won the game, that is, four discs in a row. Rows can be horizontal, vertical, or diagonal.
This implies that we need to keep track of quite a few variables. To keep track of the state of the game, we create a data structure, an array of arrays, mirroring the board or grid of cells. Each array in the array represents a column. Whenever a player adds a disc to a column, we update the data structure that backs the game and check whether the player has won the game.
I am not an expert in game development and the approach we use in this project is not the only solution to implement Four in a Row. It probably isn't the most efficient implementation either. However, by using well known Objective-C patterns and sticking to Foundation classes, most of you should be able to keep pace without much difficulty.
While exploring Four in a Row, I stumbled upon a Stack Overflow answer that outlines an algorithm for Four in a Row using bitboards. This is a very efficient and fast solution so if you are serious about board games, such as tic-tac-toe or chess, then I recommend exploring bitboards in more detail.
As I said, we will be using an array of arrays as the data structure of the game. The board itself will be a simple view with 42 subviews or board cells. Each subview or board cell corresponds to a position in the data structure. Because we need an easy way to keep a reference to each board cell, we manage a second data structure, another array of arrays, to store a reference to each board cell. This makes it easy to update the board view, but it also has some other benefits that will become evident a bit later in this tutorial.
Step 1: Adding the Board View
Let's start by creating the board view. Open MTViewController.xib, add a UIView
instance to the view controller's view and set its dimensions to 280 points by 240 points (figure 1). Modify the constraints of the view in such a way that the board view has a fixed width and height. The board view should also be horizontally and vertically centered in the view controller's view. Autolayout makes this a breeze.
Create an outlet in MTViewController.h for the board view and name it boardView
. In Interface Builder, connect the outlet to the board view. We will add the board view's subviews programmatically.
#import <UIKit/UIKit.h> @interface MTViewController : UIViewController @property (weak, nonatomic) IBOutlet UIView *boardView; @property (weak, nonatomic) IBOutlet UIButton *hostButton; @property (weak, nonatomic) IBOutlet UIButton *joinButton; @property (weak, nonatomic) IBOutlet UIButton *disconnectButton; @end
Step 2: Adding a Replay Button
When the game ends, we want to give the player the opportunity to start a new game. Add a new button to the view controller's view and give it a title of Replay (figure 2). Create an outlet, replayButton
, for the button in MTViewController.h and an action named replay:
in MTViewController.m. Connect the outlet and action to the replay button in Interface Builder (figure 2).
#import <UIKit/UIKit.h> @interface MTViewController : UIViewController @property (weak, nonatomic) IBOutlet UIView *boardView; @property (weak, nonatomic) IBOutlet UIButton *hostButton; @property (weak, nonatomic) IBOutlet UIButton *joinButton; @property (weak, nonatomic) IBOutlet UIButton *replayButton; @property (weak, nonatomic) IBOutlet UIButton *disconnectButton; @end
- (IBAction)replay:(id)sender { }
Step 3: Adding a State Label
The player of the game should be informed about the state of the game. Whose turn is it? Who has won the game? We add a label to the view controller's view and update it whenever the game state changes. Revisit MTViewController.xib and add a label (UILabel
) to the view controller's view (figure 3). Create an outlet for the label in the view controller's header file, name it gameStateLabel
, and connect it to the label in Interface Builder (figure 3).
#import <UIKit/UIKit.h> @interface MTViewController : UIViewController @property (weak, nonatomic) IBOutlet UIView *boardView; @property (weak, nonatomic) IBOutlet UIButton *hostButton; @property (weak, nonatomic) IBOutlet UIButton *joinButton; @property (weak, nonatomic) IBOutlet UIButton *replayButton; @property (weak, nonatomic) IBOutlet UIButton *disconnectButton; @property (weak, nonatomic) IBOutlet UILabel *gameStateLabel; @end
2. Laying Out the Board
Step 1: Creating the Board Cell Class
As I mentioned earlier, the board view contains 42 subviews or board cells. We will create a UIView
subclass to make each board cell a bit smarter and easier to use. Create a UIView
subclass and name it MTBoardCell
(figure 4). The MTBoardCell
class has one property, cellType
of type MTBoardCellType
, which is declared at the top of the header file.
#import <UIKit/UIKit.h> typedef enum { MTBoardCellTypeEmpty = -1, MTBoardCellTypeMine, MTBoardCellTypeYours } MTBoardCellType; @interface MTBoardCell : UIView @property (assign, nonatomic) MTBoardCellType cellType; @end
In the designated initializer, we set cellType
to MTBoardCellTypeEmpty
to mark the board cell as empty. In the implementation file of the class, we also override the setter of cellType
. In setCellType:
, we update the view by invoking updateView
, a helper method in which we update the view's background color.
#import "MTBoardCell.h" @implementation MTBoardCell #pragma mark - #pragma mark Initialization - (id)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) { // Cell Type self.cellType = MTBoardCellTypeEmpty; } return self; } #pragma mark - #pragma mark Setters & Getters - (void)setCellType:(MTBoardCellType)cellType { if (_cellType != cellType) { _cellType = cellType; // Update View [self updateView]; } } #pragma mark - #pragma mark Helper Methods - (void)updateView { // Background Color self.backgroundColor = (self.cellType == MTBoardCellTypeMine) ? [UIColor yellowColor] : (self.cellType == MTBoardCellTypeYours) ? [UIColor redColor] : [UIColor whiteColor]; } @end
Step 2: Setting Up a Game
To set up a new game, we invoke the main view controller's resetGame
method. We will invoke resetGame
in various places in our project. One of those places is the view controller's viewDidLoad
method. Because I prefer to keep the viewDidLoad
method concise, I generally move the view's setup logic to a separate setupView
helper method which is invoked in viewDidLoad
. In setupView
, we also hide all the view's subviews with the exception of the host and join button.
- (void)viewDidLoad { [super viewDidLoad]; // Setup View [self setupView]; }
- (void)setupView { // Reset Game [self resetGame]; // Configure Subviews [self.boardView setHidden:YES]; [self.replayButton setHidden:YES]; [self.disconnectButton setHidden:YES]; [self.gameStateLabel setHidden:YES]; }
Before we can implement resetGame
, we need to create the data structure that stores the game state and the data structure that stores the references to the board cells of the board view. Add a class extension at the top of MTViewController.h and create two properties, board
(NSArray
) and matrix
(NSMutableArray
). We also import the header file of MTBoardCell
and define to constants, kMTMatrixWidth
and kMTMatrixHeight
, that store the dimensions of the board.
#import "MTViewController.h" #import "MTBoardCell.h" #import "MTGameController.h" #import "MTHostGameViewController.h" #import "MTJoinGameViewController.h" #define kMTMatrixWidth 7 #define kMTMatrixHeight 6 @interface MTViewController () <MTGameControllerDelegate, MTHostGameViewControllerDelegate, MTJoinGameViewControllerDelegate> @property (strong, nonatomic) MTGameController *gameController; @property (strong, nonatomic) NSArray *board; @property (strong, nonatomic) NSMutableArray *matrix; @end
The implementation of resetGame
isn't rocket science as you can see below. Because resetGame
will also be invoked when a player taps the replay button, the implementation starts with hiding the replay button. We calculate the size of a board cell, create a mutable array for each column of the board, and add six board cells to each column. This array of arrays is stored in the class's board
property as an immutable array. The class's matrix
property is very similar. It also stores an array of arrays. The main differences are that (1) the columns contain no objects when the game is reset and (2) each column is an instance of NSMutableArray
.
- (void)resetGame { // Hide Replay Button [self.replayButton setHidden:YES]; // Helpers CGSize size = self.boardView.frame.size; CGFloat cellWidth = floorf(size.width / kMTMatrixWidth); CGFloat cellHeight = floorf(size.height / kMTMatrixHeight); NSMutableArray *buffer = [[NSMutableArray alloc] initWithCapacity:kMTMatrixWidth]; for (int i = 0; i < kMTMatrixWidth; i++) { NSMutableArray *column = [[NSMutableArray alloc] initWithCapacity:kMTMatrixHeight]; for (int j = 0; j < kMTMatrixHeight; j++) { CGRect frame = CGRectMake(i * cellWidth, (size.height - ((j + 1) * cellHeight)), cellWidth, cellHeight); MTBoardCell *cell = [[MTBoardCell alloc] initWithFrame:frame]; [cell setAutoresizingMask:(UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight)]; [self.boardView addSubview:cell]; [column addObject:cell]; } [buffer addObject:column]; } // Initialize Board self.board = [[NSArray alloc] initWithArray:buffer]; // Initialize Matrix self.matrix = [[NSMutableArray alloc] initWithCapacity:kMTMatrixWidth]; for (int i = 0; i < kMTMatrixWidth; i++) { NSMutableArray *column = [[NSMutableArray alloc] initWithCapacity:kMTMatrixHeight]; [self.matrix addObject:column]; } }
3. Adding Interaction
Step 1: Adding a Gesture Recognizer
Adding interaction to the game is as simple as adding a tap gesture recognizer to the board view in the view controller's setupView
method. Each time a player taps the board view, the addDiscToColumn:
message is sent to our MTViewController
instance.
- (void)setupView { // Reset Game [self resetGame]; // Configure Subviews [self.boardView setHidden:YES]; [self.replayButton setHidden:YES]; [self.disconnectButton setHidden:YES]; [self.gameStateLabel setHidden:YES]; // Add Tap Gesture Recognizer UITapGestureRecognizer *tgr = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(addDiscToColumn:)]; [self.boardView addGestureRecognizer:tgr]; }
Before we implement addDiscToColumn:
, we need to take a detour and talk about the game state. The MTViewController
class needs to keep track of the state of the game. By game state, I don't refer to the data structures (board
and matrix
) that we created earlier. I simply mean a property that keeps tracks of whose turn it is and whether a player has won the game. To make things easier, it is a good idea to declare a custom type for the game state. Because we will use this custom type in various places in our project, it is best to declare it in a separate file, MTConstants.h, and add an import statement for MTConstants.h to the project's precompiled header file.
Create a new NSObject
subclass named MTConstants
(figure 5), delete the implementation file (MTConstants.m), and clear the contents of MTConstants.h. In MTConstants.h, we define MTGameState
as shown below.
typedef enum { MTGameStateUnknown = -1, MTGameStateMyTurn, MTGameStateYourTurn, MTGameStateIWin, MTGameStateYouWin } MTGameState;
Add an import statement for MTConstants.h to the project's precompiled header file so that its contents are available throughout the project.
#import <Availability.h> #ifndef __IPHONE_4_0 #warning "This project uses features only available in iOS SDK 4.0 and later." #endif #ifdef __OBJC__ #import <UIKit/UIKit.h> #import <Foundation/Foundation.h> #import "GCDAsyncSocket.h" #import "MTConstants.h" #endif
In MTConstants.h
, we declare the various states of the game. In a more complex game, this might not be the best strategy or you may need to add additional states. For this project, this approach will suffice. Because Four in a Row is a turn based game, most of the game is spent in the MTGameStateMyTurn
and MTGameStateYourTurn
states, that is, it is either your turn or your opponent's turn to add a disc to the board. The last two states are used when the game has ended with one of the players as the winner of the game.
With MTGameState
defined in MTConstants.h
, it is time to declare the gameState
property in the MTViewController
class extension that we created earlier. As you might have guessed, the gameState
property is of type MTGameState
.
#import "MTViewController.h" #import "MTBoardCell.h" #import "MTGameController.h" #import "MTHostGameViewController.h" #import "MTJoinGameViewController.h" #define kMTMatrixWidth 7 #define kMTMatrixHeight 6 @interface MTViewController () <MTGameControllerDelegate, MTHostGameViewControllerDelegate, MTJoinGameViewControllerDelegate> @property (assign, nonatomic) MTGameState gameState; @property (strong, nonatomic) MTGameController *gameController; @property (strong, nonatomic) NSArray *board; @property (strong, nonatomic) NSMutableArray *matrix; @end
It is time to implement the addDiscToColumn:
method. The implementation of addDiscToColumn:
shown below is incomplete as you can see by the comments in its implementation. We will complete its implementation as we go. The main element to focus on at this point is the method's flow. We start by checking if the game has already been won by one of the players. If it has, then there is no need to add any more discs to the board. The second check we make is whether the player can add a disc, that is, is it the player's turn to add a disc to the board. If this isn't the case, then we show an alert view informing the player that it's not their turn.
The interesting part of addDiscToColumn:
is what happens if the game hasn't ended and the player is allowed to add a disc to the board. We calculate which column the player has tapped by invoking columnForPoint:
and pass the location in the board view that the player has tapped. The column
variable is then passed as an argument to addDiscToColumn:withType:
. The second parameter of this method is the cell type, which is MTBoardCellTypeMine
in this case.
- (void)addDiscToColumn:(UITapGestureRecognizer *)tgr { if (self.gameState >= MTGameStateIWin) { // Notify Players } else if (self.gameState != MTGameStateMyTurn) { NSString *message = NSLocalizedString(@"It's not your turn.", nil); UIAlertView *alertView = [[UIAlertView alloc] initWithTitle:@"Warning" message:message delegate:nil cancelButtonTitle:NSLocalizedString(@"OK", nil) otherButtonTitles:nil]; [alertView show]; } else { NSInteger column = [self columnForPoint:[tgr locationInView:tgr.view]]; [self addDiscToColumn:column withType:MTBoardCellTypeMine]; // Update Game State // Send Packet // Notify Players if Someone Has Won the Game } }
The columnForPoint:
method is nothing more than a simple calculation to infer the column based on the coordinates of point
.
- (NSInteger)columnForPoint:(CGPoint)point { return floorf(point.x / floorf(self.boardView.frame.size.width / kMTMatrixWidth)); }
In addDiscToColumn:withType:
, we update the game state by updating the view controller's matrix
property. We then fetch a reference to the corresponding board cell, stored in the view controller's board
property, and set its cell type to cellType
. Because we overrode the setCellType:
method in MTBoardCell
, the board cell's background color will be updated automatically.
- (void)addDiscToColumn:(NSInteger)column withType:(MTBoardCellType)cellType { // Update Matrix NSMutableArray *columnArray = [self.matrix objectAtIndex:column]; [columnArray addObject:@(cellType)]; // Update Cells MTBoardCell *cell = [[self.board objectAtIndex:column] objectAtIndex:([columnArray count] - 1)]; [cell setCellType:cellType]; }
Before testing the game, we need to amend the startGameWithSocket:
and endGame
methods. In these methods, we update the view controller's view based on the state of the game. Run two instances of the application and test the game in its current state.
- (void)startGameWithSocket:(GCDAsyncSocket *)socket { // Initialize Game Controller self.gameController = [[MTGameController alloc] initWithSocket:socket]; // Configure Game Controller [self.gameController setDelegate:self]; // Hide/Show Buttons [self.boardView setHidden:NO]; [self.hostButton setHidden:YES]; [self.joinButton setHidden:YES]; [self.disconnectButton setHidden:NO]; [self.gameStateLabel setHidden:NO]; }
- (void)endGame { // Clean Up [self.gameController setDelegate:nil]; [self setGameController:nil]; // Hide/Show Buttons [self.boardView setHidden:YES]; [self.hostButton setHidden:NO]; [self.joinButton setHidden:NO]; [self.disconnectButton setHidden:YES]; [self.gameStateLabel setHidden:YES]; }
4. Improving Interaction
At the moment, there are no limits to the number of discs a player can add and the actions of player A are not visible to player B, and vice versa. Let's fix that.
Step 1: Limiting Interaction
To limit interaction, we need to update the view controller's gameState
property at the appropriate time. The interaction with the board is already limited by the value of gameState
in addDiscToColumn:
, but this isn't very useful if we don't update the gameState
property.
First of all, we need to decide who's turn it is when a new game starts. We could do something fancy like a coin toss, but let's keep it simple and let the player hosting the game make the first move. This is easy enough. We simply update the gameState
property in the controller:didHostGameOnSocket:
and controller:didJoinGameOnSocket:
delegate methods. The result is that only the player hosting the game can add a disc to the board.
- (void)controller:(MTHostGameViewController *)controller didHostGameOnSocket:(GCDAsyncSocket *)socket { NSLog(@"%s", __PRETTY_FUNCTION__); // Update Game State [self setGameState:MTGameStateMyTurn]; // Start Game with Socket [self startGameWithSocket:socket]; }
- (void)controller:(MTJoinGameViewController *)controller didJoinGameOnSocket:(GCDAsyncSocket *)socket { NSLog(@"%s", __PRETTY_FUNCTION__); // Update Game State [self setGameState:MTGameStateYourTurn]; // Start Game with Socket [self startGameWithSocket:socket]; }
The second change we need to make is update the game state whenever the player makes a valid move. We do this in addDiscToColumn:
as shown below. Each time a player adds a disc to the board, the game state is set to MTGameStateYourTurn
, which means that the player cannot add any more discs to the board as long as the game state isn't updated. Before we continue, test the application one more time to see the result of our changes.
- (void)addDiscToColumn:(UITapGestureRecognizer *)tgr { if (self.gameState >= MTGameStateIWin) { // Notify Players } else if (self.gameState != MTGameStateMyTurn) { NSString *message = NSLocalizedString(@"It's not your turn.", nil); UIAlertView *alertView = [[UIAlertView alloc] initWithTitle:@"Warning" message:message delegate:nil cancelButtonTitle:NSLocalizedString(@"OK", nil) otherButtonTitles:nil]; [alertView show]; } else { NSInteger column = [self columnForPoint:[tgr locationInView:tgr.view]]; [self addDiscToColumn:column withType:MTBoardCellTypeMine]; // Update Game State [self setGameState:MTGameStateYourTurn]; // Send Packet // Notify Players if Someone Has Won the Game } }
Step 2: Sending Updates
Even though we establish a connection when a new game is started, thus far, we haven't done much with that connection. The class that is in charge of the connection is MTGameController
, which we created in the previous article. Open MTGameController.h and declare an instance method named addDiscToColumn:
. The view controller will invoke this method to inform the game controller that the other player needs to be updated about the changed game state. This is also a good moment to expand the MTGameControllerDelegate
protocol. When the game controller receives an update, it needs to notify its delegate, the main view controller, about the update because the main view controller is in charge of updating the board view. Take a look at the updated header file of the MTGameController
class.
#import <Foundation/Foundation.h> @class GCDAsyncSocket; @protocol MTGameControllerDelegate; @interface MTGameController : NSObject @property (weak, nonatomic) id<MTGameControllerDelegate> delegate; #pragma mark - #pragma mark Initialization - (id)initWithSocket:(GCDAsyncSocket *)socket; #pragma mark - #pragma mark Public Instance Methods - (void)addDiscToColumn:(NSInteger)column; @end @protocol MTGameControllerDelegate <NSObject> - (void)controller:(MTGameController *)controller didAddDiscToColumn:(NSInteger)column; - (void)controllerDidDisconnect:(MTGameController *)controller; @end
The addDiscToColumn:
method is very easy to implement thanks to the groundwork we did in the previous articles. I have updated the header file of the MTPacket
class by adding MTPacketTypeDidAddDisc
to the enumeration of packet types. Even though we declared the action
property in the MTPacket
class, we won't be needing it in this project.
- (void)addDiscToColumn:(NSInteger)column { // Send Packet NSDictionary *load = @{ @"column" : @(column) }; MTPacket *packet = [[MTPacket alloc] initWithData:load type:MTPacketTypeDidAddDisc action:0]; [self sendPacket:packet]; }
typedef enum { MTPacketTypeUnknown = -1, MTPacketTypeDidAddDisc } MTPacketType;
The parseBody:
method also needs to be updated. In its current implementation, all we do is log the packet's data to the console. In the updated implementation, we check the packet's type and notify the delegate that the opponent added a disc to a column if the packet's type is equal to MTPacketTypeDidAddDisc
.
- (void)parseBody:(NSData *)data { NSKeyedUnarchiver *unarchiver = [[NSKeyedUnarchiver alloc] initForReadingWithData:data]; MTPacket *packet = [unarchiver decodeObjectForKey:@"packet"]; [unarchiver finishDecoding]; /* NSLog(@"Packet Data > %@", packet.data); NSLog(@"Packet Type > %i", packet.type); NSLog(@"Packet Action > %i", packet.action); */ if ([packet type] == MTPacketTypeDidAddDisc) { NSNumber *column = [(NSDictionary *)[packet data] objectForKey:@"column"]; if (column) { // Notify Delegate [self.delegate controller:self didAddDiscToColumn:[column integerValue]]; } } }
Implement the new delegate method of the MTGameControllerDelegate
protocol in the MTViewController
class as shown below. We invoke addDiscToColumn:withType:
and pass the column and cell type (MTBoardCellTypeYours
). The view controller's gameState
property is also updated to ensure that the player can add a new disc to the board.
- (void)controller:(MTGameController *)controller didAddDiscToColumn:(NSInteger)column { // Update Game [self addDiscToColumn:column withType:MTBoardCellTypeYours]; // Update State [self setGameState:MTGameStateMyTurn]; }
Last but not least, we need to invoke the addDiscToColumn:
method of the MTGameController
class in the view controller's addDiscToColumn:
method. This is the last piece of the puzzle.
- (void)addDiscToColumn:(UITapGestureRecognizer *)tgr { if (self.gameState >= MTGameStateIWin) { // Notify Players } else if (self.gameState != MTGameStateMyTurn) { NSString *message = NSLocalizedString(@"It's not your turn.", nil); UIAlertView *alertView = [[UIAlertView alloc] initWithTitle:@"Warning" message:message delegate:nil cancelButtonTitle:NSLocalizedString(@"OK", nil) otherButtonTitles:nil]; [alertView show]; } else { NSInteger column = [self columnForPoint:[tgr locationInView:tgr.view]]; [self addDiscToColumn:column withType:MTBoardCellTypeMine]; // Update Game State [self setGameState:MTGameStateYourTurn]; // Send Packet [self.gameController addDiscToColumn:column]; // Notify Players if Someone Has Won the Game } }
Run two instances of the application and test the game one more time. Did you run into a problem? It is time to squash that bug that I told you about earlier in this article. The bug is located in the MTJoinGameViewController
class. In the socket:didConnectToHost:port:
method of the GCDAsyncSocketDelegate
protocol, we notify the delegate of the MTJoinGameViewController
class and pass it a reference to the socket. We stop browsing for new services and dismiss the join game view controller.
By dismissing the join game view controller, we implicitly get rid of the join game view controller as it is no longer needed. This means that the class's dealloc
method is invoked when the object is released. The current implementation of the dealloc
method is shown below.
- (void)dealloc { if (_delegate) { _delegate = nil; } if (_socket) { [_socket setDelegate:nil delegateQueue:NULL]; _socket = nil; } }
In the dealloc
method of the MTJoinGameViewController
class, we clean everything up. However, because this socket is managed by the game controller, we shouldn't set the delegate to nil
and neither should we set the delegate queue to NULL
. The game controller is instantiated before the dealloc
method is invoked, which means that the delegate of the game controller's socket is (re)set to nil
when the join game view controller is deallocated. In other words, even though the game controller has a reference to the socket, the socket's delegate is set to nil
and this renders the socket unusable to us. The solution is as simple as removing the last few lines of the dealloc
method in which we set the socket's delegate to nil
and the socket's delegate queue to NULL
. Run the application one more time to see if we have successfully fixed that nasty bug.
- (void)dealloc { if (_delegate) { _delegate = nil; } }
5. Winning the Game
In its current state, it is not possible to win a game because we haven't implemented an algorithm that checks if one of the players has four of its own discs in a row. I have created a hasPlayerOfTypeWon:
method for this purpose. It takes one argument of type MTPlayerType
and checks the board if the player of the passed type has won the game. The MTPlayerType
type is defined in MTConstants.h
. Even though we could pass 0
for player A and 1
for player B, our code becomes much more readable (and maintainable) by declaring a custom type.
typedef enum { MTPlayerTypeMe = 0, MTPlayerTypeYou } MTPlayerType;
As you might expect, hasPlayerOfTypeWon:
returns a boolean value. I won't discuss its implementation in detail because it is quite lengthy and not that difficult. The gist of it is that we check all possible winning combinations. It searches for horizontal, vertical, and diagonal matches. This is certainly not the best way to check for matches, but it is a method that I am sure most of you can understand without much difficulty. At the end of the hasPlayerOfTypeWon:
method, we also update the view controller's gameState
property if appropriate.
- (BOOL)hasPlayerOfTypeWon:(MTPlayerType)playerType { BOOL _hasWon = NO; NSInteger _counter = 0; MTBoardCellType targetType = playerType == MTPlayerTypeMe ? MTBoardCellTypeMine : MTBoardCellTypeYours; // Check Vertical Matches for (NSArray *line in self.board) { _counter = 0; for (MTBoardCell *cell in line) { _counter = (cell.cellType == targetType) ? _counter + 1 : 0; _hasWon = (_counter > 3) ? YES : _hasWon; if (_hasWon) break; } if (_hasWon) break; } if (!_hasWon) { // Check Horizontal Matches for (int i = 0; i < kMTMatrixHeight; i++) { _counter = 0; for (int j = 0; j < kMTMatrixWidth; j++) { MTBoardCell *cell = [(NSArray *)[self.board objectAtIndex:j] objectAtIndex:i]; _counter = (cell.cellType == targetType) ? _counter + 1 : 0; _hasWon = (_counter > 3) ? YES : _hasWon; if (_hasWon) break; } if (_hasWon) break; } } if (!_hasWon) { // Check Diagonal Matches - First Pass for (int i = 0; i < kMTMatrixWidth; i++) { _counter = 0; // Forward for (int j = i, row = 0; j < kMTMatrixWidth && row < kMTMatrixHeight; j++, row++) { MTBoardCell *cell = [(NSArray *)[self.board objectAtIndex:j] objectAtIndex:row]; _counter = (cell.cellType == targetType) ? _counter + 1 : 0; _hasWon = (_counter > 3) ? YES : _hasWon; if (_hasWon) break; } if (_hasWon) break; _counter = 0; // Backward for (int j = i, row = 0; j >= 0 && row < kMTMatrixHeight; j--, row++) { MTBoardCell *cell = [(NSArray *)[self.board objectAtIndex:j] objectAtIndex:row]; _counter = (cell.cellType == targetType) ? _counter + 1 : 0; _hasWon = (_counter > 3) ? YES : _hasWon; if (_hasWon) break; } if (_hasWon) break; } } if (!_hasWon) { // Check Diagonal Matches - Second Pass for (int i = 0; i < kMTMatrixWidth; i++) { _counter = 0; // Forward for (int j = i, row = (kMTMatrixHeight - 1); j < kMTMatrixWidth && row >= 0; j++, row--) { MTBoardCell *cell = [(NSArray *)[self.board objectAtIndex:j] objectAtIndex:row]; _counter = (cell.cellType == targetType) ? _counter + 1 : 0; _hasWon = (_counter > 3) ? YES : _hasWon; if (_hasWon) break; } if (_hasWon) break; _counter = 0; // Backward for (int j = i, row = (kMTMatrixHeight - 1); j >= 0 && row >= 0; j--, row--) { MTBoardCell *cell = [(NSArray *)[self.board objectAtIndex:j] objectAtIndex:row]; _counter = (cell.cellType == targetType) ? _counter + 1 : 0; _hasWon = (_counter > 3) ? YES : _hasWon; if (_hasWon) break; } if (_hasWon) break; } } // Update Game State if (_hasWon) { self.gameState = (playerType == MTPlayerTypeMe) ? MTGameStateIWin : MTGameStateYouWin; } return _hasWon; }
The hasPlayerOfTypeWon:
method is invoked in two places in the MTViewController
class. The first place is in the addDiscToColumn:
method. After the player has added a disc to the board, we check if the player has won the game by passing MTPlayerMe
as the argument of hasPlayerOfTypeWon:
.
- (void)addDiscToColumn:(UITapGestureRecognizer *)tgr { if (self.gameState >= MTGameStateIWin) { // Notify Players [self showWinner]; } else if (self.gameState != MTGameStateMyTurn) { NSString *message = NSLocalizedString(@"It's not your turn.", nil); UIAlertView *alertView = [[UIAlertView alloc] initWithTitle:@"Warning" message:message delegate:nil cancelButtonTitle:NSLocalizedString(@"OK", nil) otherButtonTitles:nil]; [alertView show]; } else { NSInteger column = [self columnForPoint:[tgr locationInView:tgr.view]]; [self addDiscToColumn:column withType:MTBoardCellTypeMine]; // Update Game State [self setGameState:MTGameStateYourTurn]; // Send Packet [self.gameController addDiscToColumn:column]; // Notify Players if Someone Has Won the Game if ([self hasPlayerOfTypeWon:MTPlayerTypeMe]) { // Show Winner [self showWinner]; } } }
If the player did win the game, we invoke showWinner
, which we will implement shortly. Notice that we also invoke the showWinner
method at the beginning of the addDiscToColumn:
method if the user taps the board view when the game has already ended.
The hasPlayerOfTypeWon:
method is also invoked in the controller:didAddDiscToColumn:
method of the MTGameControllerDelegate
protocol. Take a look at its updated implementation below. If the player's opponent has won the game, we also invoke the showWinner
method.
- (void)controller:(MTGameController *)controller didAddDiscToColumn:(NSInteger)column { // Update Game [self addDiscToColumn:column withType:MTBoardCellTypeYours]; if ([self hasPlayerOfTypeWon:MTPlayerTypeYou]) { // Show Winner [self showWinner]; } else { // Update State [self setGameState:MTGameStateMyTurn]; } }
In the showWinner
method, we update the view by displaying the replay button and showing an alert view that tells the player about the winner of the game.
- (void)showWinner { if (self.gameState < MTGameStateIWin) return; // Show Replay Button [self.replayButton setHidden:NO]; NSString *message = nil; if (self.gameState == MTGameStateIWin) { message = NSLocalizedString(@"You have won the game.", nil); } else if (self.gameState == MTGameStateYouWin) { message = NSLocalizedString(@"Your opponent has won the game.", nil); } // Show Alert View UIAlertView *alertView = [[UIAlertView alloc] initWithTitle:@"We Have a Winner" message:message delegate:self cancelButtonTitle:NSLocalizedString(@"OK", nil) otherButtonTitles:nil]; [alertView show]; }
6. Filling the Gaps
There are two pieces of functionality that I'd like to add before wrapping up this project, (1) updating the game state label whenever the game state changes and (2) enabling the replay button. Both are easy to implement.
Step 1: Updating the Game State Label
To update the game state label, we need to update the view whenever the gameState
property changes. We could use KVO (Key Value Observing) for this, but I prefer to simply override the setter of the gameState
property. Whenever the value of _gameState
changes, we invoke updateView
, another helper method.
- (void)setGameState:(MTGameState)gameState { if (_gameState != gameState) { _gameState = gameState; // Update View [self updateView]; } }
The updateView
method is, just like setupView
, a helper method. In updateView
, we update the text
property of gameStateLabel
.
- (void)updateView { // Update Game State Label switch (self.gameState) { case MTGameStateMyTurn: { self.gameStateLabel.text = NSLocalizedString(@"It is your turn.", nil); break; } case MTGameStateYourTurn: { self.gameStateLabel.text = NSLocalizedString(@"It is your opponent's turn.", nil); break; } case MTGameStateIWin: { self.gameStateLabel.text = NSLocalizedString(@"You have won.", nil); break; } case MTGameStateYouWin: { self.gameStateLabel.text = NSLocalizedString(@"Your opponent has won.", nil); break; } default: { self.gameStateLabel.text = nil; break; } } }
Step 2: Enabling the Replay Button
To enable the replay button, we should start by implementing the replay:
action. This action is invoked when the player taps the replay button, which appears when the game ends. We do three things in replay:
, (1) invoke resetGame
to reset the game, (2) update the game state to MTGameStateMyTurn
, and send the game controller a message of startNewGame
. This means that the player initiating the new game can make the first move.
- (IBAction)replay:(id)sender { // Reset Game [self resetGame]; // Update Game State self.gameState = MTGameStateMyTurn; // Notify Opponent of New Game [self.gameController startNewGame]; }
We need to implement the startNewGame
method on the MTGameController
class and extend the MTGameControllerDelegate
protocol. Open the header file of the MTGameController
class and declare the startNewGame
method and the new delegate method of the MTGameControllerDelegate
protocol.
#import <Foundation/Foundation.h> @class GCDAsyncSocket; @protocol MTGameControllerDelegate; @interface MTGameController : NSObject @property (weak, nonatomic) id<MTGameControllerDelegate> delegate; #pragma mark - #pragma mark Initialization - (id)initWithSocket:(GCDAsyncSocket *)socket; #pragma mark - #pragma mark Public Instance Methods - (void)startNewGame; - (void)addDiscToColumn:(NSInteger)column; @end @protocol MTGameControllerDelegate <NSObject> - (void)controller:(MTGameController *)controller didAddDiscToColumn:(NSInteger)column; - (void)controllerDidStartNewGame:(MTGameController *)controller; - (void)controllerDidDisconnect:(MTGameController *)controller; @end
Again, thanks to the foundation we laid in the previous article, the startNewGame
method is short and simple. To make all this work, we need to revisit the MTPacket
class and update the MTPacketType
enumeration.
- (void)startNewGame { // Send Packet NSDictionary *load = nil; MTPacket *packet = [[MTPacket alloc] initWithData:load type:MTPacketTypeStartNewGame action:0]; [self sendPacket:packet]; }
typedef enum { MTPacketTypeUnknown = -1, MTPacketTypeDidAddDisc, MTPacketTypeStartNewGame } MTPacketType;
In the parseBody:
method of the MTGameController
class, we send the delegate a message of controllerDidStartNewGame:
if the packet's type is equal to MTPacketTypeStartNewGame
.
- (void)parseBody:(NSData *)data { NSKeyedUnarchiver *unarchiver = [[NSKeyedUnarchiver alloc] initForReadingWithData:data]; MTPacket *packet = [unarchiver decodeObjectForKey:@"packet"]; [unarchiver finishDecoding]; /* NSLog(@"Packet Data > %@", packet.data); NSLog(@"Packet Type > %i", packet.type); NSLog(@"Packet Action > %i", packet.action); */ if ([packet type] == MTPacketTypeDidAddDisc) { NSNumber *column = [(NSDictionary *)[packet data] objectForKey:@"column"]; if (column) { // Notify Delegate [self.delegate controller:self didAddDiscToColumn:[column integerValue]]; } } else if ([packet type] == MTPacketTypeStartNewGame) { // Notify Delegate [self.delegate controllerDidStartNewGame:self]; } }
The last bit of work that we need to do is implementing the controllerDidStartNewGame:
delegate method in the MTViewController
class. We invoke resetGame
, as we did in the replay:
action, and update the gameState
property.
- (void)controllerDidStartNewGame:(MTGameController *)controller { // Reset Game [self resetGame]; // Update Game State self.gameState = MTGameStateYourTurn; }
Run two instances of the application and play the game with a friend to see if everything works as it should.
Conclusion
Even though we now have a playable game, I think you agree that it still needs some tweaking and polishing. The design is pretty basic and a few animations would be nice too. However, the goal of this project has been achieved, creating a multiplayer game using Bonjour and the CocoaAsyncSocket library. You should now have a basic understanding of Bonjour and the CocoaAsyncSocket library and know what each can do for you.
Comments