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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
How To Make a Simple Playing Card Game with Multiplayer and Bluetooth, Part 6
60 mins
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. ;-)
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.
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.