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

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

Leave a rating/review
Save for later
Share
You are currently viewing page 2 of 6 of this article. Click here to view the first page.

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.

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.

Contributors

Over 300 content creators. Join our team.