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.

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

The Data Model

This is a good time to talk a bit about the data model for this game. Because you’re using UIKit (as opposed to Cocos2D or OpenGL), it makes sense to structure the game using the Model-View-Controller (MVC) pattern.

A common way to make Cocos2D games is to subclass CCSprite, and put your game object logic into that class. Here you’ll do things a bit differently: you’ll make a strict separation between model, view, and view controller classes.

Note: It may not make sense to use MVC for all games, but it does for card and board games. You can capture the game rules in the model classes, separate from any presentation logic. This has the advantage of allowing you to easily unit-test these gameplay rules to ensure they’re always correct, although you’ll skip that in this tutorial.

The Game object is part of the data model. It handles the game play rules, as well as the networking traffic between the clients and the server (it is both the delegate and data-receive-handler for GKSession). But Game is not the only data model object; there are several others:

The model, view, and controller objects

You’ve seen the Game and GameViewController classes, but the others are new and you’ll be adding them to the project in the course of the tutorial. The players participating in the game are represented by Player objects. Each player has two Stacks of Cards, which are drawn from a Deck. These are all model objects.

Cards are drawn on the screen by CardView objects. All the other views are regular UILabels, UIButtons and UIImageViews. For network communication, Game uses GKSession to send and receive Packet objects, which represent a message that is sent over the network between the different devices.

Begin by creating the Player object. Add a new Objective-C class to the project, subclass of NSObject, named Player. Since this is a data model class, add it to the Data Model group. Replace the contents of Player.h with:

typedef enum
{
	PlayerPositionBottom,  // the user
	PlayerPositionLeft,
	PlayerPositionTop,
	PlayerPositionRight
}
PlayerPosition;

@interface Player : NSObject

@property (nonatomic, assign) PlayerPosition position;
@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) NSString *peerID;

@end

And in Player.m:

#import "Player.h"

@implementation Player

@synthesize position = _position;
@synthesize name = _name;
@synthesize peerID = _peerID;

- (void)dealloc
{
	#ifdef DEBUG
	NSLog(@"dealloc %@", self);
	#endif
}

- (NSString *)description
{
	return [NSString stringWithFormat:@"%@ peerID = %@, name = %@, position = %d", [super description], self.peerID, self.name, self.position];
}

@end

You’re keeping it simple right now. The three properties of Player represent the different ways that each player can be identified:

  1. By their name. This is what you show to the user, but it’s not guaranteed to be unique. The name is what the player typed in on the Host Game or Join Game screens (if they didn’t type in anything, you’ll use the device name here).
  2. By the peer ID. This is what GKSession uses internally, and it’s how you will identify the players when you need to send them messages over the network.
  3. By their “position” on the screen. This one is interesting. Each player will see himself sitting at the bottom of the screen, so player positions are relative. As you can see from the typedef, you start at the bottom position and then go clockwise (left, up, right). You’ll use the position when you need to do things in a guaranteed order, such as dealing the cards.

This is how different players see themselves and the other players sitting around the table:

How players see themselves and the other players

Signing In

The Game object has now entered its initial state, GameStateWaitingForSignIn, on both the clients and the server. In the “waiting for sign-in” state, the server will send a message to all clients asking them to respond with their local player name.

So far the server only knows which clients are connected and what their peer IDs and device names are, but it does not know anything about the name that the user typed into the “Your Name” field. Once the server knows everyone’s name, it can tell all the clients about the other players.

Add the import for Player to Game.h:

#import "Player.h"

Add a new instance variable to Game, in Game.m:

@implementation Game
{
	. . .
	NSMutableDictionary *_players;
}

You’ll put the Player objects into a dictionary. To make it easy to look up players by their peer IDs, you’ll make the peer ID the key. The dictionary needs to be allocated right away, so add an init method to the Game class:

- (id)init
{
	if ((self = [super init]))
	{
		_players = [NSMutableDictionary dictionaryWithCapacity:4];
	}
	return self;
}

The method that starts the game on the server is startServerGameWithSession:playerName:clients:, and you already do some stuff in there to set up the game. Add the following code to the bottom of that method:

- (void)startServerGameWithSession:(GKSession *)session playerName:(NSString *)name clients:(NSArray *)clients
{
	. . .

	// Create the Player object for the server.
	Player *player = [[Player alloc] init];
	player.name = name;
	player.peerID = _session.peerID;
	player.position = PlayerPositionBottom;
	[_players setObject:player forKey:player.peerID];

	// Add a Player object for each client.
	int index = 0;
	for (NSString *peerID in clients)
	{
		Player *player = [[Player alloc] init];
		player.peerID = peerID;
		[_players setObject:player forKey:player.peerID];

		if (index == 0)
			player.position = ([clients count] == 1) ? PlayerPositionTop : PlayerPositionLeft;
		else if (index == 1)
			player.position = PlayerPositionTop;
		else
			player.position = PlayerPositionRight;

		index++;
	}
}

First you create the Player object for the server and place it in the “bottom” position on the screen. Then you loop through the array of peer IDs for all connected clients and make Player objects for them. You assign the positions for the client players, in clockwise order, depending on how many players there are in total.

Notice that you don’t set the “name” property of these Player objects yet, because at this point you do not yet know the names of the clients.

Sending Messages Between Devices

Now that you have a Player object for each client, you can send the “sign-in” requests to the clients. Each client will respond asynchronously with their name. Upon receipt of such a response, you’ll look up the Player object for that client and set its “name” property with the name that the client sent back to you.

GKSession has a method called sendDataToAllPeers:withDataMode:error: that will send the contents of an NSData object to all connected peers. You can use this method to send a single message from the server to all the clients. The message in this case is an NSData object, and what is inside this NSData object is completely up to you. In Snap!, all messages have the following format:

The data contents of each packet

A packet is at least 10 bytes. These 10 bytes are called the “header,” and any (optional) bytes that may follow are the “payload.” Different types of packets have different payloads, but they all have the same header structure:

  • The first four bytes from the header form the word SNAP. This is a so-called magic number (0x534E4150 in hexadecimal) that you use to verify that the packets really are yours.
  • The magic number is followed by four bytes (really a 32-bit integer) that’s used to recognize when packets arrive out-of-order (more about this later).
  • The last two bytes from the header represent the packet type. You’ll have many types of messages that you send back-and-forth between the clients and the server, and this 16-bit integer tells you what sort of packet it is.

For some packet types, there may be more data following the header (the payload). The “sign-in response” packet that the client sends back to the server, for example, also contains a UTF-8 string with the name of the player.

This is all well and good, but you want to abstract this low-level stuff behind a nicer interface. You’re going to make a Packet class that takes care of the bits-and-bytes behind the scenes. Add a new Objective-C class to the project, subclass of NSObject, named Packet. To keep things tidy, place this in the “Networking” group.

Replace the contents of Packet.h with:

typedef enum
{
	PacketTypeSignInRequest = 0x64,    // server to client
	PacketTypeSignInResponse,          // client to server

	PacketTypeServerReady,             // server to client
	PacketTypeClientReady,             // client to server

	PacketTypeDealCards,               // server to client
	PacketTypeClientDealtCards,        // client to server

	PacketTypeActivatePlayer,          // server to client
	PacketTypeClientTurnedCard,        // client to server
	
	PacketTypePlayerShouldSnap,        // client to server
	PacketTypePlayerCalledSnap,        // server to client

	PacketTypeOtherClientQuit,         // server to client
	PacketTypeServerQuit,              // server to client
	PacketTypeClientQuit,              // client to server
}
PacketType;

@interface Packet : NSObject

@property (nonatomic, assign) PacketType packetType;

+ (id)packetWithType:(PacketType)packetType;
- (id)initWithType:(PacketType)packetType;

- (NSData *)data;

@end

The enum at the top contains a list of all the different types of messages you will send and receive. The Packet class itself it pretty simple at this point: it has a convenience constructor and init method for setting the packet type. The “data” method returns a new NSData object with the contents of this particular message. That NSData object is what you’ll send through GKSession to the other devices.

Replace the contents of Packet.m with:

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

@implementation Packet

@synthesize packetType = _packetType;

+ (id)packetWithType:(PacketType)packetType
{
	return [[[self class] alloc] initWithType:packetType];
}

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

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

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

	return data;
}

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

@end

The interesting bit here is the data method. It allocates an NSMutableData object and then places two 32-bit integers and one 16-bit integer into it. This is the 10-byte header I mentioned earlier. The first part is the word “SNAP,” the second is the packet number – which for the time being you’ll keep at 0 – and the third part is the type of the packet.

From the names of these “rw_appendIntXX” methods, you can already infer that they come from a category. Add a new Objective-C category file to the project. Name the category “SnapAdditions” and make it on NSData (not NSMutableData!).

You’re cheating a bit here, as the category will actually be on NSMutableData. Because you need a similar category on NSData later, you’re putting both of them into the same source file. Replace the contents of NSData+SnapAdditions.h with:

@interface NSData (SnapAdditions)

@end

@interface NSMutableData (SnapAdditions)

- (void)rw_appendInt32:(int)value;
- (void)rw_appendInt16:(short)value;
- (void)rw_appendInt8:(char)value;
- (void)rw_appendString:(NSString *)string;

@end

You’re leaving the category on NSData empty for now, and adding a second category on NSMutableData. As you can see, there are methods for adding integers of different sizes, as well as a method to add an NSString. Replace the contents of NSData+SnapAdditions.m with:

#import "NSData+SnapAdditions.h"

@implementation NSData (SnapAdditions)

@end

@implementation NSMutableData (SnapAdditions)

- (void)rw_appendInt32:(int)value
{
	value = htonl(value);
	[self appendBytes:&value length:4];
}

- (void)rw_appendInt16:(short)value
{
	value = htons(value);
	[self appendBytes:&value length:2];
}

- (void)rw_appendInt8:(char)value
{
	[self appendBytes:&value length:1];
}

- (void)rw_appendString:(NSString *)string
{
	const char *cString = [string UTF8String];
	[self appendBytes:cString length:strlen(cString) + 1];
}

@end

These methods are all pretty similar, but take a closer look at rw_appendInt32::

- (void)rw_appendInt32:(int)value
{
	value = htonl(value);
	[self appendBytes:&value length:4];
}

In the last line, you call [self appendBytes:length:] in order to add the memory contents of the “value” variable, which is four bytes long, to the NSMutableData object. But before that, you call the htonl() function on “value.” This is done to ensure that the integer value is always transmitted in “network byte order,” which happens to be big endian. However, the processors on which you’ll be running this app, the x86 and ARM CPUs, use little endian.

Big endian vs little endian

You could send the memory contents of the “value” variable as-is, but who knows, a new model iPhone may in the future use a different byte ordering and then what one device sends and what another receives could have an incompatible structure.

For this reason, it’s always a good idea to decide on one specific byte ordering when dealing with data transfer, and for network programming that should be big endian. If you simply take care to call htonl() for 32-bit integers and htons() for 16-bit integers before sending, then you should always be fine.

Another thing of note is rw_appendString:, which first converts the NSString to UTF-8 and then adds it to the NSMutableData object, including a NUL byte at the end to mark the end of the string.

Back to Game and the startServerGameWithSession… method. Add the following lines to the bottom of that method:

- (void)startServerGameWithSession:(GKSession *)session playerName:(NSString *)name clients:(NSArray *)clients
{
	. . .

	Packet *packet = [Packet packetWithType:PacketTypeSignInRequest];
	[self sendPacketToAllClients:packet];
}

Of course, this won’t compile yet. First add the required import:

#import "Packet.h"

And then add the sendPacketToAllClients: method:

#pragma mark - Networking

- (void)sendPacketToAllClients:(Packet *)packet
{
	GKSendDataMode dataMode = GKSendDataReliable;
	NSData *data = [packet data];
	NSError *error;
	if (![_session sendDataToAllPeers:data withDataMode:dataMode error:&error])
	{
		NSLog(@"Error sending data to clients: %@", error);
	}
}

Great, now you can run the app again and send some messages between the server and the clients. Host a new game, join with one or more clients, and keep your eye on the debug output pane. On the client it should now say something like this:

Game: receive data from peer: 1995171355, data: <534e4150 00000000 0064>, length: 10

This output comes from the GKSession data-receive-handler method receiveData:fromPeer:inSession:context:. You’re not doing anything in this method yet, but at least it logs that it’s received a message from the server. The actual data, from the NSData object that you gave GKSession to send on the server, is this:

534e4150 00000000 0064

This is 10 bytes long, but in hexadecimal notation. If your hex is a little rusty, then download Hex Fiend or a similar hex editor, and simply copy-paste the above into it:

Sign-in packet in Hex Fiend

This shows you that the packet indeed started with the word SNAP (in big endian byte order, or 0x534E4150), followed by four 0 bytes (for the packet number, which you’re not using yet), followed by the 16-bit packet type. For readability reasons, I gave the first packet type, PacketTypeSignInRequest, the value 0x64 (you can see this in Packet.h), so it would be easy to spot in the hexadecimal data.

Cool, so you managed to send a message to the client. Now the client has to respond to it.

Contributors

Over 300 content creators. Join our team.