How To Make a Simple Playing Card Game with Multiplayer and Bluetooth, Part 2
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 2
45 mins
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 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 part of the series, you created the main menu and the basics for the host and join game screens.
You also created a game server that broadcasts the “Snap!” service and a client that detects the server, but the only evidence of this so far is some NSLog-lines in the Xcode debug output pane.
Now in Part 2, you’ll show the available servers and connected clients on the screen properly, and finish up the matchmaking. Ante up!
Getting Started: Showing the Servers to the User
The MatchmakingClient class has an _availableServers variable, an NSMutableArray, that is supposed to hold the list of servers that this client has detected on the network. You’ll add the peer ID of the server to this array whenever GKSession detects a new server.
How do you know when that happens? Well, MatchmakingClient is the delegate of GKSession, and you can use the session:peer:didChangeState: delegate method for this. Replace that method in MatchmakingClient.m with:
- (void)session:(GKSession *)session peer:(NSString *)peerID didChangeState:(GKPeerConnectionState)state
{
#ifdef DEBUG
NSLog(@"MatchmakingClient: peer %@ changed state %d", peerID, state);
#endif
switch (state)
{
// The client has discovered a new server.
case GKPeerStateAvailable:
if (![_availableServers containsObject:peerID])
{
[_availableServers addObject:peerID];
[self.delegate matchmakingClient:self serverBecameAvailable:peerID];
}
break;
// The client sees that a server goes away.
case GKPeerStateUnavailable:
if ([_availableServers containsObject:peerID])
{
[_availableServers removeObject:peerID];
[self.delegate matchmakingClient:self serverBecameUnavailable:peerID];
}
break;
case GKPeerStateConnected:
break;
case GKPeerStateDisconnected:
break;
case GKPeerStateConnecting:
break;
}
}
The newly discovered server is identified by the peerID parameter. This is a string that contains a number, such as @”663723729.” This number is only important in that it identifies the server.
The third parameter is “state,” and it tells you what is going on with that peer. Currently you deal only with the states GKPeerStateAvailable and GKPeerStateUnavailable. As you can tell from their names, these states indicate that a new server has been discovered, or that a server went away (possibly because that user exited the app or he wandered out of range). Depending on the circumstance, you either add the server’s peer ID to the list of _availableServers, or you remove it from that list.
This code won’t compile as-is, because it also makes calls to self.delegate, which is a property you haven’t defined yet. The MatchmakingClient needs to let the JoinViewController know that a new server has become available (or a previously known server has become unavailable), and you do this through delegate methods. Add the following to the top of MatchmakingClient.h:
@class MatchmakingClient;
@protocol MatchmakingClientDelegate <NSObject>
- (void)matchmakingClient:(MatchmakingClient *)client serverBecameAvailable:(NSString *)peerID;
- (void)matchmakingClient:(MatchmakingClient *)client serverBecameUnavailable:(NSString *)peerID;
@end
Also add a new property to the @interface:
@property (nonatomic, weak) id <MatchmakingClientDelegate> delegate;
And synthesize it in the .m file:
@synthesize delegate = _delegate;
The JoinViewController now needs to become the delegate for MatchmakingClient, so add this protocol to the @interface line in JoinViewController.h:
@interface JoinViewController : UIViewController <UITableViewDataSource, UITableViewDelegate, UITextFieldDelegate, MatchmakingClientDelegate>
In JoinViewController.m’s viewDidAppear method, add the following line right after where the MatchmakingClient object is allocated:
_matchmakingClient.delegate = self;
And finally, implement the new delegate methods:
#pragma mark - MatchmakingClientDelegate
- (void)matchmakingClient:(MatchmakingClient *)client serverBecameAvailable:(NSString *)peerID
{
[self.tableView reloadData];
}
- (void)matchmakingClient:(MatchmakingClient *)client serverBecameUnavailable:(NSString *)peerID
{
[self.tableView reloadData];
}
You simply tell the table view to reload itself. That means you should also replace the current table data source methods to make all of this work:
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
if (_matchmakingClient != nil)
return [_matchmakingClient availableServerCount];
else
return 0;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
static NSString *CellIdentifier = @"CellIdentifier";
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
if (cell == nil)
cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier];
NSString *peerID = [_matchmakingClient peerIDForAvailableServerAtIndex:indexPath.row];
cell.textLabel.text = [_matchmakingClient displayNameForPeerID:peerID];
return cell;
}
This is very basic table view code. You simply ask the MatchmakingClient object for the rows that the table view should display, using a few new methods that you should add to MatchmakingClient. Add their signatures to MatchmakingClient.h:
- (NSUInteger)availableServerCount;
- (NSString *)peerIDForAvailableServerAtIndex:(NSUInteger)index;
- (NSString *)displayNameForPeerID:(NSString *)peerID;
And their implementations to MatchmakingClient.m:
- (NSUInteger)availableServerCount
{
return [_availableServers count];
}
- (NSString *)peerIDForAvailableServerAtIndex:(NSUInteger)index
{
return [_availableServers objectAtIndex:index];
}
- (NSString *)displayNameForPeerID:(NSString *)peerID
{
return [_session displayNameForPeer:peerID];
}
These methods are pretty simple and serve as convenient wrappers around the _availableServers and _session objects. That should do it, so restart the app on the device that serves as your client. You should see something like this:
It works! The client shows the name of the server (in the screenshot above, I used my iPod as the server).
Unfortunately, it doesn’t look very pretty. That’s easily solved. Add a new Objective-C class to the project, subclass of UITableViewCell, named PeerCell. (I suggest placing the PeerCell source files in a new group named “Views.”)
You can leave PeerCell.h as-is, but replace the contents of PeerCell.m with the following:
#import "PeerCell.h"
#import "UIFont+SnapAdditions.h"
@implementation PeerCell
- (id)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier
{
if ((self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]))
{
self.backgroundView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"CellBackground"]];
self.selectedBackgroundView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"CellBackgroundSelected"]];
self.textLabel.font = [UIFont rw_snapFontWithSize:24.0f];
self.textLabel.textColor = [UIColor colorWithRed:116/255.0f green:192/255.0f blue:97/255.0f alpha:1.0f];
self.textLabel.highlightedTextColor = self.textLabel.textColor;
}
return self;
}
@end
PeerCell is a regular UITableViewCell, but it changes the font and colors of the main textLabel, and it also gives the cell a new background. In JoinViewController’s cellForRowAtIndexPath method, replace the line that allocates the table view cell with:
cell = [[PeerCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier];
Don’t forget to add an #import for PeerCell.h as well. Now the table view cell fits a lot better with the rest of the graphics:
Try the following: exit the app on the device that acts as the server. The client should now remove the name of that server from its table view. If you have enough devices, try the app with more than one device acting as a server. The client should find all the servers and show their names in the table view.
Note: It may take a few seconds for the client to recognize that a server has appeared or disappeared, so don’t panic if the table view doesn’t immediately reload!
A Simple State Machine
The next thing to do is make the client connect to the server. So far, your app hasn’t made any connections – the client has shown to the user what servers are available, but the server doesn’t know anything about the client yet. Only after you tap the name of an available server will the client announce itself to that server.
So the MatchmakingClient can do two different things. At first, it’s looking for servers to join. When you pick a server, it will try to connect to that server. At that point, assuming it connects successfully and stays connected, the MatchmakingClient is no longer interested in any of the other servers. So there’s no reason for it to keep looking for new servers or update its _availableServers list (nor to tell its delegate about this [about what?]).
These different “states of mind” occupied by the MatchmakingClient can be described using a diagram. The full state diagram for the MatchmakingClient looks like this:
That’s four states the MatchmakingClient can occupy. It starts out in the “idle” state, where it just sits there, doing nothing. When you call startSearchingForServersWithSessionID:, it moves to the “Searching for Servers” state. That’s the code you’ve written so far.
When the user decides to connect to a particular server, the client first goes into the “connecting” state, where it attempts to connect to the server, and finally to “connected” when the connection is successfully established. If at any time in these last two states the server drops the connection (or disappears altogether), you move the client back to the idle state.
The MatchmakingClient will behave differently depending on which state it’s occupying. In the “searching for servers” state, it will add to or remove servers from its _availableServers list, but in the “connecting” or “connected” states, it won’t.
Using such a diagram to describe the possible states of your objects, you can make it immediately clear what your objects are supposed to do in different circumstances. You’ll be using state diagrams a few more times in this tutorial, including when managing the game state (which is a bit more complex than what you have here).
The implementation of a state diagram is called a “state machine.” You’ll keep track of the state of the MatchmakingClient using just an enum and an instance variable. Add the following to the top of MatchmakingClient.m, above the @implementation line:
typedef enum
{
ClientStateIdle,
ClientStateSearchingForServers,
ClientStateConnecting,
ClientStateConnected,
}
ClientState;
These four values represent the different states for this object. Also add a new instance variable:
@implementation MatchmakingClient
{
. . .
ClientState _clientState;
}
The state is something internal to this object, so you don’t need to put it in a property. Initially, the state should be “idle,” so add an init method to this class:
- (id)init
{
if ((self = [super init]))
{
_clientState = ClientStateIdle;
}
return self;
}
Now you’ll change some of the methods you wrote earlier to respect the different states. First up is startSearchingForServersWithSessionID:. This method should only take effect if the MatchmakingClient is in the idle state, so change it to:
- (void)startSearchingForServersWithSessionID:(NSString *)sessionID
{
if (_clientState == ClientStateIdle)
{
_clientState = ClientStateSearchingForServers;
// ... existing code goes here ...
}
}
Finally, change the two case statements in session:peer:didChangeState::
// The client has discovered a new server.
case GKPeerStateAvailable:
if (_clientState == ClientStateSearchingForServers)
{
if (![_availableServers containsObject:peerID])
{
[_availableServers addObject:peerID];
[self.delegate matchmakingClient:self serverBecameAvailable:peerID];
}
}
break;
// The client sees that a server goes away.
case GKPeerStateUnavailable:
if (_clientState == ClientStateSearchingForServers)
{
if ([_availableServers containsObject:peerID])
{
[_availableServers removeObject:peerID];
[self.delegate matchmakingClient:self serverBecameUnavailable:peerID];
}
}
break;
You only care about the GKPeerStateAvailable and GKPeerStateUnavailable messages if you’re in the ClientStateSearchingForServers state. Note that there are two types of state here: the state of the peer, as reported by the delegate method, and the state of the MatchmakingClient. I named the latter _clientState, as not to confuse things too much.
Connecting to the Server
Add a new method signature to MatchmakingClient.h:
- (void)connectToServerWithPeerID:(NSString *)peerID;
As the name indicates, you’ll use this to connect the client to the specified server. Add the implementation of this method to the .m file:
- (void)connectToServerWithPeerID:(NSString *)peerID
{
NSAssert(_clientState == ClientStateSearchingForServers, @"Wrong state");
_clientState = ClientStateConnecting;
_serverPeerID = peerID;
[_session connectToPeer:peerID withTimeout:_session.disconnectTimeout];
}
You can only call this method from the “searching for servers” state. If you don’t, then the application will exit with an assertion failure. That’s just a bit of defensive programming to make sure the state machine works.
You change the state to “connecting,” save the server’s peer ID in a new instance variable named _serverPeerID, and tell the GKSession object to connect this client to that peerID. For the timeout value – how long the session waits before it disconnects a peer that doesn’t respond – you use the default disconnect timeout from GKSession.
Add the new _serverPeerID instance variable:
@implementation MatchmakingClient
{
. . .
NSString *_serverPeerID;
}
That’s it for the MatchmakingClient. Now you have to call this new connectToServerWithPeerID: method from somewhere. The obvious place is JoinViewController’s table view delegate. Add the following code to JoinViewController.m:
#pragma mark - UITableViewDelegate
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
[tableView deselectRowAtIndexPath:indexPath animated:YES];
if (_matchmakingClient != nil)
{
[self.view addSubview:self.waitView];
NSString *peerID = [_matchmakingClient peerIDForAvailableServerAtIndex:indexPath.row];
[_matchmakingClient connectToServerWithPeerID:peerID];
}
}
This should be quite straightforward. First you determine the server’s peer ID (by looking at indexPath.row), and then call the new method to make the connection. Note that you also add the UIView from the “waitView” outlet to the screen, in order to cover up the table view and the other controls. Recall from earlier that the waitView is a second top-level view in the nib that you’ll use as a progress indicator.
When you now run the app on a client and tap the name of a server, it looks like this:
The MatchmakingClient has moved into the “connecting” state and is waiting for confirmation from the server. You don’t want the user to try and join any other servers at this point, so you display this temporary waiting screen.
If you take a peek at the Debug Output pane for the server app, you’ll see it now says something to the effect of:
Snap[4503:707] MatchmakingServer: peer 1310979776 changed state 4
Snap[4503:707] MatchmakingServer: connection request from peer 1310979776
These are notifications from GKSession telling the server that the client (who apparently has ID “1310979776” in this example) is attempting to connect. In the next section, you’ll make MatchmakingServer a bit smarter so that it will accept those connections and display the connected clients on the screen.
Note: The debug output says “changed state 4.” This numeric value represents one of the GKPeerState constants:
- 0 = GKPeerStateAvailable
- 1 = GKPeerStateUnavailable
- 2 = GKPeerStateConnected
- 3 = GKPeerStateDisconnected
- 4 = GKPeerStateConnecting
Tip: If you run the app on several devices simultaneously from within Xcode, you can switch between debug output using the debugger bar:
Accepting Connections on the Server
Now you have a client that will attempt to make a connection, but on the server side, you still have to accept that connection before everything is hunky-dory. That happens in the MatchmakingServer.
But before you get to that, put a state machine into the server side of things. Add the following typedef to the top of MatchmakingServer.m:
typedef enum
{
ServerStateIdle,
ServerStateAcceptingConnections,
ServerStateIgnoringNewConnections,
}
ServerState;
Unlike the client, the server only has three states.
That’s pretty simple. The “ignoring new connections” state is for when the host starts the card game. From that point on, no new clients are allowed to connect. Add a new instance variable for keeping track of the current state:
@implementation MatchmakingServer
{
. . .
ServerState _serverState;
}
As with the client, give the server an init method that initializes the state to idle:
- (id)init
{
if ((self = [super init]))
{
_serverState = ServerStateIdle;
}
return self;
}
Add an if-statement to startAcceptingConnectionsForSessionID: that checks whether the state is “idle,” and then changes it to “accepting connections”:
- (void)startAcceptingConnectionsForSessionID:(NSString *)sessionID
{
if (_serverState == ServerStateIdle)
{
_serverState = ServerStateAcceptingConnections;
// ... existing code here ...
}
}
Cool, now why don’t you make the GKSessionDelegate methods do some work. Just as the client is notified of when new servers become available, so is the server notified when a client tries to connect. In MatchmakingServer.m, change session:peer:didChangeState: to:
- (void)session:(GKSession *)session peer:(NSString *)peerID didChangeState:(GKPeerConnectionState)state
{
#ifdef DEBUG
NSLog(@"MatchmakingServer: peer %@ changed state %d", peerID, state);
#endif
switch (state)
{
case GKPeerStateAvailable:
break;
case GKPeerStateUnavailable:
break;
// A new client has connected to the server.
case GKPeerStateConnected:
if (_serverState == ServerStateAcceptingConnections)
{
if (![_connectedClients containsObject:peerID])
{
[_connectedClients addObject:peerID];
[self.delegate matchmakingServer:self clientDidConnect:peerID];
}
}
break;
// A client has disconnected from the server.
case GKPeerStateDisconnected:
if (_serverState != ServerStateIdle)
{
if ([_connectedClients containsObject:peerID])
{
[_connectedClients removeObject:peerID];
[self.delegate matchmakingServer:self clientDidDisconnect:peerID];
}
}
break;
case GKPeerStateConnecting:
break;
}
}
This time you’re interested in the GKPeerStateConnected and GKPeerStateDisconnected states. The logic is very similar to what you’ve seen in the client: you simply add the peer ID to an array and notify the delegate.
Of course, you haven’t defined a delegate protocol for the MatchmakingServer yet. To do that, add the following to the top of MatchmakingServer.h:
@class MatchmakingServer;
@protocol MatchmakingServerDelegate <NSObject>
- (void)matchmakingServer:(MatchmakingServer *)server clientDidConnect:(NSString *)peerID;
- (void)matchmakingServer:(MatchmakingServer *)server clientDidDisconnect:(NSString *)peerID;
@end
You know the drill. Add a property to the @interface section:
@property (nonatomic, weak) id <MatchmakingServerDelegate> delegate;
And synthesize it in the .m file:
@synthesize delegate = _delegate;
But who will play the role of this delegate for the MatchmakingServer? The HostViewController, of course. Switch to HostViewController.h and add MatchmakingServerDelegate to the list of implemented protocols:
@interface HostViewController : UIViewController <UITableViewDataSource, UITableViewDelegate, UITextFieldDelegate, MatchmakingServerDelegate>
Add the following line to HostViewController.m’s viewDidAppear, right after the allocation of MatchmakingServer, in order to hook up the delegate property:
_matchmakingServer.delegate = self;
And implement the delegate methods:
#pragma mark - MatchmakingServerDelegate
- (void)matchmakingServer:(MatchmakingServer *)server clientDidConnect:(NSString *)peerID
{
[self.tableView reloadData];
}
- (void)matchmakingServer:(MatchmakingServer *)server clientDidDisconnect:(NSString *)peerID
{
[self.tableView reloadData];
}
As you did with the MatchmakingClient and the JoinViewController, you simply refresh the contents of the table view. Speaking of which, you still need to implement the data source methods. Replace numberOfRowsInSection and cellForRowAtIndexPath with:
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
if (_matchmakingServer != nil)
return [_matchmakingServer connectedClientCount];
else
return 0;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
static NSString *CellIdentifier = @"CellIdentifier";
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
if (cell == nil)
cell = [[PeerCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier];
NSString *peerID = [_matchmakingServer peerIDForConnectedClientAtIndex:indexPath.row];
cell.textLabel.text = [_matchmakingServer displayNameForPeerID:peerID];
return cell;
}
This pretty much mirrors what you did before, except now you display the list of connected clients rather than the available servers. Because tapping a row in the table view has no effect on this screen, also add the following method to disable row selections:
#pragma mark - UITableViewDelegate
- (NSIndexPath *)tableView:(UITableView *)tableView willSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
return nil;
}
And add an import for PeerCell to the top of the file:
#import "PeerCell.h"
You’re almost there. You just need to add the missing methods to the MatchmakingServer. Add the following signatures to MatchmakingServer.h:
- (NSUInteger)connectedClientCount;
- (NSString *)peerIDForConnectedClientAtIndex:(NSUInteger)index;
- (NSString *)displayNameForPeerID:(NSString *)peerID;
And add their implementations to the .m file:
- (NSUInteger)connectedClientCount
{
return [_connectedClients count];
}
- (NSString *)peerIDForConnectedClientAtIndex:(NSUInteger)index
{
return [_connectedClients objectAtIndex:index];
}
- (NSString *)displayNameForPeerID:(NSString *)peerID
{
return [_session displayNameForPeer:peerID];
}
Whew, that was a lot of typing! Now you can run the app again. Restart it on the device that will function as the server (it’s OK to restart the app on the client device too, but you haven’t changed anything in the client code, so it’s not really necessary).
Now when you tap the name of a server on the client device, the name of that client device should appear in the table view on the server. Try it out.
Except that… nothing happens (got ya!). As I said before, at this point the client is still trying to make a connection to the server, but the connection isn’t fully established until the server accepts it.
GKSession has another delegate method for that, named session:didReceiveConnectionRequestFromPeer:. To accept the incoming connection, the server has to implement this method and call acceptConnectionFromPeer:error: on the session object.
You already have a placeholder implementation of this delegate method in MatchmakingServer.m, so replace it with the following:
- (void)session:(GKSession *)session didReceiveConnectionRequestFromPeer:(NSString *)peerID
{
#ifdef DEBUG
NSLog(@"MatchmakingServer: connection request from peer %@", peerID);
#endif
if (_serverState == ServerStateAcceptingConnections && [self connectedClientCount] < self.maxClients)
{
NSError *error;
if ([session acceptConnectionFromPeer:peerID error:&error])
NSLog(@"MatchmakingServer: Connection accepted from peer %@", peerID);
else
NSLog(@"MatchmakingServer: Error accepting connection from peer %@, %@", peerID, error);
}
else // not accepting connections or too many clients
{
[session denyConnectionFromPeer:peerID];
}
}
First you check if the server's state is "accepting connections." If not, then you obviously don't want to accept any new connections, so you call denyConnectionFromPeer:. You also do that when you already have the maximum number of connected clients, as specified by the maxClients property, which for Snap! is set to 3.
If everything checks out, you'll call acceptConnectionFromPeer:error:. After that, the other GKSession delegate method will be called and the new client will show up in the table view. Restart the app on the server device and try again.
The debug output on the server is now:
Snap[4541:707] MatchmakingServer: Connection accepted from peer 1803140173
Snap[4541:707] MatchmakingServer: peer 1803140173 changed state 2
State 2 corresponds to GKPeerStateConnected. Congrats! You have established a connection between the server and the client. Both devices can now send messages to each other through the GKSession object (something that you will do a lot of shortly).
This is a screenshot of my iPod (the host) with three clients connected:
Note: Even though you can type another name in the text field at the top of the screen, what appears in the table views are always the names of the devices themselves (in other words, the placeholder text from the "Your Name:" text field).
Handling Errors and Disconnects
Here's something to remember when it comes to writing networked code: it is extremely unpredictable. At any given moment, the connection may be broken and you need to gracefully handle that on either side, both client and server.
Here's how to handle the client side. Say the client is waiting to be connected, or that the connection has just been established, and the server suddenly goes away. What you do next depends on your app, but in the case of Snap! you'll return the player to the main screen.
To handle this situation, you have to check for the GKPeerStateDisconnected state in your GKSession delegate methods. Add the following to MatchmakingClient.m:
- (void)session:(GKSession *)session peer:(NSString *)peerID didChangeState:(GKPeerConnectionState)state
{
. . .
switch (state)
{
. . .
// You're now connected to the server.
case GKPeerStateConnected:
if (_clientState == ClientStateConnecting)
{
_clientState = ClientStateConnected;
}
break;
// You're now no longer connected to the server.
case GKPeerStateDisconnected:
if (_clientState == ClientStateConnected)
{
[self disconnectFromServer];
}
break;
case GKPeerStateConnecting:
. . .
}
}
Earlier, you didn't implement anything in the cases for GKPeerStateConnected and GKPeerStateDisconnected, but now you'll move the state machine ahead to the "connected" state for the former, and call the new disconnectFromServer method for the latter. Add this method to the class:
- (void)disconnectFromServer
{
NSAssert(_clientState != ClientStateIdle, @"Wrong state");
_clientState = ClientStateIdle;
[_session disconnectFromAllPeers];
_session.available = NO;
_session.delegate = nil;
_session = nil;
_availableServers = nil;
[self.delegate matchmakingClient:self didDisconnectFromServer:_serverPeerID];
_serverPeerID = nil;
}
Here you return the MatchmakingClient to the "idle" state, and clean up and destroy the GKSession object. You also call a new delegate method to let the JoinViewController know that the client is now disconnected.
Add the signature for this new delegate method to the protocol in MatchmakingClient.h:
- (void)matchmakingClient:(MatchmakingClient *)client didDisconnectFromServer:(NSString *)peerID;
This takes care of the scenario where a client who is already connected to the server gets disconnected, but it's slightly different for a client who is still in the process of connecting. That situation gets handled by yet another GKSessionDelegate method. Replace the following method in MatchmakingClient.m:
- (void)session:(GKSession *)session connectionWithPeerFailed:(NSString *)peerID withError:(NSError *)error
{
#ifdef DEBUG
NSLog(@"MatchmakingClient: connection with peer %@ failed %@", peerID, error);
#endif
[self disconnectFromServer];
}
There's nothing special here. You simply call disconnectFromServer whenever this happens. Note that this delegate method will also be called when a client tries to connect and the server explicitly calls denyConnectionFromPeer:, such as when there are already three clients active.
Because you added a new method to the MatchmakingClientDelegate protocol, you also have to implement this method in JoinViewController.m:
- (void)matchmakingClient:(MatchmakingClient *)client didDisconnectFromServer:(NSString *)peerID
{
_matchmakingClient.delegate = nil;
_matchmakingClient = nil;
[self.tableView reloadData];
[self.delegate joinViewController:self didDisconnectWithReason:_quitReason];
}
That's pretty simple, except maybe for the last line. Because you want to return the player to the main screen, the JoinViewController has to let the MainViewController know that the player got disconnected. There are different reasons why a player can get disconnected, and you need to let the main screen know why, so it can display an alert view if necessary.
For example, if the player quit the game on purpose, then no alert should be shown, because the player already knows why he got disconnected – after all, he pressed the exit button himself. But in case of a networking error, it's good to show some kind of explanation.
That means there are two more things to do here: add this new delegate method to JoinViewControllerDelegate, and add the _quitReason variable.
First, the delegate method. Add the following signature in the appropriate place in JoinViewController.h:
- (void)joinViewController:(JoinViewController *)controller didDisconnectWithReason:(QuitReason)reason;
Xcode will complain now because it doesn't know the symbol QuitReason. This is a typedef that you'll use in a few different classes, so add it to Snap-Prefix.pch so it will be visible in all your code:
typedef enum
{
QuitReasonNoNetwork, // no Wi-Fi or Bluetooth
QuitReasonConnectionDropped, // communication failure with server
QuitReasonUserQuit, // the user terminated the connection
QuitReasonServerQuit, // the server quit the game (on purpose)
}
QuitReason;
Those are the four reasons that Snap! recognizes. The JoinViewController needs an instance variable that stores the reason for quitting. You will set this variable to the proper value in a few different places, and then when the client truly disconnects, you'll pass it along to your own delegate.
Add the instance variable to JoinViewController:
@implementation JoinViewController
{
. . .
QuitReason _quitReason;
}
You'll give this variable its initial value in viewDidAppear:
- (void)viewDidAppear:(BOOL)animated
{
[super viewDidAppear:animated];
if (_matchmakingClient == nil)
{
_quitReason = QuitReasonConnectionDropped;
// ... existing code here ...
}
}
The default value for _quitReason is "connection dropped." Unless the user quits for another reason – for example, by pressing the exit button – a server disconnect will be regarded as a networking problem, rather than something that happened intentionally.
Because you've added a new method to JoinViewController's own delegate protocol, you also have to do some work in MainViewController. Add the following method to MainViewController.m, in the JoinViewControllerDelegate section:
- (void)joinViewController:(JoinViewController *)controller didDisconnectWithReason:(QuitReason)reason
{
if (reason == QuitReasonConnectionDropped)
{
[self dismissViewControllerAnimated:NO completion:^
{
[self showDisconnectedAlert];
}];
}
}
If the disconnect happened because of a network error, then you'll close the Join Game screen and show an alert. The code for showDisconnectedAlert is as follows:
- (void)showDisconnectedAlert
{
UIAlertView *alertView = [[UIAlertView alloc]
initWithTitle:NSLocalizedString(@"Disconnected", @"Client disconnected alert title")
message:NSLocalizedString(@"You were disconnected from the game.", @"Client disconnected alert message")
delegate:nil
cancelButtonTitle:NSLocalizedString(@"OK", @"Button: OK")
otherButtonTitles:nil];
[alertView show];
}
Try it out. Connect a client to the server and tap the Home button on the server device (or exit the app completely). After a second or two, the server will become unreachable and the connection will be dropped at the client end. (It will also be dropped at the server end, but because the server app is now suspended, the server will not see any of this until the app comes to the foreground again.)
The debug output for the client says:
Snap[98048:1bb03] MatchmakingClient: peer 1700680379 changed state 3
Snap[98048:1bb03] dealloc <JoinViewController: 0x9570ee0>
State 3 is, of course, GKPeerStateDisconnected. The app returns to the main screen with an alert message:
As you can see in the debug output, the JoinViewController got properly deallocated. Along with that view controller, the MatchmakingClient object should have gotten deallocated as well. If you want to know for sure, add an NSLog() to dealloc:
- (void)dealloc
{
#ifdef DEBUG
NSLog(@"dealloc %@", self);
#endif
}
This is pretty good, but what if the player taps the exit button after she's been connected to the server? In that case, the client should be the one to break the connection and no alert should be displayed. You can make that happen by fixing exitAction: in JoinViewController.m:
- (IBAction)exitAction:(id)sender
{
_quitReason = QuitReasonUserQuit;
[_matchmakingClient disconnectFromServer];
[self.delegate joinViewControllerDidCancel:self];
}
First you set the quit reason to "user quit," and then you tell the client to disconnect. Now when you receive the matchmakingClient:didDisconnectFromServer: callback message, it will tell the MainViewController that the reason is "user quit," and no alert message is shown.
Xcode complains that the "disconnectFromServer" method is unknown, but that's only because you didn't put it in MatchmakingClient.h yet. Do that now:
- (void)disconnectFromServer;
Run the app again, make a connection, and then press the exit button on the client. In the server debug output you should see that the client disconnected. The client's name should also disappear from the table view.
Note: If you restore the server app after putting it into the background with the Home button, then you need to go back to the main screen and press Host Game again. The GKSession object is no longer valid after the app has been suspended.
"No Network" Errors
Game Kit only lets you set up a peer-to-peer connection over Bluetooth or a Wi-Fi network. If neither Bluetooth nor Wi-Fi are available, you should give the user a nice error message. Fatal errors with GKSession such as these are reported in session:didFailWithError:, so replace that method in MatchmakingClient.m with the following:
- (void)session:(GKSession *)session didFailWithError:(NSError *)error
{
#ifdef DEBUG
NSLog(@"MatchmakingClient: session failed %@", error);
#endif
if ([[error domain] isEqualToString:GKSessionErrorDomain])
{
if ([error code] == GKSessionCannotEnableError)
{
[self.delegate matchmakingClientNoNetwork:self];
[self disconnectFromServer];
}
}
}
The actual error is reported in an NSError object, and if that is a GKSessionCannotEnableError, then the network simply isn't available. In that case, you tell your delegate (with a new method) and disconnect from the server.
Add this new delegate method to the protocol in MatchmakingClient.h:
- (void)matchmakingClientNoNetwork:(MatchmakingClient *)client;
And its implementation in JoinViewController.m:
- (void)matchmakingClientNoNetwork:(MatchmakingClient *)client
{
_quitReason = QuitReasonNoNetwork;
}
That was pretty simple: you just set the quit reason to "no network." Because the MatchmakingClient calls disconnectFromServer, the JoinViewController also gets a didDisconnectFromServer message and tells the MainViewController about it. All you have to do now is make MainViewController handle this new quit reason.
Replace the following method in MainViewController.m:
- (void)joinViewController:(JoinViewController *)controller didDisconnectWithReason:(QuitReason)reason
{
if (reason == QuitReasonNoNetwork)
{
[self showNoNetworkAlert];
}
else if (reason == QuitReasonConnectionDropped)
{
[self dismissViewControllerAnimated:NO completion:^
{
[self showDisconnectedAlert];
}];
}
}
The code for showNoNetworkAlert is:
- (void)showNoNetworkAlert
{
UIAlertView *alertView = [[UIAlertView alloc]
initWithTitle:NSLocalizedString(@"No Network", @"No network alert title")
message:NSLocalizedString(@"To use multiplayer, please enable Bluetooth or Wi-Fi in your device's Settings.", @"No network alert message")
delegate:nil
cancelButtonTitle:NSLocalizedString(@"OK", @"Button: OK")
otherButtonTitles:nil];
[alertView show];
}
To test this code, run the app on a device in Airplane Mode (with Wi-Fi and Bluetooth turned off).
Note: On my devices, I have to go into the Join Game screen (where nothing happens), press the exit button to go back to main, and then go into the Join Game screen again. I'm not sure why Game Kit doesn't recognize this problem on the first try. Maybe a more robust solution would use the Reachability API to check for Bluetooth and Wi-Fi availability.
The debug output should say:
MatchmakingClient: session failed Error Domain=com.apple.gamekit.GKSessionErrorDomain Code=30509 "Network not available." UserInfo=0x1509b0 {NSLocalizedFailureReason=WiFi and/or Bluetooth is required., NSLocalizedDescription=Network not available.}
And the screen shows an alert view:
For this "no network" error, you don't actually leave the Join Game screen, even though you stop the session and any networking activity. I think that jumping back to the main screen would be too disorienting for the user.
Note: The code that displays the alert views – and in fact any code that displays text in this app – uses the NSLocalizedString() macro for internationalization. Even if your apps only do English at first, it's smart to prepare your code for localizations that you may do later. For more information, see this tutorial.
There is one more situation you need to handle on the client side. In my testing, I found that sometimes a server becomes unavailable while a client is trying to connect to it. In that case, the client receives a callback with state GKPeerStateUnavailable.
If you didn't handle this situation, then eventually the client would timeout, and the user would get some kind of error message. But you can code the app to check for this type of disconnect, too.
In MatchmakingClient.m, change the case for GKPeerStateUnavailable to:
// The client sees that a server goes away.
case GKPeerStateUnavailable:
if (_clientState == ClientStateSearchingForServers)
{
// ... existing code here ...
}
// Is this the server we're currently trying to connect with?
if (_clientState == ClientStateConnecting && [peerID isEqualToString:_serverPeerID])
{
[self disconnectFromServer];
}
break;
Handling Errors On the Server
On the server, dealing with disconnects and errors is very similar. You already have the code in place to deal with clients who disconnect, so that's easy.
First, handle the "no network" situation. In MatchmakingServer.m, change session:didFailWithError: to:
- (void)session:(GKSession *)session didFailWithError:(NSError *)error
{
#ifdef DEBUG
NSLog(@"MatchmakingServer: session failed %@", error);
#endif
if ([[error domain] isEqualToString:GKSessionErrorDomain])
{
if ([error code] == GKSessionCannotEnableError)
{
[self.delegate matchmakingServerNoNetwork:self];
[self endSession];
}
}
}
This is almost identical to what you did with the MatchmakingClient, except that now you call a method named endSession to clean up after yourself. Add endSession:
- (void)endSession
{
NSAssert(_serverState != ServerStateIdle, @"Wrong state");
_serverState = ServerStateIdle;
[_session disconnectFromAllPeers];
_session.available = NO;
_session.delegate = nil;
_session = nil;
_connectedClients = nil;
[self.delegate matchmakingServerSessionDidEnd:self];
}
No big surprises here. You call two new delegate methods, matchmakingServerNoNetwork: and matchmakingServerSessionDidEnd:. Add these to your protocol in MatchmakingServer.h, then implement them in the HostViewController.
First, the new method signatures for the protocol:
- (void)matchmakingServerSessionDidEnd:(MatchmakingServer *)server;
- (void)matchmakingServerNoNetwork:(MatchmakingServer *)server;
Then, the corresponding implementations in HostViewController.m:
- (void)matchmakingServerSessionDidEnd:(MatchmakingServer *)server
{
_matchmakingServer.delegate = nil;
_matchmakingServer = nil;
[self.tableView reloadData];
[self.delegate hostViewController:self didEndSessionWithReason:_quitReason];
}
- (void)matchmakingServerNoNetwork:(MatchmakingServer *)server
{
_quitReason = QuitReasonNoNetwork;
}
Again, you've seen this logic before. To make this work, add the _quitReason instance variable to HostViewController:
@implementation HostViewController
{
. . .
QuitReason _quitReason;
}
And add a new method to its delegate protocol in HostViewController.h:
- (void)hostViewController:(HostViewController *)controller didEndSessionWithReason:(QuitReason)reason;
Finally, implement this method in MainViewController.m:
- (void)hostViewController:(HostViewController *)controller didEndSessionWithReason:(QuitReason)reason
{
if (reason == QuitReasonNoNetwork)
{
[self showNoNetworkAlert];
}
}
Run the app on the device in Airplane Mode and try to host a game. You should get the "no network" error. (If you don't get it the first time, exit to the main menu and tap the Host Game button again.) Go to the Settings app, turn off Airplane Mode, and switch back to Snap! again. Tap the Host Game button once more, and now clients should be able to find the server.
Just to play nice, you should also end the session when the user taps the exit button from the Host Game screen, so replace exitAction: in HostViewController.m with the following:
- (IBAction)exitAction:(id)sender
{
_quitReason = QuitReasonUserQuit;
[_matchmakingServer endSession];
[self.delegate hostViewControllerDidCancel:self];
}
Of course, endSession is not a public method yet, so add it to the @interface of MatchmakingServer as well:
- (void)endSession;
Phew, what a lot of work just to get the server and clients to find each other! (Believe me, it would have been a ton more work if you didn't have GKSession!)
The cool thing is, you can drop the MatchmakingServer and MatchmakingClient classes into other projects and get all this functionality for free! Because these classes are designed to be independent of any view controllers, they are easy to reuse in other projects.
Where To Go From Here?
Here is a sample project with all of the code from the tutorial series so far.
Get ready to tackle Part 3, where you'll get the client and server sending messages across the network to each other!
In the meantime, if you have any questions or comments about this part of the series, 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 Google+ and Twitter.