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

Matthijs Hollemans
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, third, fourth, fifth, and sixth parts of the series, you created most of the game, including the networking infrastructure, card animations, and gameplay.

In this seventh and final part of the series, you will finally wrap up the game! We will finish up the “Snap!” logic, fix some edge cases, add win/lose detection, and even implement a single player mode with a computer player!

Keep reading to finally finish this epic series!

Reliable vs unreliable

So far all the network transmissions you’ve sent were in reliable mode, which guarantees messages will be delivered with their contents 100% intact.

The problem with reliable mode is that on a bad network connection that drops a lot of packets, message sending can be very slow. If a message couldn’t be delivered, the networking stack will try again and again and again, until it either gives up completely and disconnects or the message is successfully delivered.

Also, even though the packets are guaranteed to arrive, the problem is the order in which they arrive is not guaranteed. You’ve seen the problems this can cause and how to work around them in the previous part of the series.

How to deal with these issues? Well, there is another method of sending data over the network and that is in unreliable mode.

With unreliable mode the only guarantee is that if the message arrives it will be 100% complete, but it’s sent only once and if something goes wrong along the way, the entire message is dropped. The recipient will never know about it.

When you’re writing a multiplayer network game that needs to be real-time, you’d usually send unreliable messages, because you don’t need the retry mechanism. By the time the retried message arrives at its destination it is likely no longer relevant. Therefore it’s better to send everything unreliably and hope that it arrives, and have some mechanism in place to deal with messages that get lost.

Tip: In a real-time game that sends position updates using unreliable packets, you’d use a technique called “dead reckoning” to estimate where the game objects will be next, and then adjust when you receive the next network message. Every second or so a larger packet with a full status update is sent, just so that clients are able to bring themselves up-to-date and get the complete picture again.

In Snap! you want the PlayerShouldSnap packets to arrive at the server as quickly as possible. Therefore you will now send them unreliably in order to lose as little time as possible.

This means that sometimes when a player taps the Snap! button their packets will get lost along the way. C’est la vie. Most of the time the packets will arrive properly anyway.

You’ll add a new property to Packet that indicates whether this packet should be sent reliably or unreliably. In Packet.h:

@property (nonatomic, assign) BOOL sendReliably;

Synthesize this property in Packet.m:

@synthesize sendReliably = _sendReliably;

Set its value to YES in the init method because by default you want all packets to be sent reliably:

- (id)initWithType:(PacketType)packetType
{
	if ((self = [super init]))
	{
		self.packetNumber = -1;
		self.packetType = packetType;
		self.sendReliably = YES;
	}
	return self;
}

The only packet you will send unreliably in our app is PacketPlayerShouldSnap. Change its init method to:

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

The actual sending of the packets happens in two places in Game.m, in the sendPacketToAllClients: and sendPacketToServer: methods. In both methods change the line that initializes the dataMode variable to:

	GKSendDataMode dataMode = packet.sendReliably ? GKSendDataReliable : GKSendDataUnreliable;

And that’s it. You should test the app to see that tapping the Snap! button on the client still shows the red X on the server, but you won’t notice any differences in how the app behaves unless you’re on a really crappy network.

“Too late”

The first player to yell “Snap!” wins (or has to pay up if there are no matching cards). For any other players who yell “Snap!” too late, you simply show the speech bubble. To keep track of this you use a new boolean instance variable:

@implementation Game
{
	. . .
	BOOL _haveSnap;
}

You reset this value to NO at the top of beginRound:

- (void)beginRound
{
	_haveSnap = NO;
	. . .
}

And in turnCardForPlayer:

- (void)turnCardForPlayer:(Player *)player
{
	_haveSnap = NO;
	. . .
}

That means after the active player has turned over his card, everyone can yell “Snap!” again.

Finally, playerCalledSnap: becomes:

- (void)playerCalledSnap:(Player *)player
{
	if (self.isServer)
	{
		if (_haveSnap)
		{
			[self.delegate game:self playerCalledSnapTooLate:player];
		}
		else
		{
			_haveSnap = YES;
 
			[self.delegate game:self playerCalledSnapWithNoMatch:player];
		}
	}
	else
	{
		Packet *packet = [PacketPlayerShouldSnap packetWithPeerID:_session.peerID];
		[self sendPacketToServer:packet];
	}
}

There is now a new delegate method, so add it to the protocol in Game.h:

- (void)game:(Game *)game playerCalledSnapTooLate:(Player *)player;

Add the implementation to GameViewController.m:

- (void)game:(Game *)game playerCalledSnapTooLate:(Player *)player
{
	[self showSnapIndicatorForPlayer:player];
	[self performSelector:@selector(hideSnapIndicatorForPlayer:) withObject:player afterDelay:1.0f];
}

Here you just show the speech bubble and then hide it again after one second. Try it out, for the second player who taps the Snap! button you only show the speech bubble but no red X. Again, for now the speech bubbles will only appear on the server but you’ll remedy that next.

Showing the speech bubbles on the clients

Right now when a client yells snap, the server doesn’t notify the other clients that this has occurred – hence they can’t update their displays.

You’ll fix this now by making the server send a packet to inform all clients when someone yells “Snap!” — even the client that did so.

This packet will also tell the clients whether it was a good snap (there were matching cards), a bad snap (no matches), or whether it was too late (another player beat you to it). The way you implement this game, it is the task of the server to make that judgement and tell it to the clients.

Add a new Packet subclass to the project, named PacketPlayerCalledSnap. Replace PacketPlayerCalledSnap.h with the following:

#import "Packet.h"
 
typedef enum
{
	SnapTypeWrong = 1,
	SnapTypeTooLate,
	SnapTypeCorrect,
}
SnapType;
 
@interface PacketPlayerCalledSnap : Packet
 
@property (nonatomic, copy) NSString *peerID;
@property (nonatomic, assign) SnapType snapType;
@property (nonatomic, strong) NSSet *matchingPeerIDs;
 
+ (id)packetWithPeerID:(NSString *)peerID snapType:(SnapType)snapType matchingPeerIDs:(NSSet *)matchingPeerIDs;
 
@end

The snapType property determines whether the call was good, bad or too late. The matchingPeerIDs property contains a list of the players with matching cards; you’ll ignore this property for now.

Replace PacketPlayerCalledSnap.m with:

#import "PacketPlayerCalledSnap.h"
#import "Player.h"
#import "NSData+SnapAdditions.h"
 
@implementation PacketPlayerCalledSnap
 
@synthesize peerID = _peerID;
@synthesize snapType = _snapType;
@synthesize matchingPeerIDs = _matchingPeerIDs;
 
+ (id)packetWithPeerID:(NSString *)peerID snapType:(SnapType)snapType matchingPeerIDs:(NSSet *)matchingPeerIDs
{
	return [[[self class] alloc] initWithPeerID:peerID snapType:snapType matchingPeerIDs:matchingPeerIDs];
}
 
- (id)initWithPeerID:(NSString *)peerID snapType:(SnapType)snapType matchingPeerIDs:(NSSet *)matchingPeerIDs
{
	if ((self = [super initWithType:PacketTypePlayerCalledSnap]))
	{
		self.peerID = peerID;
		self.snapType = snapType;
		self.matchingPeerIDs = matchingPeerIDs;
	}
	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;
 
	SnapType snapType = [data rw_int8AtOffset:offset];
 
	NSMutableSet *matchingPeerIDs = nil;
 
	return [[self class] packetWithPeerID:peerID snapType:snapType matchingPeerIDs:matchingPeerIDs];
}
 
- (void)addPayloadToData:(NSMutableData *)data
{
	[data rw_appendString:self.peerID];
	[data rw_appendInt8:self.snapType];
}
 
@end

In Packet.m, import the new file:

#import "PacketPlayerCalledSnap.h"

And add a case-statement to packetWithData:

		case PacketTypePlayerCalledSnap:
			packet = [PacketPlayerCalledSnap packetWithData:data];
			break;

Also add an import to Game.m:

#import "PacketPlayerCalledSnap.h"

Then change the top of playerCalledSnap: to:

- (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
		{
			_haveSnap = YES;
 
			Packet *packet = [PacketPlayerCalledSnap packetWithPeerID:player.peerID snapType:SnapTypeWrong matchingPeerIDs:nil];
			[self sendPacketToAllClients:packet];
 
			[self.delegate game:self playerCalledSnapWithNoMatch:player];
		}
	}
	else
	{
		Packet *packet = [PacketPlayerShouldSnap packetWithPeerID:_session.peerID];
		[self sendPacketToServer:packet];
	}
}

You send the PacketPlayerCalledSnap packet with type “TooLate” to all clients. To handle this packet on the client side, add a new case-statement to clientReceivedPacket:

		case PacketTypePlayerCalledSnap:
			if (_state == GameStatePlaying)
			{
				[self handlePlayerCalledSnapPacket:(PacketPlayerCalledSnap *)packet];
			}
			break;

This uses a new method, handlePlayerCalledSnapPacket:, so add that as well:

- (void)handlePlayerCalledSnapPacket:(PacketPlayerCalledSnap *)packet
{
	NSString *peerID = packet.peerID;
	SnapType snapType = packet.snapType;
 
	Player *player = [self playerWithPeerID:peerID];
	if (player != nil)
	{
		if (snapType == SnapTypeTooLate)
		{
			[self.delegate game:self playerCalledSnapTooLate:player];
		}
		else if (snapType == SnapTypeWrong)
		{
			[self.delegate game:self playerCalledSnapWithNoMatch:player];
		}
	}
}

This simply calls the same delegate methods as the server, but now that happens only after receiving a packet from the server, not immediately when the Snap! button is tapped. Try it out. Tapping the Snap! button, either on the client or on a server, should show the speech bubbles (and the big red X) on the client as well.

Paying cards

When a player yells “Snap!” when there are no matching cards on the table, he has to pay one card to each of the other players. Because you haven’t built the logic for determining whether there is actually a match on the table, right now the first player to yell “Snap!” is always wrong. That’s OK because it makes it easy to build and test the logic for paying cards.

In GameViewController.m‘s game:playerCalledSnapWithNoMatch:, add the following line:

- (void)game:(Game *)game playerCalledSnapWithNoMatch:(Player *)player
{
	. . .
 
	[self performSelector:@selector(playerMustPayCards:) withObject:player afterDelay:1.0f];
}

This will call the new method playerMustPayCards: one second after you’ve shown the big red X. Add the implementation for this method next:

- (void)playerMustPayCards:(Player *)player
{
	self.centerLabel.text = [NSString stringWithFormat: NSLocalizedString(@"%@ Must Pay", @"Status text: player must pay cards to the others"), player.name];
	[self.game playerMustPayCards:player];
}

Here you call a new method from Game, playerMustPayCards:, which will move the cards around from one Player to the others in the data model. Add the method signature to Game.h:

- (void)playerMustPayCards:(Player *)player;

And the method itself to Game.m:

- (void)playerMustPayCards:(Player *)player
{
	_mustPayCards = YES;
 
	int cardsNeeded = 0;
	for (PlayerPosition p = player.position; p < player.position + 4; ++p)
	{
		Player *otherPlayer = [self playerAtPosition:p % 4];
		if (otherPlayer != nil && otherPlayer != player && [otherPlayer totalCardCount] > 0)
			++cardsNeeded;
	}	
 
	if (cardsNeeded > [player.closedCards cardCount])
	{
		NSArray *recycledCards = [player recycleCards];
		if ([recycledCards count] > 0)
		{
			[self.delegate game:self didRecycleCards:recycledCards forPlayer:player];
			return;
		}
	}
 
	[self resumeAfterRecyclingCardsForPlayer:player];
}

This method first counts how many cards the player needs to pay. Only other players who have at least one open or closed card will receive a card — players with no cards left do no longer participate in the game. Then you check whether the player actually has enough cards on his closed stack; if not, then you first recycle those cards. The recycling procedure will call resumeAfterRecyclingCardsForPlayer: when it is done, so that’s where you continue (even if no recycling needs to happen).

The new boolean _mustPayCards tells resumeAfterRecyclingCardsForPlayer: that it still has a task to perform after the recycling completes. Replace that method with:

- (void)resumeAfterRecyclingCardsForPlayer:(Player *)player
{
	if (_mustPayCards)
	{
		for (PlayerPosition p = player.position; p < player.position + 4; ++p)
		{
			Player *otherPlayer = [self playerAtPosition:p % 4];
			if (otherPlayer != nil && otherPlayer != player && [otherPlayer totalCardCount] > 0)
			{
				Card *card = [player giveTopmostClosedCardToPlayer:otherPlayer];
				if (card != nil)
					[self.delegate game:self player:player paysCard:card toPlayer:otherPlayer];
			}
		}
	}
}

In the case of a normal recycle you don’t do anything here, only if the player must pay cards. You loop through all the players — based on position, so you see the players in the same order on
both the server and the clients, otherwise the cards get mixed up — and call giveTopmostClosedCardToPlayer: to change the data model. You also call a new delegate method to perform the animation.

Add the _mustPayCards boolean to the instance variables:

@implementation Game
{
	. . .
	BOOL _mustPayCards;
}

Just to make sure you start with a clean slate, you’ll set this variable to NO in beginRound:

- (void)beginRound
{
	_mustPayCards = NO;
	. . .
}

Add the new giveTopmostClosedCardToPlayer: method to both Player.h and Player.m:

- (Card *)giveTopmostClosedCardToPlayer:(Player *)otherPlayer
{
	Card *card = [self.closedCards topmostCard];
	if (card != nil)
	{
		[otherPlayer.closedCards addCardToBottom:card];
		[self.closedCards removeTopmostCard];
	}
	return card;
}

Also add the signature for the new delegate method the GameDelegate protocol in Game.h:

- (void)game:(Game *)game player:(Player *)fromPlayer paysCard:(Card *)card toPlayer:(Player *)toPlayer;

Implement this method in GameViewController.m:

- (void)game:(Game *)game player:(Player *)fromPlayer paysCard:(Card *)card toPlayer:(Player *)toPlayer
{
	CardView *cardView = [self cardViewForCard:card];
	[cardView animatePayCardFromPlayer:fromPlayer toPlayer:toPlayer];
}

And finally add the new animation method to both CardView.h and CardView.m:

- (void)animatePayCardFromPlayer:(Player *)fromPlayer toPlayer:(Player *)toPlayer
{
	[self.superview sendSubviewToBack:self];
 
	CGPoint point = [self centerForPlayer:toPlayer];
	_angle = [self angleForPlayer:toPlayer];
 
	[UIView animateWithDuration:0.4f
		delay:0.0f
		options:UIViewAnimationOptionCurveEaseOut
		animations:^
		{
			self.center = point;
			self.transform = CGAffineTransformMakeRotation(_angle);
		}
		completion:nil];
}

That’s quite a few changes, but now when a player yells “Snap!” when there is no match, the GameViewController says to Game, “that player has to pay the cards”, and then Game tells GameViewController to move those cards around. The reason that the controller tells Game what to do next has to do with timing. Game doesn’t know when its delegate is done performing the animations, so it depends on its delegate to tell it to perform the next step.

You should be able to run the app now. Tap the Snap! button for one of the players and on both the client and the server you should see a card fly from that player to each of the others.

Note: Recall that when a client disconnects, the server sends a list of redistributed cards to each of the other clients. After someone yells “Snap!”, cards also have to move from one player to another but this time the PlayerCalledSnap packet does not include such a list of cards. Instead, the clients figure out for themselves what the cards are. Both approaches are equally valid, although in the latter case it is vital that all clients and the server agree on the game state, otherwise one client may show different cards from the others, which sort-of spoils the game.

There is still that pesky issue that the game no longer lets you turn over cards after you’ve tapped Snap!. Change playerMustPayCards: in GameViewController.m to:

- (void)playerMustPayCards:(Player *)player
{
	self.centerLabel.text = [NSString stringWithFormat: NSLocalizedString(@"%@ Must Pay", @"Status text: player must pay cards to the others"), player.name];
	[self.game playerMustPayCards:player];
	[self performSelector:@selector(afterMovingCardsForPlayer:) withObject:player afterDelay:1.0f];
}

This now calls a new method, afterMovingCardsForPlayer:, one second after the cards have been moved around. Add this method to GameViewController.m:

- (void)afterMovingCardsForPlayer:(Player *)player
{
	self.snapButton.enabled = YES;
	self.turnOverButton.enabled = YES;
 
	if ([[self.game playerAtPosition:PlayerPositionBottom] totalCardCount] == 0)
		self.snapButton.hidden = YES;
}

Here you re-enable the buttons, but if the local player has no more cards available, he no longer participates and you hide his Snap! button.

Try it out, now turning over cards works again after someone yells “Snap!”. Also do this: flip over all the cards until there are none left on the closed stack — this is quicker if you limit the number of cards on the deck — and then press the Snap! button for that player. His cards should now first be recycled.

While testing this, you’ll probably run into some situations that aren’t handled properly yet. For example, a player yells “Snap!” in a two-player game while he has one closed card left and it is his turn. The closed card gets paid but now there are no cards left for him to turn over. His open stack should be recycled, but that currently doesn’t happen — you only check whether recycling needs to happen at the moment a player is activated. So you need to add some extra logic that should happen after the player paid his cards.

In GameViewController.m, change afterMovingCardsForPlayer: to:

- (void)afterMovingCardsForPlayer:(Player *)player
{
	BOOL changedPlayer = [self.game resumeAfterMovingCardsForPlayer:player];
 
	self.snapButton.enabled = YES;
	self.turnOverButton.enabled = YES;
 
	if ([[self.game playerAtPosition:PlayerPositionBottom] totalCardCount] == 0)
		self.snapButton.hidden = YES;
 
	if (!changedPlayer)
		[self showIndicatorForActivePlayer];
}

You now call the resumeAfterMovingCardsForPlayer: method on Game. This will perform a number of checks — should the active player recycle his cards, and so on — and returns YES if a new player will be made active, which can happen if the active player no longer has any cards left. If the active player didn’t change, you call showIndicatorForActivePlayer again in other to change the text on the center label back to “Player X’s turn”.

Add the resumeAfterMovingCardsForPlayer: method to Game.h and Game.m:

- (BOOL)resumeAfterMovingCardsForPlayer:(Player *)player
{
	_mustPayCards = NO;
 
	if ([[self activePlayer] totalCardCount] == 0 || _hasTurnedCard)
	{
		if (self.isServer)
			[self activateNextPlayer];
 
		return YES;
	}
	else if ([[self activePlayer] shouldRecycle])
	{
		[self recycleCardsForActivePlayer];
		return NO;
	}
 
	return NO;
}

If the active player has no more cards — he has just paid them all to the other players — then the server needs to activate the next player. Otherwise, if the active player no longer has any closed cards left, they should be recycled first.

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:

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

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

Game Over man, Game Over!

You’re now at a point where you can build in the end-of-round logic that tests whether there is a winner. The winner is the player who now has all of the cards, i.e. all of the other players have zero cards left.

Add the following method to Game.m:

- (Player *)checkWinner
{
	__block Player *winner;
 
	[_players enumerateKeysAndObjectsUsingBlock:^(id key, Player *obj, BOOL *stop)
	{
		if ([obj totalCardCount] == 52)
		{
			winner = obj;
			*stop = YES;
		}
	}];
 
	return winner;
}

The checkWinner method loops through the players and if it finds one with 52 cards, then there is a winner. If not, it returns nil. You will call this method from resumeAfterMovingCardsForPlayer:. That’s the logical place for it because this is where you end up after someone has yelled “Snap!” and you did all the animations. The changes are as follows:

- (BOOL)resumeAfterMovingCardsForPlayer:(Player *)player
{
	_mustPayCards = NO;
 
	Player *winner = [self checkWinner];
	if (winner != nil)
	{
		[self endRoundWithWinner:winner];
		return YES;
	}
 
	. . .
}

The endRoundWithWinner: method is pretty simple:

- (void)endRoundWithWinner:(Player *)winner
{
	#ifdef DEBUG
	NSLog(@"End of the round, winner is %@", winner);
	#endif
 
	_state = GameStateGameOver;
 
	winner.gamesWon++;
	[self.delegate game:self roundDidEndWithWinner:winner];
}

You change the state to GameStateGameOver, increment the gamesWon property of the winning Player, and call a new delegate method. Add this method to GameDelegate:

- (void)game:(Game *)game roundDidEndWithWinner:(Player *)player;

And implement it in GameViewController.m:

- (void)game:(Game *)game roundDidEndWithWinner:(Player *)player
{
	self.centerLabel.text = [NSString stringWithFormat:NSLocalizedString(@"** Winner: %@ **", @"Status text: winner of a round"), player.name];
 
	self.snapButton.hidden = YES;
	self.nextRoundButton.hidden = !game.isServer;
 
	[self updateWinsLabels];
	[self hideActivePlayerIndicator];
}

Try it out. For testing purposes you may want to reduce the number of cards on the deck (the way you did it before). If you do, make sure you also change the number of cards that checkWinner checks for from 52 to the number on the deck.

The end of a round

The next round

The delegate method also enabled the “next round” button on the server (bottom-right corner). Tapping this button will start a new round and deals out new cards. The UIButton is already hooked up to the nextRoundAction: method in GameViewController.m. Replace that method with:

- (IBAction)nextRoundAction:(id)sender
{
	[self.game nextRound];
}

Add the nextRound method to Game.h and Game.m:

- (void)nextRound
{
	[NSObject cancelPreviousPerformRequestsWithTarget:self];
 
	_state = GameStateDealing;
	_firstTime = YES;
	_busyDealing = YES;
 
	[self.delegate gameDidBeginNewRound:self];
 
	[_players enumerateKeysAndObjectsUsingBlock:^(id key, Player *player, BOOL *stop)
	{
		[player.closedCards removeAllCards];
		[player.openCards removeAllCards];
	}];
 
	if (self.isServer)
	{
		[self pickNextStartingPlayer];
		[self dealCards];
	}
}

The nextRound method is similar to beginGame, except that beginGame always starts from a clean slate while nextRound has to clean up the mess from the previous round first. After the dealCards method is called, the new round starts exactly the way you’ve seen before — the delegate animates the dealing of the cards and then called Game’s beginRound method, which activates the starting player, and so on.

You need to add a few additional methods. The pickNextStartingPlayer method looks at who started the previous round and then picks the next player clockwise to start the new round:

- (void)pickNextStartingPlayer
{
	do
	{
		_startingPlayerPosition++;
		if (_startingPlayerPosition > PlayerPositionRight)
			_startingPlayerPosition = PlayerPositionBottom;
	}
	while ([self playerAtPosition:_startingPlayerPosition] == nil);
 
	_activePlayerPosition = _startingPlayerPosition;
}

There is also a new delegate method, gameDidBeginNewRound:. Add this to the GameDelegate protocol in Game.h:

- (void)gameDidBeginNewRound:(Game *)game;

And implement it in GameViewController.m:

- (void)gameDidBeginNewRound:(Game *)game
{
	[self removeAllRemainingCardViews];
}

This calls a helper method, removeAllRemainingCardViews, to remove all the existing CardView objects from the screen, in order to make room for the new cards:

- (void)removeAllRemainingCardViews
{
	for (PlayerPosition p = PlayerPositionBottom; p <= PlayerPositionRight; ++p)
	{
		Player *player = [self.game playerAtPosition:p];
		if (player != nil)
		{
			[self removeRemainingCardsFromStack:player.openCards forPlayer:player];
			[self removeRemainingCardsFromStack:player.closedCards forPlayer:player];
		}
	}
}
 
- (void)removeRemainingCardsFromStack:(Stack *)stack forPlayer:(Player *)player
{
	NSTimeInterval delay = 0.0f;
 
	for (int t = 0; t < [stack cardCount]; ++t)
	{
		NSUInteger index = [stack cardCount] - t - 1;
		CardView *cardView = [self cardViewForCard:[stack cardAtIndex:index]];
		if (t < 5)
		{
			[cardView animateRemovalAtRoundEndForPlayer:player withDelay:delay];
			delay += 0.05f;
		}
		else
		{
			[cardView removeFromSuperview];
		}
	}
}

Because there will be 52 CardViews on the screen (all belonging to one player), animating them all would take a while and it’s not really that interesting to see all 52 views move off the screen. So instead, you only animate the top 5 cards from the stack, which is enough to give the user the impression that the table is being wiped clean, and simply remove all the other card views without an animation.

This requires a new method to be added to CardView.h and CardView.m:

- (void)animateRemovalAtRoundEndForPlayer:(Player *)player withDelay:(NSTimeInterval)delay
{
	[UIView animateWithDuration:0.2f
		delay:delay
		options:UIViewAnimationOptionCurveEaseIn
		animations:^
		{
			self.center = CGPointMake(self.center.x, self.superview.bounds.size.height + CardHeight);
		}
		completion:^(BOOL finished)
		{
			[self removeFromSuperview];
		}];
}

You can try it out now, but the next round starts only at the server. That is because the server hasn’t told the clients yet that a new round is starting. You could introduce a new packet type for that, but what actually happens is that the server already sends the clients a DealCards packet (because you call the dealCards method), except that the clients weren’t expecting this packet at that time so they ignored it.

You can say that if a client is in the “game over” state, any DealCards packet that is received will begin the new round. Change the case-statement in clientReceivedPacket: in Game.m to:

		case PacketTypeDealCards:
			if (_state == GameStateGameOver)
			{
				[self nextRound];
			}
			if (_state == GameStateDealing)
			{
				[self handleDealCardsPacket:(PacketDealCards *)packet];
			}
			break;

Notice that the second if-statement is not an “else if”. After calling nextRound, the state equals GameStateDealing, and you have to actually deal out the cards.

W00t, that’s it for the multiplayer game! :-)

Note: The game still needs quite a bit of work before it’s ready to go on the App Store. In its current form it’s a bit unfair. The player who hosts the game has an advantage because he sees everything a fraction of a second before the other players. This could be solved by delaying the animations on the server by the average “ping” time.

The restriction that you put in that disables the Snap! button after a player turns a card is also very unfair. That was done to avoid a problem with the networking, but it’s obviously not how you really should handle this.

Regardless of these issues, I hope that this has been a good illustration of everything that’s involved in making networked multiplayer games, and card games in particular. I wanted to show you the difficulties of writing multiplayer code, so that you know to deal with such situations in your own games.

Single player mode

The reason this game has a single player mode is that I actually wrote it as a single-player game first, just to figure out how the gameplay logic should work. As you’ve seen, multiplayer networking code can get pretty complicated and as a general development principle, I try to avoid dealing with too much complexity at once.

For the purposes of explaining everything, however, it made sense that the tutorial started with the multiplayer aspects first. Just to show you how, you’ll now put the single player mode back into the game.

There are basically two ways you can add single-player functionality into a multiplayer game:

  1. Keep the networking logic but instead of sending packets over the network, deliver them immediately to the clientReceivedPacket: method. The client and server logic are handled by one and the same Game object. (Of course, you can also make two different Game objects if that makes sense for your game, one that handles the client logic and one that does the server logic.)
  2. Don’t send any packets, but use timers to call the Game methods directly. This is the approach taken by Snap!, because it makes a bit more sense for our game. Each client has a slightly different view of the game world — player positions are rotated, for example, so that the client’s own player always sits at the bottom — and that makes it tricky to make Game act as both client and server.

Add the following methods to Game.m:

- (void)startSinglePlayerGame
{
	self.isServer = YES;
 
	Player *player = [[Player alloc] init];
	player.name = NSLocalizedString(@"You", @"Single player mode, name of user (bottom player)");
	player.peerID = @"1";
	player.position = PlayerPositionBottom;
	[_players setObject:player forKey:player.peerID];
 
	player = [[Player alloc] init];
	player.name = NSLocalizedString(@"Ray", @"Single player mode, name of left player");
	player.peerID = @"2";
	player.position = PlayerPositionLeft;
	[_players setObject:player forKey:player.peerID];
 
	player = [[Player alloc] init];
	player.name = NSLocalizedString(@"Lucy", @"Single player mode, name of top player");
	player.peerID = @"3";
	player.position = PlayerPositionTop;
	[_players setObject:player forKey:player.peerID];
 
	player = [[Player alloc] init];
	player.name = NSLocalizedString(@"Steve", @"Single player mode, name of right player");
	player.peerID = @"4";
	player.position = PlayerPositionRight;
	[_players setObject:player forKey:player.peerID];
 
	[self beginGame];
}
 
- (BOOL)isSinglePlayerGame
{
	return (_session == nil);
}

Also add the startSinglePlayerGame method signature to Game.h. Note: You set the isServer property to YES because you want the game to act as the server but with four players instead of one.

Now all the way back in MainViewController.m (remember that one?), replace the following method:

- (IBAction)singlePlayerGameAction:(id)sender
{
	if (_buttonsEnabled)
	{
		[self performExitAnimationWithCompletionBlock:^(BOOL finished)
		{
			[self startGameWithBlock:^(Game *game)
			{
				[game startSinglePlayerGame];
			}];
		}];
	}
}

You can run the game now and tap the Single Player Game button from the main menu:

Single player game

There are two things you should notice: the computer players (the ones on the left, top and right) don’t actually turn over their cards, and the Xcode debug output pane says this:

Error sending data to clients: (null)

Let’s fix this second issue first. The server still tries to send out DealCards and ActivatePlayer packets, even though there is no one to send them to. To prevent this, change sendPacketToAllClients: in Game.m to:

- (void)sendPacketToAllClients:(Packet *)packet
{
	if ([self isSinglePlayerGame])
		return;
 
	. . .
}

As a defensive programming measure, you should also add an assertion to sendPacketToServer:

- (void)sendPacketToServer:(Packet *)packet
{
	NSAssert(![self isSinglePlayerGame], @"Should not send packets in single player mode");
 
	. . .
}

In the quitGameWithReason: method, change the if-statement to:

	if (reason == QuitReasonUserQuit && ![self isSinglePlayerGame])
	{
		. . .
	}

Now you should no longer get any networking errors (because you no longer do any networking).

In beginRound, make the following change:

- (void)beginRound
{
	if ([self isSinglePlayerGame])
		_state = GameStatePlaying;
 
	. . .
}

The reason for this is that in multiplayer mode, the game state changes from GameStateDealing to GameStatePlaying after the server received ClientDealtCards packets from all clients. But because that never happens now, you have to change the state manually here.

There are two things that the computer players need to do: 1) turn over their cards, 2) yell “Snap!” whenever they detect a match. You’ll do the card flipping first. Change the activatePlayerAtPosition: method to the following:

- (void)activatePlayerAtPosition:(PlayerPosition)playerPosition
{
	_hasTurnedCard = NO;
 
	if ([self isSinglePlayerGame])
	{
		if (_activePlayerPosition != PlayerPositionBottom)
			[self scheduleTurningCardForComputerPlayer];
	}
	else if (self.isServer)
	{
		NSString *peerID = [self activePlayer].peerID;
		Packet* packet = [PacketActivatePlayer packetWithPeerID:peerID];
		[self sendPacketToAllClients:packet];
	}
 
	[self.delegate game:self didActivatePlayer:[self activePlayer]];
}

Only the first if-statement is new. If this is a single-player game and the active player is not the user (the one at the bottom), then call scheduleTurningCardForComputerPlayer to turn over the card. It doesn’t turn the card immediately but after a random delay, to keep things more exciting. Add this new method:

- (void)scheduleTurningCardForComputerPlayer
{
	NSTimeInterval delay = 0.5f + RANDOM_FLOAT() * 2.0f;
	[self performSelector:@selector(turnCardForActivePlayer) withObject:nil afterDelay:delay];
}

Now you can run the app again and play against the computer players. It’ll be a pretty easy game, though, because you’re still the only one who will yell “Snap!”. You will put the logic for that in turnCardForActivePlayer, because that’s the moment when a new match will become visible:

- (void)turnCardForActivePlayer
{
	if ([self isSinglePlayerGame])
	{
		[NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(playerCalledSnap:) object:[self playerAtPosition:PlayerPositionLeft]];
		[NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(playerCalledSnap:) object:[self playerAtPosition:PlayerPositionTop]];
		[NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(playerCalledSnap:) object:[self playerAtPosition:PlayerPositionRight]];
	}
 
	[self turnCardForPlayer:[self activePlayer]];
 
	if ([self isSinglePlayerGame])
	{
		if ([_matchingPlayers count] > 0 || RANDOM_INT(50) == 0)
		{
			for (PlayerPosition p = PlayerPositionLeft; p <= PlayerPositionRight; ++p)
			{
				Player *computerPlayer = [self playerAtPosition:p];
				if ([computerPlayer totalCardCount] > 0)
				{
					NSTimeInterval delay = 0.5f + RANDOM_FLOAT() * 2.0f;
					[self performSelector:@selector(playerCalledSnap:) withObject:computerPlayer afterDelay:delay];
				}
			}
		}
	}
 
	if (self.isServer)
		[self performSelector:@selector(activateNextPlayer) withObject:nil afterDelay:0.5f];
}

First you cancel any “Snap!” messages from computer players that still may be pending. These are for the previous set of cards that you’re on the table and therefore are now out-of-date. Then if there is a match, or randomly every one out of 50 or so times, you’ll schedule a playerCalledSnap: message after a random delay for all computer players. The RANDOM_INT(50) bit is so that even computer players sometimes yell “Snap!” when there aren’t any matches.

There are a few small things you still need to do in order to wrap this up. In playerCalledSnap: you need to cancel any scheduled calls to turnCardForActivePlayer. You don’t want the active player to flip over his top card when someone just yelled “Snap!”, because that might invalidate the Snap!. In a multiplayer game you cannot prevent this from happening — everything happens completely asynchronously there — but it’s also less likely to occur. In a single-player game it happens all the time and looks weird. So change the method to:

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

And finally, in resumeAfterMovingCardsForPlayer:, you have to turn that card if you cancelled it earlier:

- (BOOL)resumeAfterMovingCardsForPlayer:(Player *)player
{
	. . .
 
	else if ([self isSinglePlayerGame] && _activePlayerPosition != PlayerPositionBottom)
	{
		[self scheduleTurningCardForComputerPlayer];
		return NO;
	}
 
	return NO;
}

Believe it or not, that completes the single-player mode! It was pretty easy to add because all the game logic already existed, you just had to build some simple rules to drive the computer AI.

The End

Guess what, you made it to the very end! Queue drum roll please! :]

Phew, that was a loooong tutorial! I hope it was instructive to see how you can make games that look pretty good with just UIKit and UIView-based animation, and how to use Game Kit and GKSession for multiplayer games over Bluetooth and Wi-Fi.

The full Snap! game in action

Here is a sample project with the finished project from the tutorial series.

Credits for the sound effects:

The deck of cards that I scanned the card images from is by Alf Cooke Limited, most likely from the 1950-1970s.

For more information on Game Kit and peer-to-peer connectivity, see Part II of the Game Kit Programming Guide.

If you have any questions or comments on any part of this 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.

Matthijs Hollemans

Matthijs Hollemans is an independent iOS developer and designer who lives to create awesome software. His focus is on iPad apps because he thinks tablets are way cooler than phones. Whenever he is not glued to his screen, Matthijs plays the piano, is into healthy and natural living, and goes out barefoot running. Visit his website at http://www.hollance.com.

User Comments

21 Comments

[ 1 , 2 ]
  • Could you please tell me if the sounds in this tutorial are free for commercial use, or we have to replace them or buy somewhere?
    iosdac
  • iosdac wrote:Could you please tell me if the sounds in this tutorial are free for commercial use, or we have to replace them or buy somewhere?

    The end of the tutorial lists the credits for the sounds. I made these sounds by taking existing sounds from freesound.org and messing them up a bit in Audacity. Check out the sounds on freesound.org to see what rights you have to re-use them.
    Hollance
  • noliv wrote:I loved this tutorial also, big thanks Matthijs, I learnt a lot.
    Turn based Game Center don't fit this kind of game… it's true that the game seems turn based, but you can actually act when it's not your turn. Also, turn based Game Center is better for games where you have very limited time constraints....


    I am building card game based on this tutorial and I must have rooms. Do you say that I can't built to work over Game center or?

    And can somebody tell me most importat question. Can I do game like this over Game center (same game only there is no calling SNAP functionality)? If I can please point me where and how should start/learn that.
    iosdac
  • love this tutorial, learned A LOT :-)

    If i would try to do my own card game is the card deck public domain?
    PeterK
  • Hi I'm currently on the new Maverick OS, Xcode 4.6.2 and after running the game as a single player mode (I'm running the original downloaded file, the one downloaded from this site), the game terminates with an "unrecognized selector sent to instance". I added the breakpoint, and the problem seems to be this line:

    if ([cardView.card isEqualToCard:card])

    In GameViewController.m, inside the method:

    - (CardView *)cardViewForCard:(Card *)card

    I already checked the class Card.h being imported. Everything seems fine. I'm sure it has to do with some kind of dependency I need to perform before compiling the file. Could somebody help me with this please?
    culantro
  • Would love to get this tutorial updated to the GameKit or Multipeer Connectivity framwork and Swift :)
    BirdsAreSpies
[ 1 , 2 ]

Other Items of Interest

Ray's Monthly Newsletter

Sign up to receive a monthly newsletter with my favorite dev links, and receive a free epic-length tutorial as a bonus!

Advertise with Us!

Vote for Our Next Tutorial!

Every week, we alternate between Gaming and Non-Gaming tutorial votes. This week: Non-Gaming!

    Loading ... Loading ...

Last week's winner: How to Make a Simple 2D Game with Metal.

Suggest a Tutorial - Past Results

Hang Out With Us!

Every month, we have a free live Tech Talk - come hang out with us!


Coming up in October: Xcode 6 Tips and Tricks!

Sign Up - October

Our Books

Our Team

Tutorial Team

  • Sam Davies

... 52 total!

Update Team

  • Ray Fix

... 14 total!

Editorial Team

... 22 total!

Code Team

  • Orta Therox

... 3 total!

Subject Matter Experts

  • Richard Casey

... 4 total!