How To Make a Simple Playing Card Game with Multiplayer and Bluetooth, Part 4

This is a post by iOS Tutorial Team member Matthijs Hollemans, an experienced iOS developer and designer. You can find him on Google+ and Twitter. Welcome back to our monster 7-part tutorial series on creating a multiplayer card game over Bluetooth or Wi-Fi using UIKit! If you are new to this series, check out the […] By Matthijs Hollemans.

Leave a rating/review
Save for later
Share
Create a multiplayer networked card game!

Create a multiplayer networked card game!

This is a post by iOS Tutorial Team member Matthijs Hollemans, an experienced iOS developer and designer. You can find him on and Twitter.

Welcome back to our monster 7-part tutorial series on creating a multiplayer card game over Bluetooth or Wi-Fi using UIKit!

If you are new to this series, check out the introduction first. There you can see a video of the game, and we’ll invite you to our special Reader’s Challenge!

In the first, second, and third parts of the series, you created a basic shell of the game, got the clients and server connected, and added the ability to send packets back and forth.

In this fourth part of the series, you will implement a game handshake, allowing the clients and servers to exchange information like the names of the connected players. In addition, you’ll start creating the main gameplay screen and start laying out the labels.

Synchronizing the Clients

In the previous part of the series you made the server and the client send packets to each other over a Bluetooth or Wi-Fi network. The server sent a “sign-in request” to each client, and the clients replied with a “sign-in response” packet that contained the player’s name.

But the server can’t start the game until all the clients have signed in. You need a way to keep track of this.

One way is to keep a counter. You’d initialize it with the number of connected clients, and every time a client sends the “sign-in response” message, you’d decrement the counter. Once the counter reaches 0, all clients have signed in.

Simple enough, but there is one problem: what if a single client sends a message twice? That shouldn’t happen during normal usage, but as a general rule, with networked programming, you can’t trust what happens at the other end.

It would be better to keep track of which peers have sent the server a response. You already have an object that represents each client – Player – so you’ll simply add a BOOL to the Player class for this sort of bookkeeping. Add the following property to Player.h:

@property (nonatomic, assign) BOOL receivedResponse;

And synthesize it in Player.m:

@synthesize receivedResponse = _receivedResponse;

You’ll set this variable to NO every time the server sends a message to the client, and set it to YES whenever the server receives a response from the corresponding client. When the flags for all the Players are YES, the server knows it is ready to continue.

In Game.m, add the following lines to the top of sendPacketToAllClients:

- (void)sendPacketToAllClients:(Packet *)packet
{
	[_players enumerateKeysAndObjectsUsingBlock:^(id key, Player *obj, BOOL *stop)
	{
		obj.receivedResponse = [_session.peerID isEqualToString:obj.peerID];
	}];

	. . .
}

This loops through all Players from the _players dictionary and sets their receivedResponse property to NO, except for the player that belongs to the server (identified by _session.peerID), because you need no confirmation from that one.

At the other end of things, you set the property to YES after having received a message from that Player:

- (void)receiveData:(NSData *)data fromPeer:(NSString *)peerID inSession:(GKSession *)session context:(void *)context
{
	#ifdef DEBUG
	NSLog(@"Game: receive data from peer: %@, data: %@, length: %d", peerID, data, [data length]);
	#endif
	
	Packet *packet = [Packet packetWithData:data];
	if (packet == nil)
	{
		NSLog(@"Invalid packet: %@", data);
		return;
	}

	Player *player = [self playerWithPeerID:peerID];
	if (player != nil)
	{
		player.receivedResponse = YES;  // this is the new bit
	}

	if (self.isServer)
		[self serverReceivedPacket:packet fromPlayer:player];
	else
		[self clientReceivedPacket:packet];
}

In serverReceivedPacket:fromPlayer:, you check whether you’ve received “sign-in responses” from all clients, and if so, continue to the next game state:

		case PacketTypeSignInResponse:
			if (_state == GameStateWaitingForSignIn)
			{
				player.name = ((PacketSignInResponse *)packet).playerName;

				if ([self receivedResponsesFromAllPlayers])
				{
					_state = GameStateWaitingForReady;

					NSLog(@"all clients have signed in");
				}
			}
			break;

receivedResponsesFromAllPlayers simply checks whether the receivedResponse boolean on all Player objects is YES. Add this method to the Game class:

- (BOOL)receivedResponsesFromAllPlayers
{
	for (NSString *peerID in _players)
	{
		Player *player = [self playerWithPeerID:peerID];
		if (!player.receivedResponse)
			return NO;
	}
	return YES;
}

Now when you run the app, the server should say “all clients have signed in” after it has received the responses from the clients.

The Game State Machine

You’ve seen in a previous installment of this tutorial that the MatchmakingServer and MatchmakingClient both use a state machine to keep track of what they’re doing (accepting new connections, discovering servers, etc). These state machines are very basic, with only a few states.

The Game class also has a state machine and it’s a bit more complicated, although still nothing to lose sleep over. The game states also vary slightly between server and client. This is the state diagram for the server:

State diagram for the server game

This diagram roughly describes all the things you’ll be building in the rest of this tutorial, including the packets that the server will send to the clients. The state machine for the clients looks like this:

State diagram for the client game

At the moment you’ve only implemented the WaitingForSignIn state. As you can see from the above diagrams, after signing in, the app goes into the “waiting for ready” state, which is what you’ll implement in the next section. You may want to keep referring to these diagrams as you move through the rest of the tutorial.

The Waiting-for-Ready State

So far, the server has sent a message to each of the clients asking them to sign in and send a player name back to the server. Now that the server has received the answers from all the clients, the game can start in earnest. The server now knows who everyone is, but the clients themselves don’t know anything about the other players yet.

That’s what the “waiting for ready” state is for: the server will send a message to each client to tell them about the other players. Each client will use this message to make a dictionary of Player objects, so that it has the same information as the server. (Remember, until now only the server has had a dictionary of Player objects.)

The handshake before the game starts

This process is often called a handshake, where the different parties involved need to go through a couple of steps to agree on what is happening. You’ve done the first part – sending and receiving the “sign-in” packets – now you’ll do the second part, where the server sends the “I’m ready, here are all the players” packet to the clients.

The new packet type is PacketTypeServerReady, and this gets its own Packet subclass. Add a new Objective-C class to the project, named PacketServerReady, subclass of Packet. Replace the new .h file with:

#import "Packet.h"

@interface PacketServerReady : Packet

@property (nonatomic, strong) NSMutableDictionary *players;

+ (id)packetWithPlayers:(NSMutableDictionary *)players;

@end

And replace the .m file with:

#import "PacketServerReady.h"
#import "NSData+SnapAdditions.h"
#import "Player.h"

@implementation PacketServerReady

@synthesize players = _players;

+ (id)packetWithPlayers:(NSMutableDictionary *)players
{
	return [[[self class] alloc] initWithPlayers:players];
}

- (id)initWithPlayers:(NSMutableDictionary *)players
{
	if ((self = [super initWithType:PacketTypeServerReady]))
	{
		self.players = players;
	}
	return self;
}

- (void)addPayloadToData:(NSMutableData *)data
{
	[data rw_appendInt8:[self.players count]];

	[self.players enumerateKeysAndObjectsUsingBlock:^(id key, Player *player, BOOL *stop)
	{
		[data rw_appendString:player.peerID];
		[data rw_appendString:player.name];
		[data rw_appendInt8:player.position];
	}];
}

@end

When you create the PacketServerReady object, you pass it the dictionary of Player objects. addPayloadToData: loops through this dictionary and for each Player, it appends to the mutable data the peer ID (a string), the player’s name (also a string), and the player’s position (an 8-bit integer, which is big enough to fit all the possible values from the PlayerPosition enum).

In Game.m, add an import for this new class:

#import "PacketServerReady.h"

Then in serverReceivedPacket:fromPlayer:, change the case-statement to:

		case PacketTypeSignInResponse:
			if (_state == GameStateWaitingForSignIn)
			{
				player.name = ((PacketSignInResponse *)packet).playerName;

				if ([self receivedResponsesFromAllPlayers])
				{
					_state = GameStateWaitingForReady;

					Packet *packet = [PacketServerReady packetWithPlayers:_players];
					[self sendPacketToAllClients:packet];
				}
			}
			break;

This is mostly the same as before, except now you send that new PacketServerReady message to all clients, after they’ve all signed in. Run the app and check the debug output on the clients. Testing with two devices and the simulator, both of my clients received the following packet:

Game: receive data from peer: 1371535819, data: <534e4150 00000000 00660331 32393632 38353131 37004d61 74746869 6a732069 50616420 32000231 33373135 33353831 3900636f 6d2e686f 6c6c616e 63652e53 6e617033 35363234 32393639 2e313034 32363100 00313339 32313930 36393000 4d617474 68696a73 2069506f 640001>, length: 111

(The clients also complain about this being an invalid packet, but that’s because you haven’t yet built in the support for this packet type.)

In Hex Fiend, this looks like:

ServerReady packet in Hex Fiend

The first 10 bytes are as usual (SNAP, the so-far-unused packet number field, and the packet type, which is now 0x66). The next byte, highlighted in the screenshot, is the number of players that have signed on to the game. The information for each of the players follows.

For the first player in this list, the information is comprised of “1296285117” (its peer ID), followed by a NUL-byte to terminate the string, then “Matthijs iPad 2,” also followed by a NUL-byte, and then an 8-bit number with the value 0x02, which is this player’s position, PlayerPositionRight. You should be able to decode the bytes for the other two players yourself.

It’s time to build-in the client side of things to support this new Packet. First of all, you have to add a case-statement to packetWithData:. Open Packet.m and add an import to the top:

#import "PacketServerReady.h"

Then, inside packetWithData:, add a new case-statement:

		case PacketTypeServerReady:
			packet = [PacketServerReady packetWithData:data];
			break;

Of course, you still need to override the packetWithData: method in the subclass, so open PacketServerReady.m and add the following:

+ (id)packetWithData:(NSData *)data
{
	NSMutableDictionary *players = [NSMutableDictionary dictionaryWithCapacity:4];

	size_t offset = PACKET_HEADER_SIZE;
	size_t count;

	int numberOfPlayers = [data rw_int8AtOffset:offset];
	offset += 1;

	for (int t = 0; t < numberOfPlayers; ++t)
	{
		NSString *peerID = [data rw_stringAtOffset:offset bytesRead:&count];
		offset += count;

		NSString *name = [data rw_stringAtOffset:offset bytesRead:&count];
		offset += count;

		PlayerPosition position = [data rw_int8AtOffset:offset];
		offset += 1;

		Player *player = [[Player alloc] init];
		player.peerID = peerID;
		player.name = name;
		player.position = position;
		[players setObject:player forKey:player.peerID];
	}

	return [[self class] packetWithPlayers:players];
}

Remember how on the server, the dictionary of Player objects was created in the startServerGameWithSession:playerName:clients: method? Well, on the client, the Player objects are created right here, using the information the server sent us.

For each player, you read the peer ID, player name, and position from the NSData object, use these to create a new Player object, and add the new Player to a dictionary using the peer ID as the key. The client will now have the same Player objects as the server.

In Game.m, add the following case-statement to clientReceivedPacket:

		case PacketTypeServerReady:
			if (_state == GameStateWaitingForReady)
			{
				_players = ((PacketServerReady *)packet).players;
				
				NSLog(@"the players are: %@", _players);
			}
			break;

Now run the game again. The client should print out its new _players dictionary:

Snap[327:707] the players are: {
    1966531510 = "<Player: 0x19ce80> peerID = 1966531510, name = Matthijs iPod, position = 1";
    2094524878 = "<Player: 0x194fc0> peerID = 2094524878, name = com.hollance.Snap356243785.503477, position = 0";
    313323322 = "<Player: 0x1982c0> peerID = 313323322, name = Matthijs iPad 2, position = 2";
}

Waiting For the "Client Ready" Response

You're not done yet. Because the server wants to be sure that all clients have received the ServerReady message and have created their dictionary of Player objects, it expects each client to send back a "client ready" message. As before, the server waits for each client to send this message before it will continue.

The new PacketTypeClientReady message does not require any additional data, so you don't need to make a Packet subclass for it. Add the following lines to the case-statement in clientReceivedPacket:

		case PacketTypeServerReady:
			if (_state == GameStateWaitingForReady)
			{
				_players = ((PacketServerReady *)packet).players;
				[self changeRelativePositionsOfPlayers];

				Packet *packet = [Packet packetWithType:PacketTypeClientReady];
				[self sendPacketToServer:packet];

				[self beginGame];
			}
			break;

There are two new methods here, changeRelativePositionsOfPlayers, and beginGame. Add these to Game.m as well:

- (void)beginGame
{
	_state = GameStateDealing;
	NSLog(@"the game should begin");
}

- (void)changeRelativePositionsOfPlayers
{
	NSAssert(!self.isServer, @"Must be client");

	Player *myPlayer = [self playerWithPeerID:_session.peerID];
	int diff = myPlayer.position;
	myPlayer.position = PlayerPositionBottom;

	[_players enumerateKeysAndObjectsUsingBlock:^(id key, Player *obj, BOOL *stop)
	{
		if (obj != myPlayer)
		{
			obj.position = (obj.position - diff) % 4;
		}
	}];
}

As you can see, beginGame doesn't do much yet, but you'll change that shortly. For now, the interesting method is changeRelativePositionsOfPlayers. Do you remember how I mentioned that a user always sees herself sitting at the bottom of the screen? That means every player sees something different. Here's the illustration again:

How the different players see each other

The local user's own Player object always sits at the bottom of the screen, i.e. in PlayerPositionBottom. But when the server created the Player objects and sent them to the clients, it was the server's own Player object that sat at PlayerPositionBottom.

changeRelativePositionsOfPlayers rotates the players around the table so that the local user is always at the bottom. This means each client has different values for the "position" properties of its Player objects. This is no problem if you always use the peer ID, and not the position, to identify players.

After receiving the ServerReady message, the client goes from the GameStateWaitingForReady state to the GameStateDealing state, and sends a PacketTypeClientReady packet back to the server. You still need to handle these packets on the server.

In Packet.m, change the switch-statement in packetWithData: to read:

	switch (packetType)
	{
		case PacketTypeSignInRequest:
		case PacketTypeClientReady:
			packet = [Packet packetWithType:packetType];
			break;

		. . .
	}

That is, you'll simply return a plain Packet object for both PacketTypeSignInRequest and the new PacketTypeClientReady message.

In Game.m, add a case-statement to serverReceivedPacket:fromPlayer to handle the "client ready" packet:

		case PacketTypeClientReady:
			if (_state == GameStateWaitingForReady && [self receivedResponsesFromAllPlayers])
			{
				[self beginGame];
			}
			break;

Pretty simple! If you've received responses from all the clients, you call beginGame to start the game on the server as well. Notice two things:

  1. Both the client and server use the beginGame method. For Snap! it makes sense that the client and server share most of the code, which is why the Game class does both. For some other games, you may want to create separate "ClientGame" and "ServerGame" classes.
  2. You only accept the ClientReady packet if the game is in the GameStateWaitingForReady state. That's one more example of defensive programming. For example, you don't want to call beginGame if you're in the middle of a game and a misbehaving client sends the ClientReady packet again. Never trust what's on the other side of the line!

Build and run the app again for all your devices. Both the client and server should now say "the game should begin." Of course, you can't see any of this on the screen yet. You're about to make it look a bit more interesting.

Showing the Players

The beginGame method is where it happens. Change this method to:

- (void)beginGame
{
	_state = GameStateDealing;
	[self.delegate gameDidBegin:self];
}

This calls a new delegate method, gameDidBegin:, so add that to the protocol in Game.h:

@protocol GameDelegate <NSObject>
. . .
- (void)gameDidBegin:(Game *)game;
. . .
@end

Since GameViewController is the delegate of Game, you have to add the implementation of this method to that class. But before you can do that, there are a few things to add to the GameViewController nib. Quite a few things, in fact. You need to add labels for the player names, a Snap! button, and much more.

Rather than boring you with several pages of Interface Builder instructions, download the resources for this part and replace the old GameViewController.xib with the new one. Be sure to copy it over the existing xib file in the en.lproj folder!

Also add the new images from the download to the project. When you open the new nib in Interface Builder, it should look like this:

The full GameViewController nib

It's a bit messy, but obviously you won't be showing all of these elements on the screen at the same time. Feel free to click around in the nib to see what's in there. Here's a quick summary:

  • Labels for the players. There is a label for the player's name and for how many games the player has won. There is also a red indicator (a UIImageView) for each player that shows which player is currently active.
  • Snap! buttons. There is a Snap! button for the local user, and a Snap! speech bubble for all the players. You'll show the bubble whenever any player taps the Snap! button.
  • A yellow smiley and a red X. These are shown when the player who first yells "Snap!" is correct – there really is a matching pair of cards on the table – or wrong, respectively.
  • The "next round" button. This is in the bottom-right corner, shown after a player wins the current game. You've already seen the exit button in the bottom-left corner and the label in the center, which is used to let the player know what's going on.

Notice that the labels are all in their own container view, named "Labels Container" in the sidebar. The same thing is done for the buttons, and there's also a separate container view for the cards (although there are no card views in the nib). Giving the different UI elements their own container views makes it easier to layer them. For example, the card views will be on top of the labels, but below the buttons.

Many of the views from the nib are connected to outlets and actions on File's Owner. However, you haven't added the corresponding properties and methods to GameViewController, so let's do that now.

Add the following to the class extension at the top of GameViewController.m:

@property (nonatomic, weak) IBOutlet UIImageView *backgroundImageView;
@property (nonatomic, weak) IBOutlet UIView *cardContainerView;
@property (nonatomic, weak) IBOutlet UIButton *turnOverButton;
@property (nonatomic, weak) IBOutlet UIButton *snapButton;
@property (nonatomic, weak) IBOutlet UIButton *nextRoundButton;
@property (nonatomic, weak) IBOutlet UIImageView *wrongSnapImageView;
@property (nonatomic, weak) IBOutlet UIImageView *correctSnapImageView;

@property (nonatomic, weak) IBOutlet UILabel *playerNameBottomLabel;
@property (nonatomic, weak) IBOutlet UILabel *playerNameLeftLabel;
@property (nonatomic, weak) IBOutlet UILabel *playerNameTopLabel;
@property (nonatomic, weak) IBOutlet UILabel *playerNameRightLabel;

@property (nonatomic, weak) IBOutlet UILabel *playerWinsBottomLabel;
@property (nonatomic, weak) IBOutlet UILabel *playerWinsLeftLabel;
@property (nonatomic, weak) IBOutlet UILabel *playerWinsTopLabel;
@property (nonatomic, weak) IBOutlet UILabel *playerWinsRightLabel;

@property (nonatomic, weak) IBOutlet UIImageView *playerActiveBottomImageView;
@property (nonatomic, weak) IBOutlet UIImageView *playerActiveLeftImageView;
@property (nonatomic, weak) IBOutlet UIImageView *playerActiveTopImageView;
@property (nonatomic, weak) IBOutlet UIImageView *playerActiveRightImageView;

@property (nonatomic, weak) IBOutlet UIImageView *snapIndicatorBottomImageView;
@property (nonatomic, weak) IBOutlet UIImageView *snapIndicatorLeftImageView;
@property (nonatomic, weak) IBOutlet UIImageView *snapIndicatorTopImageView;
@property (nonatomic, weak) IBOutlet UIImageView *snapIndicatorRightImageView;

And synthesize these properties:

@synthesize backgroundImageView = _backgroundImageView;
@synthesize cardContainerView = _cardContainerView;
@synthesize turnOverButton = _turnOverButton;
@synthesize snapButton = _snapButton;
@synthesize nextRoundButton = _nextRoundButton;
@synthesize wrongSnapImageView = _wrongSnapImageView;
@synthesize correctSnapImageView = _correctSnapImageView;

@synthesize playerNameBottomLabel = _playerNameBottomLabel;
@synthesize playerNameLeftLabel = _playerNameLeftLabel;
@synthesize playerNameTopLabel = _playerNameTopLabel;
@synthesize playerNameRightLabel = _playerNameRightLabel;

@synthesize playerWinsBottomLabel = _playerWinsBottomLabel;
@synthesize playerWinsLeftLabel = _playerWinsLeftLabel;
@synthesize playerWinsTopLabel = _playerWinsTopLabel;
@synthesize playerWinsRightLabel = _playerWinsRightLabel;

@synthesize playerActiveBottomImageView = _playerActiveBottomImageView;
@synthesize playerActiveLeftImageView = _playerActiveLeftImageView;
@synthesize playerActiveTopImageView = _playerActiveTopImageView;
@synthesize playerActiveRightImageView = _playerActiveRightImageView;

@synthesize snapIndicatorBottomImageView = _snapIndicatorBottomImageView;
@synthesize snapIndicatorLeftImageView = _snapIndicatorLeftImageView;
@synthesize snapIndicatorTopImageView = _snapIndicatorTopImageView;
@synthesize snapIndicatorRightImageView = _snapIndicatorRightImageView;

Also add the following action method placeholders:

- (IBAction)turnOverPressed:(id)sender
{
}

- (IBAction)turnOverEnter:(id)sender
{
}

- (IBAction)turnOverExit:(id)sender
{
}

- (IBAction)turnOverAction:(id)sender
{
}

- (IBAction)snapAction:(id)sender
{
}

- (IBAction)nextRoundAction:(id)sender
{
}

At this point, it's a good idea to test whether you made all these changes correctly. To get the app to compile again, add an empty version of the gameDidBegin: delegate method to GameViewController.m:

- (void)gameDidBegin:(Game *)game
{
}

And build and run. After pressing Start on the server, the screen should look something like this:

What starting a game looks like with the new nib

That's a bit too colorful for my taste. ;-) Don't worry, you'll clean this up in the following section!

Note: If you still see the old screen, then you may have to do a Clean first and/or remove the app from your device or the simulator. Sometimes Xcode keeps the old nib around, and you don't want that to happen.

Cleaning Up the Game Screen

Now that you have the GameViewController nib and have connected all the outlets and actions, you want to show only the labels for the names of the connected players. Change GameViewController's viewDidLoad to:

- (void)viewDidLoad
{
	[super viewDidLoad];

	self.centerLabel.font = [UIFont rw_snapFontWithSize:18.0f];

	self.snapButton.hidden = YES;
	self.nextRoundButton.hidden = YES;
	self.wrongSnapImageView.hidden = YES;
	self.correctSnapImageView.hidden = YES;

	[self hidePlayerLabels];
	[self hideActivePlayerIndicator];
	[self hideSnapIndicators];
}

In addition to setting the center label's font, you hide all the labels and other indicators. These methods don't exist yet, so add them:

#pragma mark - Game UI

- (void)hidePlayerLabels
{
	self.playerNameBottomLabel.hidden = YES;
	self.playerWinsBottomLabel.hidden = YES;

	self.playerNameLeftLabel.hidden = YES;
	self.playerWinsLeftLabel.hidden = YES;

	self.playerNameTopLabel.hidden = YES;
	self.playerWinsTopLabel.hidden = YES;

	self.playerNameRightLabel.hidden = YES;
	self.playerWinsRightLabel.hidden = YES;
}

- (void)hideActivePlayerIndicator
{
	self.playerActiveBottomImageView.hidden = YES;
	self.playerActiveLeftImageView.hidden   = YES;
	self.playerActiveTopImageView.hidden    = YES;
	self.playerActiveRightImageView.hidden  = YES;
}

- (void)hideSnapIndicators
{
	self.snapIndicatorBottomImageView.hidden = YES;
	self.snapIndicatorLeftImageView.hidden   = YES;
	self.snapIndicatorTopImageView.hidden    = YES;
	self.snapIndicatorRightImageView.hidden  = YES;
}

If you run the game now, you're back where you were and will no longer see all the new buttons and labels. The game screen will only shows the center label and the exit button and it will look like nothing changed.

To show the names of the players who've signed on, replace the gameDidBegin: method in GameViewController.m with:

- (void)gameDidBegin:(Game *)game
{
	[self showPlayerLabels];
	[self calculateLabelFrames];
	[self updateWinsLabels];
}

This requires three new methods, starting with showPlayerLabels:

- (void)showPlayerLabels
{
	Player *player = [self.game playerAtPosition:PlayerPositionBottom];
	if (player != nil)
	{
		self.playerNameBottomLabel.hidden = NO;
		self.playerWinsBottomLabel.hidden = NO;
	}

	player = [self.game playerAtPosition:PlayerPositionLeft];
	if (player != nil)
	{
		self.playerNameLeftLabel.hidden = NO;
		self.playerWinsLeftLabel.hidden = NO;
	}

	player = [self.game playerAtPosition:PlayerPositionTop];
	if (player != nil)
	{
		self.playerNameTopLabel.hidden = NO;
		self.playerWinsTopLabel.hidden = NO;
	}

	player = [self.game playerAtPosition:PlayerPositionRight];
	if (player != nil)
	{
		self.playerNameRightLabel.hidden = NO;
		self.playerWinsRightLabel.hidden = NO;
	}
}

Here you simply look at the four Player objects, but unlike what you did in Game, you're not enumerating the _players dictionary.

When designing the public interface for Game, I decided not to expose the list of players as a dictionary. Having them in a dictionary is useful for the Game class, but its delegate doesn't need to know about that internal structure. Instead, you'll give Game a method that lets other objects look up Player objects based on their position around the table.

Add the method signature to Game.h:

- (Player *)playerAtPosition:(PlayerPosition)position;

And add the implementation in Game.m:

- (Player *)playerAtPosition:(PlayerPosition)position
{
	NSAssert(position >= PlayerPositionBottom && position <= PlayerPositionRight, @"Invalid player position");

	__block Player *player;
	[_players enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop)
	{
		player = obj;
		if (player.position == position)
			*stop = YES;
		else
			player = nil;
	}];

	return player;
}

Notice the NSAssert that just makes sure any users of the Game object aren't trying to be too clever. I've sprinkled such assertions throughout the code, because they help catch silly programming errors.

Don't forget to add an import for Game.h to GameViewController.m, otherwise it won't see this new method:

#import "Game.h"

There are two methods left to add to GameViewController: updateWinsLabels and calculateLabelFrames. Start with updateWinsLabels. This method will put the number of games that each player has won into the label next to the player's name. Currently, you're not keeping track of this "games won" value, so first you need to add a new property to Player.h:

@property (nonatomic, assign) int gamesWon;

And synthesize it in Player.m:

@synthesize gamesWon = _gamesWon;

You don't need to give this variable an initial value, because the default value for ints is 0. Now add updateWinsLabels to GameViewController.m:

- (void)updateWinsLabels
{
	NSString *format = NSLocalizedString(@"%d Won", @"Number of games won");
	
	Player *player = [self.game playerAtPosition:PlayerPositionBottom];
	if (player != nil)
		self.playerWinsBottomLabel.text = [NSString stringWithFormat:format, player.gamesWon];

	player = [self.game playerAtPosition:PlayerPositionLeft];
	if (player != nil)
		self.playerWinsLeftLabel.text = [NSString stringWithFormat:format, player.gamesWon];

	player = [self.game playerAtPosition:PlayerPositionTop];
	if (player != nil)
		self.playerWinsTopLabel.text = [NSString stringWithFormat:format, player.gamesWon];

	player = [self.game playerAtPosition:PlayerPositionRight];
	if (player != nil)
		self.playerWinsRightLabel.text = [NSString stringWithFormat:format, player.gamesWon];
}

This is pretty self-explanatory. You do have to check each time whether "player" is nil, because if there are fewer than four players, not all positions around the table are filled.

Now for calculateLabelFrames. This is a big one. It even comes with a little helper method, resizeLabelToFit. Add both these methods to GameViewController.m:

- (void)resizeLabelToFit:(UILabel *)label
{
	[label sizeToFit];

	CGRect rect = label.frame;
	rect.size.width = ceilf(rect.size.width/2.0f) * 2.0f;  // make even
	rect.size.height = ceilf(rect.size.height/2.0f) * 2.0f;  // make even
	label.frame = rect;
}

- (void)calculateLabelFrames
{
	UIFont *font = [UIFont rw_snapFontWithSize:14.0f];
	self.playerNameBottomLabel.font = font;
	self.playerNameLeftLabel.font = font;
	self.playerNameTopLabel.font = font;
	self.playerNameRightLabel.font = font;

	font = [UIFont rw_snapFontWithSize:11.0f];
	self.playerWinsBottomLabel.font = font;
	self.playerWinsLeftLabel.font = font;
	self.playerWinsTopLabel.font = font;
	self.playerWinsRightLabel.font = font;

	self.playerWinsBottomLabel.layer.cornerRadius = 4.0f;
	self.playerWinsLeftLabel.layer.cornerRadius = 4.0f;
	self.playerWinsTopLabel.layer.cornerRadius = 4.0f;
	self.playerWinsRightLabel.layer.cornerRadius = 4.0f;

	UIImage *image = [[UIImage imageNamed:@"ActivePlayer"] stretchableImageWithLeftCapWidth:20 topCapHeight:0];
	self.playerActiveBottomImageView.image = image;
	self.playerActiveLeftImageView.image = image;
	self.playerActiveTopImageView.image = image;
	self.playerActiveRightImageView.image = image;

	CGFloat viewWidth = self.view.bounds.size.width;
	CGFloat centerX = viewWidth / 2.0f;

	Player *player = [self.game playerAtPosition:PlayerPositionBottom];
	if (player != nil)
	{
		self.playerNameBottomLabel.text = player.name;

		[self resizeLabelToFit:self.playerNameBottomLabel];
		CGFloat labelWidth = self.playerNameBottomLabel.bounds.size.width;

		CGPoint point = CGPointMake(centerX - 19.0f - 3.0f, 306.0f);
		self.playerNameBottomLabel.center = point;

		CGPoint winsPoint = point;
		winsPoint.x += labelWidth/2.0f + 6.0f + 19.0f;
		winsPoint.y -= 0.5f;
		self.playerWinsBottomLabel.center = winsPoint;

		self.playerActiveBottomImageView.frame = CGRectMake(0, 0, 20.0f + labelWidth + 6.0f + 38.0f + 2.0f, 20.0f);

		point.x = centerX - 9.0f;
		self.playerActiveBottomImageView.center = point;
	}

	player = [self.game playerAtPosition:PlayerPositionLeft];
	if (player != nil)
	{
		self.playerNameLeftLabel.text = player.name;
	
		[self resizeLabelToFit:self.playerNameLeftLabel];
		CGFloat labelWidth = self.playerNameLeftLabel.bounds.size.width;

		CGPoint point = CGPointMake(2.0 + 20.0f + labelWidth/2.0f, 48.0f);
		self.playerNameLeftLabel.center = point;

		CGPoint winsPoint = point;
		winsPoint.x += labelWidth/2.0f + 6.0f + 19.0f;
		winsPoint.y -= 0.5f;
		self.playerWinsLeftLabel.center = winsPoint;

		self.playerActiveLeftImageView.frame = CGRectMake(2.0f, 38.0f, 20.0f + labelWidth + 6.0f + 38.0f + 2.0f, 20.0f);
	}

	player = [self.game playerAtPosition:PlayerPositionTop];
	if (player != nil)
	{
		self.playerNameTopLabel.text = player.name;

		[self resizeLabelToFit:self.playerNameTopLabel];
		CGFloat labelWidth = self.playerNameTopLabel.bounds.size.width;

		CGPoint point = CGPointMake(centerX - 19.0f - 3.0f, 15.0f);
		self.playerNameTopLabel.center = point;

		CGPoint winsPoint = point;
		winsPoint.x += labelWidth/2.0f + 6.0f + 19.0f;
		winsPoint.y -= 0.5f;
		self.playerWinsTopLabel.center = winsPoint;

		self.playerActiveTopImageView.frame = CGRectMake(0, 0, 20.0f + labelWidth + 6.0f + 38.0f + 2.0f, 20.0f);

		point.x = centerX - 9.0f;
		self.playerActiveTopImageView.center = point;
	}

	player = [self.game playerAtPosition:PlayerPositionRight];
	if (player != nil)
	{
		self.playerNameRightLabel.text = player.name;

		[self resizeLabelToFit:self.playerNameRightLabel];
		CGFloat labelWidth = self.playerNameRightLabel.bounds.size.width;

		CGPoint point = CGPointMake(viewWidth - labelWidth/2.0f - 2.0f - 6.0f - 38.0f - 12.0f, 48.0f);
		self.playerNameRightLabel.center = point;

		CGPoint winsPoint = point;
		winsPoint.x += labelWidth/2.0f + 6.0f + 19.0f;
		winsPoint.y -= 0.5f;
		self.playerWinsRightLabel.center = winsPoint;

		self.playerActiveRightImageView.frame = CGRectMake(self.playerNameRightLabel.frame.origin.x - 20.0f, 38.0f, 20.0f + labelWidth + 6.0f + 38.0f + 2.0f, 20.0f);
	}
}

This might look scary, but it simply sets the font and text on the player name labels, and then moves them around so they are in their ideal positions. Notice there's a bit of arithmetic going on. Rather than adding everything up myself, I let the compiler take care of it (laziness is a programmer's greatest virtue).

Because this code uses the .layer.cornerRadius property to give some of the labels rounded corners, you have to link the app with the QuartzCore framework. Add this framework to the project, and add an import in Snap-Prefix.pch:

	#import <QuartzCore/QuartzCore.h>

Run the app and you should now see something like this, on both the server and the clients:

The player labels on the game screen

Handling Disconnects

Before you can build the fun part of the game, there is something you still need to deal with. Because networking is unpredictable, clients may get disconnected from the server at any time. A player wanders out of range, the network has too high a packet loss, a freak solar flare passes through the galaxy, or any other number of things can make this happen.

You should deal with such sudden disconnects gracefully, so that they don't interrupt the game too much. That's what you'll do in this section.

Recalling MatchmakingServer, then may remember that GKSession lets its delegate know about new connections and disconnects in the session:peer:didChangeState: method. This method is already part of the Game class, but it doesn't do anything yet. Replace that method in Game.m with:

- (void)session:(GKSession *)session peer:(NSString *)peerID didChangeState:(GKPeerConnectionState)state
{
	#ifdef DEBUG
	NSLog(@"Game: peer %@ changed state %d", peerID, state);
	#endif
	
	if (state == GKPeerStateDisconnected)
	{
		if (self.isServer)
		{
			[self clientDidDisconnect:peerID];
		}
	}
}

Pretty simple. If you are the server and the peer is now in state GKPeerStateDisconnected, then you call the new clientDidDisconnect: method. Add that method as well:

- (void)clientDidDisconnect:(NSString *)peerID
{
	if (_state != GameStateQuitting)
	{
		Player *player = [self playerWithPeerID:peerID];
		if (player != nil)
		{
			[_players removeObjectForKey:peerID];
			[self.delegate game:self playerDidDisconnect:player];
		}
	}
}

This simply looks up the Player object and removes it from the _players dictionary. Note that you don't do anything here if the game is already in the process of quitting (GameStateQuitting), but more about that later. You also have to add a new delegate method to GameDelegate, in Game.h:

- (void)game:(Game *)game playerDidDisconnect:(Player *)disconnectedPlayer;

Add the implementation of this method in GameViewController.m:

- (void)game:(Game *)game playerDidDisconnect:(Player *)disconnectedPlayer
{
	[self hidePlayerLabelsForPlayer:disconnectedPlayer];
	[self hideActiveIndicatorForPlayer:disconnectedPlayer];
	[self hideSnapIndicatorForPlayer:disconnectedPlayer];
}

This requires three new methods, so add them as well:

- (void)hidePlayerLabelsForPlayer:(Player *)player
{
	switch (player.position)
	{
		case PlayerPositionBottom:
			self.playerNameBottomLabel.hidden = YES;
			self.playerWinsBottomLabel.hidden = YES;
			break;
		
		case PlayerPositionLeft:
			self.playerNameLeftLabel.hidden = YES;
			self.playerWinsLeftLabel.hidden = YES;
			break;
		
		case PlayerPositionTop:
			self.playerNameTopLabel.hidden = YES;
			self.playerWinsTopLabel.hidden = YES;
			break;
		
		case PlayerPositionRight:
			self.playerNameRightLabel.hidden = YES;
			self.playerWinsRightLabel.hidden = YES;
			break;
	}
}

- (void)hideActiveIndicatorForPlayer:(Player *)player
{
	switch (player.position)
	{
		case PlayerPositionBottom: self.playerActiveBottomImageView.hidden = YES; break;
		case PlayerPositionLeft:   self.playerActiveLeftImageView.hidden   = YES; break;
		case PlayerPositionTop:    self.playerActiveTopImageView.hidden    = YES; break;
		case PlayerPositionRight:  self.playerActiveRightImageView.hidden  = YES; break;
	}
}

- (void)hideSnapIndicatorForPlayer:(Player *)player
{
	switch (player.position)
	{
		case PlayerPositionBottom: self.snapIndicatorBottomImageView.hidden = YES; break;
		case PlayerPositionLeft:   self.snapIndicatorLeftImageView.hidden   = YES; break;
		case PlayerPositionTop:    self.snapIndicatorTopImageView.hidden    = YES; break;
		case PlayerPositionRight:  self.snapIndicatorRightImageView.hidden  = YES; break;
	}
}

OK, try it out. Run the game, preferably with two or more clients, and wait until they've all signed in. Then exit the app on one of the clients. The name of that player should now disappear from the screen of the server. However, it doesn't yet disappear from the screen of the other client(s).

To make that happen, the server needs to let the remaining clients know that one of the other players has disconnected. For that, you'll introduce a new packet type: PacketTypeOtherClientQuit.

Change clientDidDisconnect: in Game.m to:

- (void)clientDidDisconnect:(NSString *)peerID
{
	if (_state != GameStateQuitting)
	{
		Player *player = [self playerWithPeerID:peerID];
		if (player != nil)
		{
			[_players removeObjectForKey:peerID];
			
			if (_state != GameStateWaitingForSignIn)
			{
				// Tell the other clients that this one is now disconnected.
				if (self.isServer)
				{
					PacketOtherClientQuit *packet = [PacketOtherClientQuit packetWithPeerID:peerID];
					[self sendPacketToAllClients:packet];
				}			

				[self.delegate game:self playerDidDisconnect:player];
			}
		}
	}
}

You now create a PacketOtherClientQuit packet and send it to all clients. Note, however, that you don't do this if you're in the initial "waiting for sign-in" state. At that point, the clients don't know anything about each other, so there's no need to tell them about another player's disconnect.

Add an import for this new Packet subclass:

#import "PacketOtherClientQuit.h"

This class doesn't exist yet, so add a new Objective-C class to the project named PacketOtherClientQuit, subclass of Packet. Replace the contents of the new .h file with:

#import "Packet.h"

@interface PacketOtherClientQuit : Packet

@property (nonatomic, copy) NSString *peerID;

+ (id)packetWithPeerID:(NSString *)peerID;

@end

Replace the .m file with:

#import "PacketOtherClientQuit.h"
#import "NSData+SnapAdditions.h"

@implementation PacketOtherClientQuit

@synthesize peerID = _peerID;

+ (id)packetWithPeerID:(NSString *)peerID
{
	return [[[self class] alloc] initWithPeerID:peerID];
}

- (id)initWithPeerID:(NSString *)peerID
{
	if ((self = [super initWithType:PacketTypeOtherClientQuit]))
	{
		self.peerID = peerID;
	}
	return self;
}

+ (id)packetWithData:(NSData *)data
{
	size_t offset = PACKET_HEADER_SIZE;
	size_t count;

	NSString *peerID = [data rw_stringAtOffset:offset bytesRead:&count];

	return [[self class] packetWithPeerID:peerID];
}

- (void)addPayloadToData:(NSMutableData *)data
{
	[data rw_appendString:self.peerID];
}

@end

This is very similar to what you've seen before. Simply add the contents of the peerID variable to the NSMutableData object. Don't forget that you also need to tell the Packet superclass about this new type. Add an import to Packet.m:

#import "PacketOtherClientQuit.h"

And a new case-statement to packetWithData:

		case PacketTypeOtherClientQuit:
			packet = [PacketOtherClientQuit packetWithData:data];
			break;

Now all that remains is reading this new packet on the client side of things. In Game.m's clientReceivedPacket:, add a new case-statement:

		case PacketTypeOtherClientQuit:
			if (_state != GameStateQuitting)
			{
				PacketOtherClientQuit *quitPacket = ((PacketOtherClientQuit *)packet);
				[self clientDidDisconnect:quitPacket.peerID];
			}	
			break;

Here you call the same clientDidDisconnect: method that was used on the server, and as a result, the player should disappear from the screen. Try it out! (Note: You need to try this with at least two clients, because you need to disconnect one of those clients to see the effect.)

Note: In this tutorial, when a client drops the connection, that client is removed from the game. You could come up with an alternative scheme that allows clients to reconnect, if that makes sense for your game, but it won't be covered here.

If you tried disconnecting the server, then you'll have seen that the client didn't react at all. The GKSessionDelegate method does recognize that the server went away (in the debug output it says, "peer XXX changed state 3," which means that peer disconnected), but the game doesn't quit. That's a fairly easy fix. Replace the Game.m's session:peer:didChangeState: GKSession delegate method with:

- (void)session:(GKSession *)session peer:(NSString *)peerID didChangeState:(GKPeerConnectionState)state
{
	#ifdef DEBUG
	NSLog(@"Game: peer %@ changed state %d", peerID, state);
	#endif
	
	if (state == GKPeerStateDisconnected)
	{
		if (self.isServer)
		{
			[self clientDidDisconnect:peerID];
		}
		else if ([peerID isEqualToString:_serverPeerID])
		{
			[self quitGameWithReason:QuitReasonConnectionDropped];
		}
	}
}

The else-if clause checks to see whether it was the server that got disconnected, and if so calls the quitGameWithReason: method that you already have.

Note that it's necessary to check for the server's peer ID, because Game Kit also connects all the clients to all the other clients. You don't do anything with those additional connections in this particular app, but it does mean you get a "disconnected" mention when one of those clients disappears. As you've just seen, those other-client disconnects aren't handled here, but with the PacketOtherClientQuit message.

Now the client should return to the main screen with a disconnected message after the server disappears. Try it out.

Disconnected alert

Handling the Exit Button

Clients (as well as the server) can get disconnected in two ways:

  1. There is a networking problem and Game Kit drops the connection.
  2. The player leaves the game on purpose by tapping the exit button.

You've already handled the first situation. It's time to improve how you deal with the second.

Right now, pressing the exit button works. If you do it as the server, the GKSession object gets destroyed and all clients are automatically disconnected. Exiting as the client destroys its GKSession , and the server sees the disconnect and informs the other clients.

The only problem is, there's no way to distinguish between an intentional disconnect (pressing the exit button) and an accidental one (network error). It sometimes it takes a while for the disconnect to be recognized.

To solve this, you will send a "quit" packet to the other players to inform them that this user is leaving the game on purpose. Change GameViewController's exitAction: to the following:

- (IBAction)exitAction:(id)sender
{
	if (self.game.isServer)
	{
		_alertView = [[UIAlertView alloc]
			initWithTitle:NSLocalizedString(@"End Game?", @"Alert title (user is host)")
			message:NSLocalizedString(@"This will terminate the game for all other players.", @"Alert message (user is host)")
			delegate:self
			cancelButtonTitle:NSLocalizedString(@"No", @"Button: No")
			otherButtonTitles:NSLocalizedString(@"Yes", @"Button: Yes"),
			nil];

		[_alertView show];
	}
	else
	{
		_alertView = [[UIAlertView alloc]
			initWithTitle: NSLocalizedString(@"Leave Game?", @"Alert title (user is not host)")
			message:nil
			delegate:self
			cancelButtonTitle:NSLocalizedString(@"No", @"Button: No")
			otherButtonTitles:NSLocalizedString(@"Yes", @"Button: Yes"),
			nil];

		[_alertView show];
	}
}

Before you allow the user to quit, the game will display an alert popup asking for confirmation. Notice that _alertView is a new instance variable, so add that to the declaration section at the top of the file:

@implementation GameViewController
{
	UIAlertView *_alertView;
}

I will explain why you do this shortly. The GameViewController must become the delegate for the UIAlertView, but the protocol declaration is already in the @interface line, so that saves some time. You do have to add the implementation of the method to the view controller:

#pragma mark - UIAlertViewDelegate

- (void)alertView:(UIAlertView *)alertView didDismissWithButtonIndex:(NSInteger)buttonIndex
{
	if (buttonIndex != alertView.cancelButtonIndex)
	{
		[self.game quitGameWithReason:QuitReasonUserQuit];
	}
}

This simply exits the game when the user taps the OK button. In Game.m, change the quitGameWithReason: method to:

- (void)quitGameWithReason:(QuitReason)reason
{
	_state = GameStateQuitting;

	if (reason == QuitReasonUserQuit)
	{
		if (self.isServer)
		{
			Packet *packet = [Packet packetWithType:PacketTypeServerQuit];
			[self sendPacketToAllClients:packet];
		}
		else
		{
			Packet *packet = [Packet packetWithType:PacketTypeClientQuit];
			[self sendPacketToServer:packet];
		}
	}

	[_session disconnectFromAllPeers];
	_session.delegate = nil;
	_session = nil;

	[self.delegate game:self didQuitWithReason:reason];
}

Just before you end the session, you send a PacketTypeServerQuit or a PacketTypeClientQuit message, depending on whether you're the server or a client.

Add the following case-statement in clientReceivedPacket:

		case PacketTypeServerQuit:
			[self quitGameWithReason:QuitReasonServerQuit];
			break;

And add the following in serverReceivedPacket:fromPlayer:

		case PacketTypeClientQuit:
			[self clientDidDisconnect:player.peerID];
			break;

In Packet.m, add these two new packet types to the switch-statement in packetWithData:

	switch (packetType)
	{
		case PacketTypeSignInRequest:
		case PacketTypeClientReady:
		case PacketTypeServerQuit:
		case PacketTypeClientQuit:
			packet = [Packet packetWithType:packetType];
			break;

		. . .

That should do it. Try it out. Now when the server quits, clients should not get the "you're disconnected" alert (because the quit reason is QuitReasonServerQuit and not QuitReasonConnectionDropped). Also try exiting a client.

There's one problematic edge case that I found when testing. What happens if a client taps the exit button and the alert view is showing, and at the same time that client gets disconnected?

You can try it for yourself. Tap the exit button on the client but leave the alert view on the screen, then kill the app on the server. After a few seconds, the client returns to the main screen and displays a new alert popup to inform you that you've been disconnected.

At this point, or after you've tapped OK, the app may crash. I'm not sure why, but it seems the two alert views are interfering with each other. The workaround is simple: in GameViewController's viewWillDisappear:, you''ll dismiss the alert view if it's still showing:

- (void)viewWillDisappear:(BOOL)animated
{
	[super viewWillDisappear:animated];

	[_alertView dismissWithClickedButtonIndex:_alertView.cancelButtonIndex animated:NO];
}

Tip: Try to think through all weird situations your app might be in during a network disconnect, because it can happen at any time!

Just to cover all the bases, the GKSessionDelegate has another method that you should implement, session:didFailWithError:. Replace this method in Game.m with:

- (void)session:(GKSession *)session didFailWithError:(NSError *)error
{
	#ifdef DEBUG
	NSLog(@"Game: session failed %@", error);
	#endif

	if ([[error domain] isEqualToString:GKSessionErrorDomain])
	{
		if (_state != GameStateQuitting)
		{
			[self quitGameWithReason:QuitReasonConnectionDropped];
		}
	}
}

If a fatal Game Kit error occurs, then you exit the game. This will still try to send a Quit packet, although that probably won't succeed.

Note: If the app goes into the background and stays there for too long (more than a few seconds), the Game Kit connection is typically dropped. When you return to the game, it recognizes this (in the GKSession delegate methods), and the game will exit.

You can also handle this more intelligently. If your game allows peers to reconnect after the connection has been dropped, then you'll need to create a new GKSession object and set up the connections again. Unfortunately, GKSession doesn't do this automatically.

Note that the server will never get a "you're disconnected" message, only the clients; the client session gets destroyed when it is disconnected, but the server keeps the session alive until you exit the screen.

Where To Go From Here?

Here is a sample project with all of the code from the tutorial series so far.

Congratulations, you now have most of the networking code in place, and have a solid game handshake and disconnection handling.

Now you have a firm foundation in place and can finally get to the fun stuff - playing around with cards! You're ready to move on to Part 5, where you'll start implementing the gameplay itself.

In the meantime, if you have any questions or comments about this part of the series, please join the forum discussion below!


This is a post by iOS Tutorial Team member Matthijs Hollemans, an experienced iOS developer and designer. You can find him on and Twitter.

Contributors

Over 300 content creators. Join our team.