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

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 3 of 6 of this article. Click here to view the first page.

Testing edge cases

The “|| _hasTurnedCard” expression in the first if-statement is there to catch an interesting edge case: You (on a client) have no closed cards and it’s not your turn. The other player turns his card and you immediately tap “Snap!”. Now the client ends up paying two cards instead of one. This happens because the ActivatePlayer message starts the recycling and this gets in the way of playerMustPayCards:.

The solution is to cancel the scheduled activateNextPlayer message when anyone taps Snap! (in playerCalledSnap:) and to resume in resumeAfterMovingCardsForPlayer:. In other words, you don’t want the PlayerCalledSnap and ActivatePlayer messages to interfere with each other. Therefore, also add the following line to playerCalledSnap in Game.m:

- (void)playerCalledSnap:(Player *)player
{
	if (self.isServer)
	{
		if (_haveSnap)
		{
			. . .
		}
		else
		{
			[NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(activateNextPlayer) object:nil];

			. . .

If there was any activateNextPlayer method call pending, then it gets cancelled and you wait until resumeAfterMovingCardsForPlayer: to activate the next player.

Time for some more testing. Here are some things that should work now:

  • If the active player taps Snap! (when he still needs to turn his card) but he has no closed cards left, his open stack should be recycled and the player remains active until he turns over the next card.
  • If the active player taps Snap! (when he still needs to turn his card) and he has exactly as many closed cards left as there are other players, then he first pays the cards, and after that his open stack should be recycled.
  • After the active player taps Snap! and pays all his cards to the other players, he is out of the game and the next player should be activated. His Snap! button should have disappeared.
  • As the above, but the active player only has one open card left. He’s not out of the game but he no longer gets activated either (because it makes no sense for him to keep turning over that one card).

There are a lot of edge cases here that you need to check for. Here is another one: Test this with just two players. The active player has only two cards left: one closed, one open. Tap “Snap!”. The player now pays his closed card, but the open card does not get recycled and the game is stuck because the player cannot turn any cards. That happens because Player’s shouldRecycle only returns YES if there is more than one open card, but not if there is just one.

Replace the first if-statement in resumeAfterMovingCardsForPlayer: with the following to handle this situation:

	if ([[self activePlayer] totalCardCount] == 0 
		|| _hasTurnedCard 
		|| ([[self activePlayer].closedCards cardCount] == 0 && [[self activePlayer].openCards cardCount] == 1))
	{
		. . .
	}

If the active player now only has one open card then you will also activate the next player.

Checking for matching cards

Up till now when a player tapped Snap! you have assumed it was a wrong move, so in this section you’ll build in the logic to check whether there are any matching cards on the table. If the Snap! is correct, then you have to move the open stacks from the players with the matching cards to the player who first yelled “Snap!”.

Add a new instance variable to Game.m:

@implementation Game
{
	. . .
	NSMutableSet *_matchingPlayers;
}

This is a mutable set that will contain pointers to the Player objects that have matching cards. Because the order of the players doesn’t match, you use a set instead of an array. Allocate the NSMutableSet object in the init method:

- (id)init
{
	if ((self = [super init]))
	{
		_players = [NSMutableDictionary dictionaryWithCapacity:4];
		_matchingPlayers = [NSMutableSet setWithCapacity:4];
	}
	return self;
}

At the start of the a new round there are no matches yet, so you clear out the set:

- (void)beginRound
{
	[_matchingPlayers removeAllObjects];
	. . .
}

You fill up the _matchingPlayers set in a new method, checkMatch. Add this method to Game.m:

- (void)checkMatch
{
	[_matchingPlayers removeAllObjects];

	for (PlayerPosition p = PlayerPositionBottom; p <= PlayerPositionRight; ++p)
	{
		Player *player1 = [self playerAtPosition:p];
		if (player1 != nil)
		{
			for (PlayerPosition q = PlayerPositionBottom; q <= PlayerPositionRight; ++q)
			{
				Player *player2 = [self playerAtPosition:q];
				if (p != q && player2 != nil)
				{
					Card *card1 = [player1.openCards topmostCard];
					Card *card2 = [player2.openCards topmostCard];
					if (card1 != nil && card2 != nil && [card1 matchesCard:card2])
					{
						[_matchingPlayers addObject:player1];
						break;
					}
				}
			}
		}
	}

	#ifdef DEBUG
	NSLog(@"Matching players: %@", _matchingPlayers);
	#endif
}

For each player, this compares his top-most open card to the top-most open card of all other players. This algorithm will match the following combinations: AABC, AAAB, AAAA, AABB. In other words, it sees two-of-a-kind, three-of-a-kind, four-of-a-kind and two-pairs. Any of these combinations can occur in a 4-player game and they all count as valid matches. Note: AABB counts as one match but the winning player will receive both piles with AA and BB; on the other hand, AAAB gives you AAA but not the pile with B. If the _matchingPlayers set is empty after a call to checkMatch, then there is no match.

This requires a new method to be added to Card, matchesCard:, that checks whether the values of the two cards are equal. Only the values are important, the suit may be different. Add this new method to Card.h and Card.m:

- (BOOL)matchesCard:(Card *)otherCard
{
	NSParameterAssert(otherCard != nil);
	return self.value == otherCard.value;
}

Back to Game.m. You need to call checkMatch in two places, after a card has been turned, but also after the active player has recycled his cards because that removes open cards from the table. Add the call to turnCardForPlayer:

- (void)turnCardForPlayer:(Player *)player
{
	. . .

	[self checkMatch];
}

And to recycleCardsForActivePlayer,

- (void)recycleCardsForActivePlayer
{
	Player *player = [self activePlayer];

	NSArray *recycledCards = [player recycleCards];
	NSAssert([recycledCards count] > 0, @"Should have cards to recycle");

	[self checkMatch];

	[self.delegate game:self didRecycleCards:recycledCards forPlayer:player];
}

You can run the app again now and the Xcode debug output pane should show you at any given time what the matching players are, for example:

Snap[3011:707] Matching players: {(
    <Player: 0x1b3b80> peerID = 348782535, name = Matthijs iPod, position = 0,
    <Player: 0x1b87e0> peerID = 1389204336, name = Simulator, position = 2

)}

Note that you call checkMatch on both the server and the client. It's not really necessary on the client, because the client doesn't do anything with that information, but it won't hurt either.

Tapping the Snap! button still ignores the set of matching players, so let's add some new logic to playerCalledSnap:

- (void)playerCalledSnap:(Player *)player
{
	if (self.isServer)
	{
		if (_haveSnap)
		{
			Packet *packet = [PacketPlayerCalledSnap packetWithPeerID:player.peerID snapType:SnapTypeTooLate matchingPeerIDs:nil];
			[self sendPacketToAllClients:packet];

			[self.delegate game:self playerCalledSnapTooLate:player];
		}
		else
		{
			[NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(activateNextPlayer) object:nil];

			_haveSnap = YES;

			if ([_matchingPlayers count] == 0)
			{
				Packet *packet = [PacketPlayerCalledSnap packetWithPeerID:player.peerID snapType:SnapTypeWrong matchingPeerIDs:nil];
				[self sendPacketToAllClients:packet];

				[self.delegate game:self playerCalledSnapWithNoMatch:player];
			}
			else
			{
				[self.delegate game:self player:player calledSnapWithMatchingPlayers:_matchingPlayers];
			}
		}
	}
	else
	{
		Packet *packet = [PacketPlayerShouldSnap packetWithPeerID:_session.peerID];
		[self sendPacketToServer:packet];
	}
}

You now look at the [_matchingPlayers count]. If the count is more than zero then the Snap! is valid and you call the new game:player:calledSnapWithMatchingPlayers: delegate method. Add the signature for this to GameDelegate in Game.h:

- (void)game:(Game *)game player:(Player *)player calledSnapWithMatchingPlayers:(NSSet *)matchingPlayers;

And implement the method in GameViewController.m:

- (void)game:(Game *)game player:(Player *)player calledSnapWithMatchingPlayers:(NSSet *)matchingPlayers
{
	[_correctMatchSound play];

	[self showSplashView:self.correctSnapImageView forPlayer:player];
	[self showSnapIndicatorForPlayer:player];
	[self performSelector:@selector(hideSnapIndicatorForPlayer:) withObject:player afterDelay:1.0f];

	self.turnOverButton.enabled = NO;
	self.centerLabel.text = NSLocalizedString(@"*** Match! ***", @"Status text: player called snap with a match");

	NSArray *array = [NSArray arrayWithObjects:player, matchingPlayers, nil];
	[self performSelector:@selector(playerWillReceiveCards:) withObject:array afterDelay:1.0f];
}

This should be fairly straightforward and not so different from before, except maybe the last bit. After one second you call the playerWillReceiveCards: method, but because you want to pass it two parameters -- the player and matchingPlayers objects -- you have to put them into a temporary NSArray.

Add the playerWillReceiveCards: method:

- (void)playerWillReceiveCards:(NSArray *)array
{
	Player *player = [array objectAtIndex:0];
	NSSet *matchingPlayers = [array objectAtIndex:1];

	if (player.position == PlayerPositionBottom)
		self.centerLabel.text = NSLocalizedString(@"You Receive Cards", @"Status text: player will receive cards from the others");
	else
		self.centerLabel.text = [NSString stringWithFormat:NSLocalizedString(@"%@ Receives Cards", @"Status text: player will receive cards from the others"), player.name];

	for (PlayerPosition p = player.position; p < player.position + 4; ++p)
	{
		Player *otherPlayer = [self.game playerAtPosition:p % 4];
		if (otherPlayer != nil && [matchingPlayers containsObject:otherPlayer])
		{
			NSArray *cards = [otherPlayer giveAllOpenCardsToPlayer:player];
			for (Card *card in cards)
			{
				CardView *cardView = [self cardViewForCard:card];
				[cardView animateCloseAndMoveFromPlayer:otherPlayer toPlayer:player withDelay:0.0f];
			}
		}
	}

	[self performSelector:@selector(afterMovingCardsForPlayer:) withObject:player afterDelay:1.0f];
}

This loops through the players based on their positions -- so it happens in the same order on both the server and all clients -- and if a player is in the matchingPlayers set, it is instructed to give all its open cards to the player who yelled "Snap!" first. You use an existing animation method on CardView to move the cards around on the screen.

When all of that is done, you call afterMovingCardsForPlayer:, which is the same method you called after paying cards on a wrong Snap!, so that the Game object can decide what to do next (activate the next player, recycle the cards of the active player, and so on).

The giveAllOpenCardsToPlayer: method already exists in Player.m, as you used that for recycling, so all you need to do is add its signature to Player.h:

- (NSArray *)giveAllOpenCardsToPlayer:(Player *)otherPlayer;

You can try it out now. Tapping Snap! when there is a match should show a smiley face and give that player all the cards, but -- as usual -- moving the cards only works on the server yet. The server still needs to let the clients know that a successful Snap! was made by sending them a PacketPlayerCalledSnap message.

Add this logic to playerCalledSnap: in Game.m, in the following if-statement:

			if ([_matchingPlayers count] == 0)
			{
				. . .
			}
			else
			{
				NSMutableSet *matchingPeerIDs = [NSMutableSet setWithCapacity:4];
				[_matchingPlayers enumerateObjectsUsingBlock:^(Player *obj, BOOL *stop)
				{
					[matchingPeerIDs addObject:obj.peerID];
				}];

				Packet *packet = [PacketPlayerCalledSnap packetWithPeerID:player.peerID snapType:SnapTypeCorrect matchingPeerIDs:matchingPeerIDs];
				[self sendPacketToAllClients:packet];

				[self.delegate game:self player:player calledSnapWithMatchingPlayers:_matchingPlayers];
			}

You now create a new NSMutableSet that contains just the peerIDs from the matching players (recall that _matchingPlayers contains the actual Player objects), and you give that new set to the packet. The PacketPlayerCalledSnap class doesn't do anything yet with those peer IDs, so let's add that logic as well.

Change its packetWithData: and addPayloadToData: methods in PacketPlayerCalledSnap.m to:

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

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

	SnapType snapType = [data rw_int8AtOffset:offset];
	offset += 1;

	NSMutableSet *matchingPeerIDs = nil;
	if (snapType == SnapTypeCorrect)
	{
		matchingPeerIDs = [NSMutableSet setWithCapacity:4];
		while (offset < [data length])
		{
			NSString *matchingPeerID = [data rw_stringAtOffset:offset bytesRead:&count];
			offset += count;

			[matchingPeerIDs addObject:matchingPeerID];
		}
	}

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

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

	[self.matchingPeerIDs enumerateObjectsUsingBlock:^(NSString *obj, BOOL *stop)
	{
		[data rw_appendString:obj];
	}];
}

What remains is handling this packet on the client, so in Game.m change the handlePlayerCalledSnapPacket: method to:

		if (snapType == SnapTypeTooLate)
		{
			. . .
		}
		else if (snapType == SnapTypeWrong)
		{
			. . .
		}
		else
		{
			NSMutableSet *matchingPlayers = [NSMutableSet setWithCapacity:4];
			[packet.matchingPeerIDs enumerateObjectsUsingBlock:^(NSString *obj, BOOL *stop)
			{
				Player *player = [self playerWithPeerID:obj];
				if (player != nil)
					[matchingPlayers addObject:player];
			}];

			[self.delegate game:self player:player calledSnapWithMatchingPlayers:matchingPlayers];
		}

This does the opposite from before. It reconstructs the matchingPlayers set using the peer IDs from the Packet and then calls the delegate to make the animations happen.

And that's it. Now the clients will also show the smiley face and the cards being moved around.

The smiley that's shown when there is a match

Try this: Play a game with one client. It's the server player's turn. Turn over your card and immediately tap the Snap! button. The client will now show the wrong card being turned over. (It works fine the other way around when the client player does this.)

The problem is that the server turns over the top card before its player has to pay a card to the client, but the client first handles the logic for paying the card and does not turn over the card until it receives the ActivatePlayer packet. So turning cards over at the receipt of the ActivatePlayer message has come back to bite us.

I can think of a few solutions here, but because this tutorial is already way too long, you'll suffice with a simple workaround: after the local player turns over his card, you simply disable the Snap! button and enable it again when the next player is made active. This prevents the problem from ever happening, but it's also a bit unfair to the player who is turning over his card because only his Snap! button is disabled, not the buttons for the other players -- if the others are quick enough to react, they have a slight advantage here.

In GameViewController.m, add the following line to game:player:turnedOverCard:

This disables the Snap! button if the local player -- the one in the bottom position -- was the one who turned over the card.

	self.snapButton.enabled = (player.position != PlayerPositionBottom);
	self.snapButton.enabled = (player.position != PlayerPositionBottom);

Contributors

Over 300 content creators. Join our team.