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

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
You are currently viewing page 4 of 6 of this article. Click here to view the first page.

Disconnects, one more time

You're not quite done yet. Imagine what should happen here: you're in the middle of a game and one of the other players suddenly disconnects (for whatever reason). What happens with the cards that player was holding?

For the game of Snap! you cannot simply let those cards disappear because then it may be impossible to finish a round. Instead, that player's cards need to be redistributed to the other players. That is what you will do in this section.

To accomplish this you will give the clientDidDisconnect: method a second parameter named redistributedCards:, which is an NSDictionary of peer IDs and Card objects. On the server this dictionary will be nil, which tells the server it still needs to calculate how that player's cards will be redistributed, but on the client the dictionary tells the client which player (peer ID) gets which new cards.

If this description is confusing, then hopefully the source code will clarify things. Replace the clientDidDisconnect: method in Game.m with this:

- (void)clientDidDisconnect:(NSString *)peerID redistributedCards:(NSDictionary *)redistributedCards
{
	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.
				// Give the cards of the disconnected player to the others.
				if (self.isServer)
				{
					redistributedCards = [self redistributeCardsOfDisconnectedPlayer:player];

					PacketOtherClientQuit *packet = [PacketOtherClientQuit packetWithPeerID:peerID cards:redistributedCards];
					[self sendPacketToAllClients:packet];
				}

				// Add the new cards to the bottom of the closed piles.
				[redistributedCards enumerateKeysAndObjectsUsingBlock:^(id key, NSArray *array, BOOL *stop)
				{
					Player *player = [self playerWithPeerID:key];
					if (player != nil)
					{
						[array enumerateObjectsUsingBlock:^(Card *card, NSUInteger idx, BOOL *stop)
						{
							card.isTurnedOver = NO;
							[player.closedCards addCardToBottom:card];
						}];
					}
				}];

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

				if (self.isServer && player.position == _activePlayerPosition)
					[self activateNextPlayer];
			}
		}
	}
}

The logic in this method is quite similar to what it did before, but now you also use the redistributedCards dictionary to give the new cards to the remaining players to put on their closed piles. If the disconnected player was also the active one, then you have to activate the next player.

Xcode now gives all sorts of errors and warnings, so let's add the missing methods. First up is redistributeCardsOfDisconnectedPlayer:

- (NSDictionary *)redistributeCardsOfDisconnectedPlayer:(Player *)disconnectedPlayer
{
	NSMutableDictionary *playerCards = [NSMutableDictionary dictionaryWithCapacity:4];

	[_players enumerateKeysAndObjectsUsingBlock:^(id key, Player *obj, BOOL *stop)
	{
		if (obj != disconnectedPlayer && [obj totalCardCount] > 0)
		{
			NSMutableArray *array = [NSMutableArray arrayWithCapacity:26];
			[playerCards setObject:array forKey:key];
		}
	}];

	NSMutableArray *oldCards = [NSMutableArray arrayWithCapacity:52];
	[oldCards addObjectsFromArray:[disconnectedPlayer.closedCards array]];
	[oldCards addObjectsFromArray:[disconnectedPlayer.openCards array]];

	while ([oldCards count] > 0)
	{
		[playerCards enumerateKeysAndObjectsUsingBlock:^(id key, NSMutableArray *obj, BOOL *stop)
		{
			if ([oldCards count] > 0)
			{
				[obj addObject:[oldCards lastObject]];
				[oldCards removeLastObject];
			}
			else
			{
				*stop = YES;
			}
		}];
	}

	return playerCards;
}

This is the method that returns the dictionary of cards. The structure of this dictionary is similar in principle to the dictionary that you used with PacketDealCards. The keys are peer IDs that identify a player, and for each player there is an array of Card objects. Note that players who no longer have any cards left at all, open or closed, do not longer participate in the round. They are skipped when redistributing the cards.

The first enumeration block in this method adds the peerIDs for the players who will receive cards to the dictionary and gives each of them an empty (mutable) array. Then you enumerate again but this time through a list of the disconnected player's cards. As long as there are cards left, you pass them out among the remaining players. Finally, you return the dictionary object.

This requires that a new method is added to Player, so add it to both Player.h and Player.m:

- (int)totalCardCount
{
	return [self.closedCards cardCount] + [self.openCards cardCount];
}

Still a few errors to go. The PacketOtherClientQuit packet now also needs to include the dictionary of redistributed cards, because you need to send this list to the remaining clients. In theory they could calculate this redistribution by themselves as they have all the pieces of the puzzle -- each client knows which cards all of the other players have -- but for educational purposes I decided to send them the full list of cards.

In PacketOtherClientQuit.h, add a new property and change the packetWithPeerID method to:

@property (nonatomic, strong) NSDictionary *cards;

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

Because this change affects every method in the class, it's easiest just to replace the entire contents of PacketOtherClientQuit.m:

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

@implementation PacketOtherClientQuit

@synthesize peerID = _peerID;
@synthesize cards = _cards;

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

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

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

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

	NSDictionary *cards = [[self class] cardsFromData:data atOffset:offset];

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

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

@end

All right, that takes care of the packet. Now the GameDelegate. Previously you had a delegate method game:playerDidDisconnect:. That method now needs to get a new parameter so you can pass it the dictionary of redistributed cards as well, for a nice animation so that the user actually sees what is going on.

Replace the method signature in Game.h with:

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

And the implementation in GameViewController.m with:

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

	for (PlayerPosition p = PlayerPositionBottom; p <= PlayerPositionRight; ++p)
	{
		Player *otherPlayer = [self.game playerAtPosition:p];
		if (otherPlayer != disconnectedPlayer)
		{
			NSArray *cards = [redistributedCards objectForKey:otherPlayer.peerID];
			for (Card *card in cards)
			{
				CardView *cardView = [self cardViewForCard:card];
				cardView.card = card;
				[cardView animateCloseAndMoveFromPlayer:disconnectedPlayer toPlayer:otherPlayer withDelay:0.0f];			
			}
		}
	}
}

This simply looks up the CardView for each redistributed Card and tells it to perform an animation that moves the card from one player to the other.

Add this new animation method to CardView.h and CardView.m:

- (void)animateCloseAndMoveFromPlayer:(Player *)fromPlayer toPlayer:(Player *)toPlayer withDelay:(NSTimeInterval)delay
{
	[self.superview sendSubviewToBack:self];

	[self unloadFront];
	[self loadBack];

	CGPoint point = [self centerForPlayer:toPlayer];
	_angle = [self angleForPlayer:toPlayer];

	[UIView animateWithDuration:0.4f
		delay:delay
		options:UIViewAnimationOptionCurveEaseOut
		animations:^
		{
			self.center = point;
			self.transform = CGAffineTransformMakeRotation(_angle);
		}
		completion:nil];
}

You're not done yet, but far enough along to test whether it actually works on the server. To get the code to compile, replace everywhere in Game.m the following call is made,

[self clientDidDisconnect:. . .];

to:

[self clientDidDisconnect:. . .  redistributedCards:nil];

Run the app with just one client and the server, and then disconnect the client after the cards have been dealt. You should now see the client's player disappear from the server and his cards (both open and closed) fly to the server's closed pile. Also notice that the server's player should become active if it wasn't.

Note: It's always a good idea to test the simplest situations first, which in this case is with just one client and on the server only. Once the server side part works, test it on the client side, then test with multiple clients, and so on. Problems are easiest to debug when you find the simplest situation possible that reproduces the problem. Also, very complex situations may actually hide issues that you don't notice because so much is going on at once.

Getting redistribution to work on the clients is pretty simple because you've done most of the work already. In Game.m's clientReceivedPacket:, replace the case-statement for PacketTypeOtherClientQuit with:

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

Instead of nil, you now pass "quitPacket.cards" to the clientDidDisconnect:redistributedCards: method. Because you already wrote that code, the clients should now handle the redistribution as well, except for one small issue. Recall that in the delegate method you did this:

			for (Card *card in cards)
			{
				CardView *cardView = [self cardViewForCard:card];
				cardView.card = card;
				[cardView animateCloseAndMoveFromPlayer:. . .];		
			}

On the server this works fine because the Card objects there never change. For example, the Card object at address 0x1234 used to belong to player X and after moving it around it now belongs to player Y. On the client, however, the Card objects do change. When you receive the PacketOtherClientQuit packet, it reads the suit and value from the incoming NSData object and gives those to a new Card object that it freshly allocates!

In that case, [self cardViewForCard:card] will return nil because there is no CardView that currently points at that Card object. After all, you just made that new Card object from scratch.

The solution is that cardViewForCard: should not compare object pointers, but it should really see whether it has a CardView for a Card with the same suit and value as the one you're looking for. You can accomplish this by adding a new method to Card.m (and also add its declaration to Card.h):

- (BOOL)isEqualToCard:(Card *)otherCard
{
	return (otherCard.suit == self.suit && otherCard.value == self.value);
}

This method simply compares the suit and value of this Card object with another. Then in GameViewController.m's cardViewForCard:, you can do this:

- (CardView *)cardViewForCard:(Card *)card
{
	for (CardView *cardView in self.cardContainerView.subviews)
	{
		if ([cardView.card isEqualToCard:card])
			return cardView;
	}
	return nil;
}

And that should do it. Try the app with at least three players (two clients and one server). Now when one of the clients disconnects, his cards should be redistributed among the two remaining players.

Because it is vital that all participants keep their card stacks in the same order, you should tap through all the cards after the disconnect to see if what the remaining client shows is the same as what the server shows. (This is easiest if you test with a smaller deck.) Later on, when it's actually possible for players to lose cards you should also test the situation where one of the players has no cards left; he shouldn't get any of the disconnected player's cards.

Note: The source code from this tutorial works pretty well but it's not perfect. Hopefully it shows you the sort of things that you need to think about when you're writing multiplayer games. It's often the weird edge cases that make network programming hard.

One situation that isn't handled very well by Snap! is the following: if multiple clients disconnect at around the same time, the card redistribute messages may arrive in the wrong order. Small chance, but it could theoretically happen -- after all, nothing is guaranteed when networking.

You could handle this with the packetNumber scheme, but that may conflict with the ActivatePlayer packets, causing the message that activates the client itself not to be guaranteed anymore (because that ActivatePlayer packet could be dropped if it arrives out-of-order with an OtherClientQuit packet).

There are a couple of ways to solve this issue, but this tutorial is already long enough. Suffice to say, multiplayer network programming can cause a lot of headaches!

Contributors

Over 300 content creators. Join our team.