How To Make a Simple Playing Card Game with Multiplayer and Bluetooth, Part 3
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 3
55 mins
Sending Responses Back to the Server
I’ve mentioned the GKSession data-receive-handler method a few times now. One of the parameters to that method is a new NSData object, with the binary contents of the message as it was received from the sender. This method is where you’ll turn that NSData object back into a Packet, look at the packet type, and decide what to do with it.
In Game.m, replace the following method with:
- (void)receiveData:(NSData *)data fromPeer:(NSString *)peerID inSession:(GKSession *)session context:(void *)context
{
#ifdef DEBUG
NSLog(@"Game: receive data from peer: %@, data: %@, length: %d", peerID, data, [data length]);
#endif
Packet *packet = [Packet packetWithData:data];
if (packet == nil)
{
NSLog(@"Invalid packet: %@", data);
return;
}
[self clientReceivedPacket:packet];
}
You still log the incoming data, but then call a new convenience constructor on Packet – packetWithData: – to turn that NSData into a new Packet object. That may not always work. If, for example, the incoming data doesn’t start with the ‘SNAP’ header, then you log an error. But if you do have a valid Packet object, you pass it along to another new method, clientReceivedPacket:.
First add that convenience constructor to the Packet class. Add the method signature to Packet.h:
+ (id)packetWithData:(NSData *)data;
To Packet.m, first add the following line above the @implementation:
const size_t PACKET_HEADER_SIZE = 10;
Then add the implementation of packetWithData: in Packet.m:
+ (id)packetWithData:(NSData *)data
{
if ([data length] < PACKET_HEADER_SIZE)
{
NSLog(@"Error: Packet too small");
return nil;
}
if ([data rw_int32AtOffset:0] != 'SNAP')
{
NSLog(@"Error: Packet has invalid header");
return nil;
}
int packetNumber = [data rw_int32AtOffset:4];
PacketType packetType = [data rw_int16AtOffset:8];
return [Packet packetWithType:packetType];
}
First you verify that the data is at least 10 bytes. If it's any smaller, something is wrong. Most of the time you'll send packets in "reliable" mode, which means they are guaranteed to arrive with exactly the same content as you sent them, so you don't have to worry about any bits "falling over" during transmission.
But it's good to do some sanity checks anyway, as a form of defensive programming. After all, who says some "rogue" client won't be sending different messages in order to fool us? For the same reason, you check that the first 32-bit integer truly represents the word SNAP.
Note: The word 'SNAP' may look like a string, but it isn't. It's also not a single character, but something known as a four-character-code, or "fourcc" for short. Many networking protocols and file formats use such 32-bit codes to indicate their format. For fun, open some random files in Hex Fiend. You'll see they often start with a fourcc.
Note: The word 'SNAP' may look like a string, but it isn't. It's also not a single character, but something known as a four-character-code, or "fourcc" for short. Many networking protocols and file formats use such 32-bit codes to indicate their format. For fun, open some random files in Hex Fiend. You'll see they often start with a fourcc.
Xcode doesn't know yet about these new rw_intXXAtOffset: methods, so add them to the NSData category. First to NSData+SnapAdditions.h:
@interface NSData (SnapAdditions)
- (int)rw_int32AtOffset:(size_t)offset;
- (short)rw_int16AtOffset:(size_t)offset;
- (char)rw_int8AtOffset:(size_t)offset;
- (NSString *)rw_stringAtOffset:(size_t)offset bytesRead:(size_t *)amount;
@end
This time you're adding the methods to NSData, not NSMutableData. After all, the GKSession data-receive-handler only receives an immutable NSData object. Put the methods themselves in NSData+SnapAdditions.m:
@implementation NSData (SnapAdditions)
- (int)rw_int32AtOffset:(size_t)offset
{
const int *intBytes = (const int *)[self bytes];
return ntohl(intBytes[offset / 4]);
}
- (short)rw_int16AtOffset:(size_t)offset
{
const short *shortBytes = (const short *)[self bytes];
return ntohs(shortBytes[offset / 2]);
}
- (char)rw_int8AtOffset:(size_t)offset
{
const char *charBytes = (const char *)[self bytes];
return charBytes[offset];
}
- (NSString *)rw_stringAtOffset:(size_t)offset bytesRead:(size_t *)amount
{
const char *charBytes = (const char *)[self bytes];
NSString *string = [NSString stringWithUTF8String:charBytes + offset];
*amount = strlen(charBytes + offset) + 1;
return string;
}
@end
Unlike the "append" methods from NSMutableData, which update a write pointer every time you call them, these reading methods do not automatically update a read pointer. That would have been the most convenient solution, but categories cannot add new data members to a class.
Instead, you're required to pass in a byte offset from where you want to read. Note that these methods assume the data is in network byte-order (big endian), and therefore use ntohl() and ntohs() to convert them back to host byte-order.
rw_stringAtOffset:bytesRead: deserves special mention, as it returns the number of bytes read in a by-reference parameter. With the integer methods, you already know how many bytes you will read, but with a string that number can be anything. (The bytesRead parameter contains the number of bytes read, including the NUL-byte that terminates the string.)
All that's left now is implementing clientReceivedPacket: in Game.m. This is it:
- (void)clientReceivedPacket:(Packet *)packet
{
switch (packet.packetType)
{
case PacketTypeSignInRequest:
if (_state == GameStateWaitingForSignIn)
{
_state = GameStateWaitingForReady;
Packet *packet = [PacketSignInResponse packetWithPlayerName:_localPlayerName];
[self sendPacketToServer:packet];
}
break;
default:
NSLog(@"Client received unexpected packet: %@", packet);
break;
}
}
You simply look at the packet type, and then decide how to handle it. For PacketTypeSignInRequest – the only type we currently have – you change the game state to "waiting for ready," then send a "sign-in response" packet back to the server.
This uses a new class, PacketSignInResponse, rather than just Packet. The sign-in response will contain additional data beyond the standard 10-byte header, and for this project, you'll make such packets subclasses of Packet.
But before you get to that, first add the sendPacketToServer: method (below sendPacketToAllClients: is a good place):
- (void)sendPacketToServer:(Packet *)packet
{
GKSendDataMode dataMode = GKSendDataReliable;
NSData *data = [packet data];
NSError *error;
if (![_session sendData:data toPeers:[NSArray arrayWithObject:_serverPeerID] withDataMode:dataMode error:&error])
{
NSLog(@"Error sending data to server: %@", error);
}
}
It looks very similar to sendPacketToAllClients:, except you don't send the packet to all connected peers, but just to the one identified by _serverPeerID.
The reason for this is that behind the scenes, Game Kit appears to connect every peer to every other peer, so if there are two clients and one server, then the clients are not only connected to the server but also to each other. For this game, you don't want clients to send messages to each other, only to the server. (You can see this in the "peer XXX changed state 2" output from GKSession's session:peer:didChangeState: callback.)
Now you need to make the new PacketSignInResponse class. Add a new Objective-C class to the project, subclass of Packet, named PacketSignInResponse. Replace the new .h file with:
#import "Packet.h"
@interface PacketSignInResponse : Packet
@property (nonatomic, copy) NSString *playerName;
+ (id)packetWithPlayerName:(NSString *)playerName;
@end
In addition to the regular data of the Packet superclass, this particular packet also contains a player name. Replace the .m file with:
#import "PacketSignInResponse.h"
#import "NSData+SnapAdditions.h"
@implementation PacketSignInResponse
@synthesize playerName = _playerName;
+ (id)packetWithPlayerName:(NSString *)playerName
{
return [[[self class] alloc] initWithPlayerName:playerName];
}
- (id)initWithPlayerName:(NSString *)playerName
{
if ((self = [super initWithType:PacketTypeSignInResponse]))
{
self.playerName = playerName;
}
return self;
}
- (void)addPayloadToData:(NSMutableData *)data
{
[data rw_appendString:self.playerName];
}
@end
This should be fairly straightforward, except maybe for addPayloadToData:. This is a new method that Packet subclasses can override to place their own data into the NSMutableData object. Here you simply append the string contents of the playerName property to the data object. To make this work, you have to call this method from the superclass.
Add an empty version of addPayloadToData: to Packet.m:
- (void)addPayloadToData:(NSMutableData *)data
{
// base class does nothing
}
And call it from the "data" method:
- (NSData *)data
{
NSMutableData *data = [[NSMutableData alloc] initWithCapacity:100];
[data rw_appendInt32:'SNAP']; // 0x534E4150
[data rw_appendInt32:0];
[data rw_appendInt16:self.packetType];
[self addPayloadToData:data];
return data;
}
By default, addPayloadToData: doesn't do anything, but subclasses can use it to put their own additional data into the message.
One more thing is needed to make everything compile again, and that is an import for the new Packet subclass in Game.m:
#import "PacketSignInResponse.h"
The compiler may still warn that the value stored in the local packetNumber variable is never read (in Packet's packetWithData:). That's OK for now. You'll do something with that variable soon enough.
Now build and run the app again, both on the client and the server, and start a new game. As before, the client should receive the "sign-in" packet (type code 0x64), but in response it should now send the server a packet that contains its player name. The debug output for the server should say something like this:
Game: receive data from peer: 1100677320, data: <534e4150 00000000 00656372 617a7920 6a6f6500>, length: 20
Paste that data (everything between < and >) into Hex Fiend to figure out what was sent back to the server:
The packet type is now 0x65 instead of 0x64 (PacketTypeSignInResponse instead of PacketTypeSignInRequest), and the highlighted bit shows the name of this particular player ("crazy joe"), including the NUL-byte that terminates the string.
Note: It's a good idea to keep your Game Kit transmissions limited in size. Apple recommends 1,000 bytes or less, although the upper limit seems to be 87 kilobytes or so.
If you send less than 1,000 bytes, all the data can fit into a single TCP/IP packet, which will guarantee speedy delivery. Larger messages will have to be split up and recombined by the receiver. Game Kit takes care of that for you, and it's still pretty fast, but for best performance, stay below 1,000 bytes.
Note: It's a good idea to keep your Game Kit transmissions limited in size. Apple recommends 1,000 bytes or less, although the upper limit seems to be 87 kilobytes or so.
If you send less than 1,000 bytes, all the data can fit into a single TCP/IP packet, which will guarantee speedy delivery. Larger messages will have to be split up and recombined by the receiver. Game Kit takes care of that for you, and it's still pretty fast, but for best performance, stay below 1,000 bytes.