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
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, and fifth parts of the series, you created the networking infrastructure and the start of the game, including dealing cards, choosing the start player, and allowing the player to flip cards over.

In this sixth part of the series, you’ll implement the logic to allow players to take turns, deal with networking edge cases such as out-of-order packets, and start implementing the Snap feature!

Going round the table

Right now, the app will only let a single player turn over cards. That’s a bit sad for the other players, so in this section you’ll give the other players a turn!

That is typical for card games: the players take turns, so you’ll have to come up with a mechanism that makes players active turn-by-turn and then send the results of whatever this player did to everyone else.

There are two situations you need to handle: when the player who is also the server turns over a card, and when a player on a client turns over a card. In the latter case, the client needs to tell the server that the card has been turned over (in the first case, the server obviously already knows).

Let’s handle the server situation first, because that’s simplest. Add a new method to Game.m:

- (void)turnCardForActivePlayer
{
	[self turnCardForPlayer:[self activePlayer]];

	if (self.isServer)
		[self performSelector:@selector(activateNextPlayer) withObject:nil afterDelay:0.5f];
}

This turns the card for the active player and then schedules the activateNextPlayer method to be called after a small delay. I chose to use a delay here because that looks better in combination with the card turning animation. You only want to activate the next player when the CardView has been fully turned over.

Change the turnCardForPlayerAtBottom method to:

- (void)turnCardForPlayerAtBottom
{
	if (_state == GameStatePlaying 
		&& _activePlayerPosition == PlayerPositionBottom
		&& [[self activePlayer].closedCards cardCount] > 0)
	{
		[self turnCardForActivePlayer];
	}
}

Here you moved the logic for turning the card for the active player into its own method because you will need to call that method from two other places:

  1. When the client player turns the card.
  2. In single-player mode (more about that near the end of the tutorial).

Next add this new method to Game.m:

- (void)activateNextPlayer
{
	NSAssert(self.isServer, @"Must be server");

	while (true)
	{
		_activePlayerPosition++;
		if (_activePlayerPosition > PlayerPositionRight)
			_activePlayerPosition = PlayerPositionBottom;

		Player *nextPlayer = [self activePlayer];
		if (nextPlayer != nil)
		{
			[self activatePlayerAtPosition:_activePlayerPosition];
			return;
		}
	}
}

This loops clockwise through the player positions until it finds a valid Player object, and then activates that Player. Because you call the activatePlayerAtPosition: method, this will also send a PacketActivatePlayer packet to all clients. You can only call activateNextPlayer on the server.

That should do it for the server. If the server’s player is the starting player (and for testing purposes you can fake this by always setting _activePlayerPosition to PlayerPositionBottom in pickRandomStartingPlayer), then tapping to turn over the card will activate the next player, also on the clients. Try it out!

Turning cards on the client

Of course, turning over a card on a client does not activate the next player yet, because the client does not tell the server yet about this event. For this you’ll introduce a new packet type, PacketTypeClientTurnedCard.

This is a packet that needs no additional data, so the only thing you have to do to make this work is to add a case-statement to Packet.m‘s packetWithData: method:

	switch (packetType)
	{
		case PacketTypeSignInRequest:
		case PacketTypeClientReady:
		case PacketTypeClientDealtCards:
		case PacketTypeClientTurnedCard:  // add this
		case PacketTypeServerQuit:
		case PacketTypeClientQuit:
			packet = [Packet packetWithType:packetType];
			break;

		. . .

In Game.m, change turnCardForPlayerAtBottom to:

- (void)turnCardForPlayerAtBottom
{
	if (_state == GameStatePlaying 
		&& _activePlayerPosition == PlayerPositionBottom
		&& [[self activePlayer].closedCards cardCount] > 0)
	{
		[self turnCardForActivePlayer];

		if (!self.isServer)
		{
			Packet *packet = [Packet packetWithType:PacketTypeClientTurnedCard];
			[self sendPacketToServer:packet];
		}
	}
}

If this method gets called on a device that acts as a client, it will send the new “client turned card” packet to the server. To handle this packet type on the server, add the following case-statement to serverReceivedPacket:fromPlayer:

		case PacketTypeClientTurnedCard:
			if (_state == GameStatePlaying && player == [self activePlayer])
			{
				[self turnCardForActivePlayer];
			}
			break;

If the packet came from the currently active player (defensive programming!), then you should turn over the card for it. Notice that this will only turn the card on the server but the client will never show the cards that the server turned over. With two or more clients this also runs into problems, because client B will never be told that client A turned over its card.

Here’s the trick: according to the game rules, after a player turns over its card you will always activate the next player, and for that all clients already receive an ActivatePlayer packet. On receipt of that message the client can simply turn over the card from the previously active player. A good place to make that happen is handleActivatePlayerPacket:, so change that method to:

- (void)handleActivatePlayerPacket:(PacketActivatePlayer *)packet
{
	NSString *peerID = packet.peerID;

	Player* newPlayer = [self playerWithPeerID:peerID];
	if (newPlayer == nil)
		return;

	[self performSelector:@selector(activatePlayerWithPeerID:) withObject:peerID afterDelay:0.5f];
}

You call turnCardForActivePlayer to turn over the card for the previous player, but not if this client itself was the previous player (i.e. the one in the bottom position) because that card has already been turned manually by the local user. After that, you call the new activatePlayerWithPeerID: method to activate the next player with a delay, which again looks better in combination with the card turn animation.

Add the activatePlayerWithPeerID: method:

- (void)activatePlayerWithPeerID:(NSString *)peerID
{
	NSAssert(!self.isServer, @"Must be client");

	Player *player = [self playerWithPeerID:peerID];
	_activePlayerPosition = player.position;
	[self activatePlayerAtPosition:_activePlayerPosition];
}

Try it out now. Whoops, if the starting player is the server’s, the client will now flip over a card even before the server player has tapped on anything!

The reason for this is that after dealing, the client immediately receives an ActivatePlayer packet and in response it will flip over that player’s top-most card. Obviously, it shouldn’t do that the very first time it receives the ActivatePlayer packet, so let’s handle this with a new boolean instance variable:

@implementation Game
{
	. . .
	BOOL _firstTime;
}

You set this boolean to YES in beginGame (it is best to do this near the top of the method, before any packets are sent out to the clients):

- (void)beginGame
{
	_firstTime = YES;
	. . .
}

Then you check for this variable at the very top of handleActivatePlayerPacket:

- (void)handleActivatePlayerPacket:(PacketActivatePlayer *)packet
{
	if (_firstTime)
	{
		_firstTime = NO;
		return;
	}

	. . .
}

When a new round starts, the starting player has already been activated. In that case, you don’t need to do anything here and you can simply ignore the ActivatePlayer packet.

Try it again, first with one client, then with two (or more). Each player in turn can now flip over a card, and all players should show the cards of the others being turned over. Verify that all devices actually show the same cards.

Testing on devices

Bugs, bugs, bugs

There are still a few small issues that you have to resolve, though. If you tap fast enough, it is possible to turn over several cards at once! That’s against the rules of Snap!, so you should prevent that.

The main problem is that GameViewController’s turnOverAction: can be called at any time. In turnCardForPlayerAtBottom you already check to make sure the game is in the right state and that the user’s player is really active, but you should also check to make sure the player hasn’t already turned over his card in this turn. (The culprit is the delay between the animation and activating the next player, during which it is still this player’s turn.)

Add the following instance variables to Game.m:

@implementation Game
{
	. . .
	BOOL _busyDealing;
	BOOL _hasTurnedCard;
}

You set these booleans to NO in beginRound:

- (void)beginRound
{
	_busyDealing = NO;
	_hasTurnedCard = NO;

	[self activatePlayerAtPosition:_activePlayerPosition];
}

Next set the _hasTurnedCard variable to YES in turnCardForPlayer:, because that’s when the card gets turned over:

- (void)turnCardForPlayer:(Player *)player
{
	NSAssert([player.closedCards cardCount] > 0, @"Player has no more cards");

	_hasTurnedCard = YES;

	Card *card = [player turnOverTopCard];
	[self.delegate game:self player:player turnedOverCard:card];
}

Set it to NO again in activatePlayerAtPosition:, because that activates the next player. At that point this new player obviously hasn’t turned over his card yet.

- (void)activatePlayerAtPosition:(PlayerPosition)playerPosition
{
	_hasTurnedCard = NO;

	. . .
}

The _busyDealing flag prevents the player from turning over cards while the dealing animation is still taking place.

This is where the game state is a bit misleading, at least on the server. It changes from “dealing” to “playing” when all clients have responded with a ClientDealtCards packet, but usually this will happen quicker than the dealing cards animation. If you were to tap on the stack in the interval between having received all those packets and the animation completing, it is possible to already turn over a card.

You don’t want that to happen, so the _busyDealing boolean takes care of that. Set it to YES in beginGame:

- (void)beginGame
{
	_state = GameStateDealing;
	_firstTime = YES;
	_busyDealing = YES;

	. . .
}

Finally, modify the if-statement in turnCardForPlayerAtBottom as follows:

- (void)turnCardForPlayerAtBottom
{
	if (_state == GameStatePlaying 
		&& _activePlayerPosition == PlayerPositionBottom
		&& !_busyDealing
		&& !_hasTurnedCard
		&& [[self activePlayer].closedCards cardCount] > 0)
	{
		. . .
	}
}

Try it out. Rapid tapping during or after the dealing animation should no longer flip over more than a single card.

One more small thing you should do. Because you now use performSelector:withObject:afterDelay: to activate the next player, you schedule methods to be called in the future. But if the user meanwhile exits from the game (either on purpose or through a disconnect), you should really cancel those pending method calls, or strange things may happen. Therefore, add the following line to quitGameWithReason: in Game.m:

- (void)quitGameWithReason:(QuitReason)reason
{
	[NSObject cancelPreviousPerformRequestsWithTarget:self];

	. . .
}

All right, the game is starting to shape up! It already has many of the basics that most card games need, i.e. taking turns and flipping over cards.

You still have to handle what happens when players run out of cards — currently you can’t do anything anymore when that happens — and allow the players to yell “Snap!” when they tap the Snap! button.

But before you get to that, you should make the networking code a bit more robust.

Note: In its current form, the game isn’t going to be 100% fair. The user who flips a card sees that card before any of the other clients and therefore has an advantage (it takes a few milliseconds to send out the network packets). You could compensate for this by delaying the turn-over animation for that user. One way to do this is to measure the latency between the devices (the network “ping”). The server also has an advantage because it receives the packets before the other clients do. Solving these issues is left as an exercise for the reader. ;-)

Out-of-order packets

You’ve already seen that networking is full of surprises. For example, you never know when a client is going to disconnect so you need to be prepared to handle disconnects at any given time. You also cannot trust the peers that you’re connected to, so it makes sense to validate any packets that you receive.

There is another thing that you should be aware of: packets may not actually arrive in the order that you sent them. GKSession can send packets in one of two modes: reliable mode and unreliable mode. So far you’ve been using reliable mode for all our transmissions, which means that no matter what, as long as the connection exists, that packet will be delivered with exactly the right contents. If the packet transmission fails, GKSession will try sending that packet again.

But if you’ve sent more than one packet, and packet A fails while packet B doesn’t, then packet B may arrive at its destination before packet A. Whether this is a problem or not depends on the application that you’re writing.

For Snap!, you’ve already largely solved this packets arriving out-of-order problem by building in several synchronization points. For example, the server asks all clients to sign in, and then waits for their responses before it continues. Then it sends them the ServerReady packet and again waits until all clients have responded. There is no way the server can receive the ClientReady response before the SignInResponse because it will never send out the ServerReady packets until it has all “sign-in” responses.

However, you can’t really use such a synchronization mechanism during the actual gameplay. That would require the clients to send acknowledgments for every packet from the server, which will make the game a lot less responsive — since it would need to do a lot of waiting — and a lot less fun.

An alternative approach is to simply ignore any packets that arrive “out-of-order”. If packet A arrives after packet B, then you simply pretend that you never received packet A. This approach is mostly useful for games that send continuous status updates, such as real-time action games. For example, if the packets contain position information of your game objects — let’s say spaceships — then a packet with older data should be discarded because it is no longer relevant.

For Snap! a potential out-of-order problem exists: if the order of ActivatePlayer messages gets messed up, then a client may see that first player Y is activated, followed by player X (this is the message that is out of order). It should ignore the activation for player X because it is obviously an old message. However, the client needs to retroactively perform player X’s turn as well — you can’t just skip that player.

To detect whether packets arrive out of order you have to number them. If a packet with number 99 arrives after packet 100, then it should be discarded. That’s what the 32-bit “packet number” field is for in our Packets. In Snap! you will only use this field for the ActivatePlayer packets, but in some games you may want to use this mechanism for all packets.

Add a new property to Packet.h:

@property (nonatomic, assign) int packetNumber;

And synthesize it in Packet.m:

@synthesize packetNumber = _packetNumber;

Change the initWithType: method to:

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

If packetNumber is set to -1, then you will not use the out-of-order detection mechanism. This is true for most packets.

Change packetWithData: to:

+ (id)packetWithData:(NSData *)data
{
	. . .

	packet.packetNumber = packetNumber;
	return packet;
}

And change the data method to:

- (NSData *)data
{
	NSMutableData *data = [[NSMutableData alloc] initWithCapacity:100];

	[data rw_appendInt32:'SNAP'];   // 0x534E4150
	[data rw_appendInt32:self.packetNumber];
	[data rw_appendInt16:self.packetType];

	[self addPayloadToData:data];
	return data;
}

This now places the contents of the packetNumber property into the NSMutableData object. For debug purposes you can replace the description method with:

- (NSString *)description
{
	return [NSString stringWithFormat:@"%@ number=%d, type=%d", [super description], self.packetNumber, self.packetType];
}

Next fill in this packetNumber property in Game.m when you send the packets. First add a new instance variable:

@implementation Game
{
	. . .
	int _sendPacketNumber;
}

Add the following lines to the top of sendPacketToAllClients:

- (void)sendPacketToAllClients:(Packet *)packet
{
	if (packet.packetNumber != -1)
		packet.packetNumber = _sendPacketNumber++;

	. . .
}

As well as sendPacketToServer:

- (void)sendPacketToServer:(Packet *)packet
{
	if (packet.packetNumber != -1)
		packet.packetNumber = _sendPacketNumber++;

	. . .
}

And that’s it as far as sending is concerned. Each packet that you send out gets a unique number that keeps increasing.

To ignore the packets that arrive out-of-order, you have to keep track of the last packet number that was received. It’s easiest if you do that for each client separately. Since each participant in the networking session is represented by a Player object, you’ll add a new property to Player.h:

@property (nonatomic, assign) int lastPacketNumberReceived;

And synthesize it in Player.m:

@synthesize lastPacketNumberReceived = _lastPacketNumberReceived;

You will set this to -1 in the init method, because the first packet that this player will receive has number 0.

- (id)init
{
	if ((self = [super init]))
	{
		_lastPacketNumberReceived = -1;
		_closedCards = [[Stack alloc] init];
		_openCards = [[Stack alloc] init];
	}
	return self;
}

Actually ignoring the out-of-order packets is pretty easy. In Game.m‘s receiveData:fromPeer:inSession:context: method there is an if-statement that checks whether player != nil. Replace that if-statement with:

	if (player != nil)
	{
		if (packet.packetNumber != -1 && packet.packetNumber <= player.lastPacketNumberReceived)
		{
			NSLog(@"Out-of-order packet!");
			return;
		}

		player.lastPacketNumberReceived = packet.packetNumber;
		player.receivedResponse = YES;
	}

The packetNumber value from the Packet must always be greater than the number of the packet you last received. As I mentioned before, this feature will only be enabled for the ActivatePlayer packets. All other packets have their packetNumber set to -1 by default. Edit PacketActivatePlayer.m to enable the out-of-order mechanism for this packet:

- (id)initWithPeerID:(NSString *)peerID
{
	if ((self = [super initWithType:PacketTypeActivatePlayer]))
	{
		self.packetNumber = 0;  // enable packet numbers for this packet
		self.peerID = peerID;
	}
	return self;
}

When you run the app now, it should still work exactly the same as before, but the debug output for the client now says:

<534e4150 ffffffff 0064>
<534e4150 ffffffff 0066...>
<534e4150 ffffffff 0068...>

...and so on for packets that have a -1 packet number (because -1 shows up as 0xffffffff in hexadecimal). The ActivatePlayer packets (packet type 0x6a), on the other hand, have an incrementing packet number:

<534e4150 00000000 006a...>
<534e4150 00000001 006a...>
<534e4150 00000002 006a...>
... and so on...

This was the first step. Now you actually have to make the game smart enough to recover from an ActivatePlayer packet that gets discarded because it is out-of-order. After all, on the client you turn over the cards for the other players upon receipt of the ActivatePlayer packet. If one of those packets gets skipped, then one of the players does not get his cards turned. So you have to recognize when a player was skipped and then do something about it.

Because it's hard to test this sort of thing -- you never know when the network is going to drop packets and deliver them out-of-order -- you're going to fake this situation so you can properly test it. Add the following global variable to Game.m, above the @implementation block:

PlayerPosition testPosition;

In changeRelativePositionsOfPlayers, add the following line:

- (void)changeRelativePositionsOfPlayers
{
	. . .
	testPosition = diff;
}

In handleActivatePlayerPacket:, add the following:

- (void)handleActivatePlayerPacket:(PacketActivatePlayer *)packet
{
	. . .

	Player* newPlayer = [self playerWithPeerID:peerID];
	if (newPlayer == nil)
		return;

	// For faking missed ActivatePlayer messages.	
	static int foo = 0;
	if (foo++ % 2 == 1 && testPosition == PlayerPositionTop && newPlayer.position != PlayerPositionBottom)
	{
		NSLog(@"*** faking missed message");
		return;
	}

	if (_activePlayerPosition != PlayerPositionBottom)
		. . .
}

You need to test this with at least two clients. When you run the app now, every other ActivatePlayer packet will be skipped by the client who sits at the top (as seen from the server). It only gets the ActivatePlayer packet for the player after that, and the player in between is skipped.

So how do you solve this? You know that a client can never miss its own ActivatePlayer message, so that is a good synchronization point. The reason it can never miss its own ActivatePlayer message is that there can be no messages sent by the server after that, as the client first has to send a ClientTurnedCard response back to the server, and the server waits for that.

Change the code for handleActivatePlayerPacket: to:

- (void)handleActivatePlayerPacket:(PacketActivatePlayer *)packet
{
	if (_firstTime)
	{
		_firstTime = NO;
		return;
	}

	NSString *peerID = packet.peerID;

	Player* newPlayer = [self playerWithPeerID:peerID];
	if (newPlayer == nil)
		return;

	// For faking missed ActivatePlayer messages.	
	static int foo = 0;
	if (foo++ % 2 == 1 && testPosition == PlayerPositionTop && newPlayer.position != PlayerPositionBottom)
	{
		NSLog(@"*** faking missed message");
		return;
	}

	PlayerPosition minPosition = _activePlayerPosition;
	if (minPosition == PlayerPositionBottom)
		minPosition = PlayerPositionLeft;

	PlayerPosition maxPosition = newPlayer.position;
	if (maxPosition < minPosition)
		maxPosition = PlayerPositionRight + 1;

	// Special situation for when there is only one player (that is not the
	// local user) who has more than one card.
	if (_activePlayerPosition == newPlayer.position && _activePlayerPosition != PlayerPositionBottom)
		maxPosition = minPosition + 1;

	for (PlayerPosition p = minPosition; p < maxPosition; ++p)
	{
		Player *player = [self playerAtPosition:p];
		
		// Skip players that have no cards or only one open card.
		if (player != nil && [player.closedCards cardCount] > 0)
		{
			[self turnCardForPlayer:player];
		}
	}

	[self performSelector:@selector(activatePlayerWithPeerID:) withObject:peerID afterDelay:0.5f];
}

This new code calculates which players should have their cards turned. Usually this will be the player immediately next to the newly active one, but if any ActivatePlayer messages got lost, then it also includes those skipped players. Try the app again with two clients. The one at the top (from the server's point-of-view) still drops ActivatePlayer messages but the next time it does receive an ActivatePlayer message, it makes up for that loss and turns over the card of the skipped player.

All right, once you've verified that it works, comment out (or remove) the code for faking the dropped messages.

Note: Handling out-of-order packets can be pretty nasty. When you design the communications protocol for your game you should take this into consideration from the beginning. What will you do when packets arrive in the wrong order, simply drop the "old" ones or somehow integrate that older data back into your model? And what if you discard those older packets, how do you make up for the data that you are missing out on? What is best here really depends on the type of game you're making.

If your head didn't explode yet from all this hard thinking, then now is a good time to take a break before you continue with the actual game logic.

Recycling cards

What happens when a player runs out of cards? According to the rules of Snap!, he takes his pile of face-up cards and flips it over, so these now become his closed cards again. I named this process "recycling" and you'll have to add a nice animation for that.

Add the following method to Player.h and Player.m:

- (BOOL)shouldRecycle
{
	return ([self.closedCards cardCount] == 0) && ([self.openCards cardCount] > 1);
}

This means the player's cards should be recycled when he has no closed cards left and at least more than one open card. You don't recycle the cards when there is just one open card because that is a little silly, the player will just be turning over that same card all the time.

Now the question is, where do you check whether the player's cards need to be recycled? I decided a good moment is when the next player is activated. If this new player has no more cards to turn over at that point, you first recycle his old cards.

In Game.m, change the activateNextPlayer method to:

- (void)activateNextPlayer
{
	NSAssert(self.isServer, @"Must be server");

	while (true)
	{
		_activePlayerPosition++;
		if (_activePlayerPosition > PlayerPositionRight)
			_activePlayerPosition = PlayerPositionBottom;

		Player *nextPlayer = [self activePlayer];
		if (nextPlayer != nil)
		{
			if ([nextPlayer.closedCards cardCount] > 0)
			{
				[self activatePlayerAtPosition:_activePlayerPosition];
				return;
			}

			if ([nextPlayer shouldRecycle])
			{
				[self activatePlayerAtPosition:_activePlayerPosition];
				[self recycleCardsForActivePlayer];
				return;
			}
		}
	}
}

This now loops through all the players until it finds one that still has cards left to turn over, or cards that can be recycled. Note that this skips players who have just one open card.

The recycleCardsForActivePlayer method is new:

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

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

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

This is pretty simple, it calls a new recycleCards method on the Player object, and then notifies the delegate so that it can do an animation to move the cards from the open stack back to the closed stack.

Add the recycleCards signature to Player.h:

- (NSArray *)recycleCards;

And the method itself to Player.m:

- (NSArray *)recycleCards
{
	return [self giveAllOpenCardsToPlayer:self];
}

- (NSArray *)giveAllOpenCardsToPlayer:(Player *)otherPlayer
{
	NSUInteger count = [self.openCards cardCount];
	NSMutableArray *movedCards = [NSMutableArray arrayWithCapacity:count];

	for (NSUInteger t = 0; t < count; ++t)
	{
		Card *card = [self.openCards cardAtIndex:t];
		card.isTurnedOver = NO;
		[otherPlayer.closedCards addCardToBottom:card];
		[movedCards addObject:card];
	}

	[self.openCards removeAllCards];
	return movedCards;
}

As you can see, recycleCards is actually implemented in terms of another method, giveAllOpenCardsToPlayer:. That is because later on you will also need to move those cards around when another player yells "Snap!" and wins the cards -- it's the same logic, whether the source and destination player are different or the same person.

This does require us to add the addCardToBottom: and removeAllCards methods to the Stack class. Add the signatures to Stack.h and the methods to Stack.m:

- (void)addCardToBottom:(Card *)card
{
	NSAssert(card != nil, @"Card cannot be nil");
	NSAssert([_cards indexOfObject:card] == NSNotFound, @"Already have this Card");
	[_cards insertObject:card atIndex:0];
}

- (void)removeAllCards
{
	[_cards removeAllObjects];
}

That's it for the data model methods, now the new delegate method. Add this to the GameDelegate protocol:

- (void)game:(Game *)game didRecycleCards:(NSArray *)recycledCards forPlayer:(Player *)player;

And implement the method in GameViewController.m:

- (void)game:(Game *)game didRecycleCards:(NSArray *)recycledCards forPlayer:(Player *)player
{
	self.snapButton.enabled = NO;
	self.turnOverButton.enabled = NO;

	NSTimeInterval delay = 0.0f;
	for (Card *card in recycledCards)
	{
		CardView *cardView = [self cardViewForCard:card];
		[cardView animateRecycleForPlayer:player withDelay:delay];
		delay += 0.025f;
	}

	[self performSelector:@selector(afterRecyclingCardsForPlayer:) withObject:player afterDelay:delay + 0.5f];
}

- (void)afterRecyclingCardsForPlayer:(Player *)player
{
	self.snapButton.enabled = YES;
	self.turnOverButton.enabled = YES;

	[self.game resumeAfterRecyclingCardsForPlayer:player];
}

Game calls this delegate method with the list of cards that were recycled (you got that list from Player's recycleCards method). You simply call the animateRecycleForPlayer:withDelay: method on each CardView from the list of recycled Cards. Then half a second later you call afterRecyclingCardsForPlayer: to resume the game.

Add this new method to Game.h and Game.m but leave it empty for now. Later you'll do more stuff here. (It may happen that a player yells "Snap!" when there are no matching cards on the table and then the player has to pay one card to every other player. In that case it can occur that the player must first recycle his cards, that's what this method is really for.)

- (void)resumeAfterRecyclingCardsForPlayer:(Player *)player
{
}

Then finally the animation in CardView. Add the following method to both CardView.h and CardView.m:

- (void)animateRecycleForPlayer:(Player *)player withDelay:(NSTimeInterval)delay
{
	[self.superview sendSubviewToBack:self];

	[self unloadFront];
	[self loadBack];

	[UIView animateWithDuration:0.2f
		delay:delay
		options:UIViewAnimationOptionCurveEaseOut
		animations:^
		{
			self.center = [self centerForPlayer:player];
		}
		completion:nil];
}

- (void)unloadFront
{
	[_frontImageView removeFromSuperview];
	_frontImageView = nil;
}

Cool, now the code should compile without errors and you can try it out. However, if you only have two players then there's a lot of tapping involved before you get to the end of a player's stack, so just for testing it makes sense to reduce the number of cards in the deck. In Deck.m's setUpCards method, replace the second for-loop with:

		for (int value = /*CardAce*/CardQueen; value <= CardKing; ++value)

I simply commented out the starting value (CardAce) and replaced it with CardQueen. Now there will be only eight cards in the deck (four queens and four kings). Of course you do have to remember to restore this back to the original value when you're done testing!

Try it out. When a player runs out of cards, on his next turn the cards are recycled and moved from his open stack to his closed stack again. Except... it doesn't work on the client yet.

The server shows the animation just fine, regardless of who it is performed on, a client or the server itself, but clients don't. That's because you only call recycleCardsForActivePlayer from activateNextPlayer and that method can only be called by the server. Clients call activatePlayerWithPeerID: instead, so you have to put the recycle detection logic in there too (in Game.m):

- (void)activatePlayerWithPeerID:(NSString *)peerID
{
	. . .

	if ([player shouldRecycle])
	{
		[self recycleCardsForActivePlayer];
	}
}

Awesome, now it works on the clients too.

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!

The Snap! button

Everything you've done so far has been very important -- letting players take turns, animating card views, handling disconnects -- but the fun part of the Snap! game is of course yelling "Snap!" when you see a pair of matching cards. There are three things that can happen when you press the Snap! button:

  1. There is a match and you're the first person to yell "Snap!". You will now receive the complete stacks of open cards from the players who hold the matching cards. These cards will go on your closed stack.
  2. There isn't a match. You will now have to pay all other players one card from your closed stack.
  3. You're not the first person to yell "Snap!". Nothing happens, although the game will still show a speech bubble to point out that you're too slow. ;-)

As always you'll start simple and slowly build in all the required features. Simple in this case means that tapping the Snap! button always counts as "no match" and whoever tapped the button will have to pay cards to the other players.

In GameViewController.m, change the snapAction: method to:

- (IBAction)snapAction:(id)sender
{
	[self.game playerCalledSnap:[self.game playerAtPosition:PlayerPositionBottom]];
}

Add this new method to Game.h and Game.m:

- (void)playerCalledSnap:(Player *)player
{
	[self.delegate game:self playerCalledSnapWithNoMatch:player];
}

Soon you'll be doing a lot more in playerCalledSnap:, but for now anytime someone yells "Snap!", you mark it down as a wrong move. Add this new delegate method to the GameDelegate protocol:

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

And implement it in GameViewController.m:

- (void)game:(Game *)game playerCalledSnapWithNoMatch:(Player *)player
{
	[_wrongMatchSound play];

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

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

There's some new stuff here, a sound effect and two new methods. The sound effect is easy, just add a new instance variable:

@implementation GameViewController
{
	. . .
	AVAudioPlayer *_wrongMatchSound;
	AVAudioPlayer *_correctMatchSound;
}

In the interest of saving some time, you might as well add the sound effect for a "correct match" here. These two new AVAudioPlayer objects are created in loadSounds:

- (void)loadSounds
{
	. . .

	url = [[NSBundle mainBundle] URLForResource:@"WrongMatch" withExtension:@"caf"];
	_wrongMatchSound = [[AVAudioPlayer alloc] initWithContentsOfURL:url error:nil];
	[_wrongMatchSound prepareToPlay];

	url = [[NSBundle mainBundle] URLForResource:@"CorrectMatch" withExtension:@"caf"];
	_correctMatchSound = [[AVAudioPlayer alloc] initWithContentsOfURL:url error:nil];
	[_correctMatchSound prepareToPlay];
}

Next up is the showSplashView:forPlayer: method. The "splash view" is the smiley face that shows up when the "Snap!" was correct (self.correctSnapImageView) or the big red X that shows up on a wrong Snap! (self.wrongSnapImageView).

These two image views are part of the GameViewController nib but so far they have been hidden. What showSplashView:forPlayer: does, is move the image view over to the player's position, show it with a "splash!"-type animation (hence the name), and then remove it again.

Add this method next:

- (void)showSplashView:(UIImageView *)splashView forPlayer:(Player *)player
{
	splashView.center = [self splashViewPositionForPlayer:player];
	splashView.hidden = NO;
	splashView.alpha = 1.0f;
	splashView.transform = CGAffineTransformMakeScale(2.0f, 2.0f);

	[UIView animateWithDuration:0.1f
		delay:0.0f
		options:UIViewAnimationOptionCurveEaseIn
		animations:^
		{
			splashView.transform = CGAffineTransformIdentity;
		}
		completion:^(BOOL finished)
		{
			[UIView animateWithDuration:0.1f 
				delay:1.0f
				options:UIViewAnimationOptionCurveEaseIn
				animations:^
				{
					splashView.alpha = 0.0f;
					splashView.transform = CGAffineTransformMakeScale(0.5f, 0.5f);
				}
				completion:^(BOOL finished)
				{
					splashView.hidden = YES;
				}];
		}];
}

- (CGPoint)splashViewPositionForPlayer:(Player *)player
{
	CGRect rect = self.view.bounds;
	CGFloat midX = CGRectGetMidX(rect);
	CGFloat midY = CGRectGetMidY(rect);
	CGFloat maxX = CGRectGetMaxX(rect);
	CGFloat maxY = CGRectGetMaxY(rect);

	if (player.position == PlayerPositionBottom)
		return CGPointMake(midX, maxY - CardHeight/2.0f - 30.0f);
	else if (player.position == PlayerPositionLeft)
		return CGPointMake(31.0f + CardWidth / 2.0f, midY - 22.0f);
	else if (player.position == PlayerPositionTop)
		return CGPointMake(midX, 29.0f + CardHeight/2.0f);
	else
		return CGPointMake(maxX - CardWidth + 1.0f, midY - 22.0f);
}

The other new method to add is showSnapIndicatorForPlayer:. It simply unhides an image view. This is the purple speech bubble graphic that shows which player yelled "Snap!":

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

That should be enough to try it out. Tapping either on the server or a client should work, although they don't communicate with each other yet.

The big red X that's shown when there is no match

Notice that you temporarily disable the UIButton that is used for turning over the cards after you've tapped the Snap! button, just so the user cannot mess with our animations. Currently there is no code yet to re-enable that button, but you'll fix that in a moment.

Sending "Snap!" to the server

When a client taps the Snap! button, it needs to tell the server, so the server can figure out whether or not it's a good Snap! and tell everyone else. Change playerCalledSnap: in Game.m to:

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

If the player is on a client, then you send the new PacketPlayerShouldSnap packet. Create this new class (as always a subclass of Packet) and add it to the project. Replace PacketPlayerShouldSnap.h with:

#import "Packet.h"

@interface PacketPlayerShouldSnap : Packet

@property (nonatomic, copy) NSString *peerID;

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

@end

And replace PacketPlayerShouldSnap.m with:

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

@implementation PacketPlayerShouldSnap

@synthesize peerID = _peerID;

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

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

+ (id)packetWithData:(NSData *)data
{
	size_t count;
	NSString *peerID = [data rw_stringAtOffset:PACKET_HEADER_SIZE bytesRead:&count];
	return [[self class] packetWithPeerID:peerID];
}

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

@end

It's just a packet that sends the peer ID of the player who called snap. You already have at least one Packet subclass that does exactly the same thing, but in a short while you'll add something extra to this class that makes it different. (As I write this I realize it is technically not necessary to send the peer ID because you always already know the peer ID of the sender of a message, but it won't hurt either way.)

In Packet.m, add an import for this new subclass:

#import "PacketPlayerShouldSnap.h"

And a case-statement in packetWithData:

		case PacketTypePlayerShouldSnap:
			packet = [PacketPlayerShouldSnap packetWithData:data];
			break;

Also add an import in Game.m:

#import "PacketPlayerShouldSnap.h"

And then add the following case-statement to serverReceivedPacket:fromPlayer:

		case PacketTypePlayerShouldSnap:
			if (_state == GameStatePlaying)
			{
				NSString *peerID = ((PacketPlayerShouldSnap *)packet).peerID;
				Player *player = [self playerWithPeerID:peerID];
				if (player != nil)
					[self playerCalledSnap:player];
			}
			break;

This figures out which player you're talking about and then calls the playerCalledSnap: method again.

Try it out! Tap the Snap! button on the client and watch a speech bubble (and for now, the big red X) appear on the server.

Where To Go From Here?

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

Congratulations - you've got the game almost working! The cards are dealt, the players take turns, and you can even call Snap!

Next, check out the seventh and final part of this tutorial series, where you'll finally wrap up the rest of the game!

In the meantime, if you have any questions or comments 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.