Apple introduced AirPlay in iOS 5, allowing your iOS devices to stream content to an Apple TV. This opened up a lot of gaming possibilities, such as using an Apple TV as one display and your iOS device as another.
GameKit was introduced back in iOS 3 (or was it still “iPhoneOS” back then?) and even though it has evolved a lot over the years, it had one interesting capability from the start called peer-to-peer connectivity. This can be used as a communication channel for multiplayer gaming.
In this tutorial you’ll see how to use AirPlay and the peer-to-peer connectivity feature of GameKit to create a trivia game that displays the question and answers through an Apple TV. Each player will use their own iOS device to answer the questions, and the first player to answer correctly wins the point!
The trivia game uses the Sprite Kit framework to handle the drawing and the UI. As covering Sprite Kit in depth is not the goal of this tutorial, it’s okay if you’re not familiar with it. If you’re curious, you can check out a few Sprite Kit tutorials from our site.
You won’t need an Apple TV either – you can use the simulator to mimic the external display if need be.
Getting Started
To get started, download the starter project and unzip the file.
Build and run your project; you should see the following screen:
Feel free to take a peek through the starter project. You’ll see that it includes the code necessary for the main game logic of the quiz game and its user interface, but no code related to Airplay or multiplayer logic yet.
Once you’re ready taking a look through, it’s time to start learning about AirPlay and GameKit!
Setting Up a Secondary Screen
First of all, note that by default your iOS device supports screen mirroring to an external display (like an Apple TV) without you having to write one line of code. You simply swipe up from the bottom of the screen, tap the AirPlay button, and then select your external device and it just works:
However, often in games you want to have your iOS device show one thing, and your external display show something else. For example, in this quiz game we want the Apple TV to show one screen (the question) and the iOS devices to show a different screen (buttons to select the answers). Doing this requires some code, so that is the focus of this tutorial.
Also note that AirPlay doesn’t have a specific API to output to an Apple TV; instead, it uses the generic external display API. This means using the same API you can either connect wirelessly to an AppleTV over AirPlay, or manually connect to an external TV or monitor using one of the cables that Apple sells for this purpose.
So, if you want to display different things to different screens (i.e. not mirroring), and regardless of what type of external display you’re connecting to (Apple TV or something else), the first thing you’ll need to do is to detect whether a new external display is available.
Open ATViewController.m and add the following methods to the end of the class implementation:
#pragma mark - AirPlay and extended display - (void)setupOutputScreen { // Register for screen notifications NSNotificationCenter *center = [NSNotificationCenter defaultCenter]; [center addObserver:self selector:@selector(screenDidConnect:) name:UIScreenDidConnectNotification object:nil]; [center addObserver:self selector:@selector(screenDidDisconnect:) name:UIScreenDidDisconnectNotification object:nil]; [center addObserver:self selector:@selector(screenModeDidChange:) name:UIScreenModeDidChangeNotification object:nil]; // Setup screen mirroring for an existing screen NSArray *connectedScreens = [UIScreen screens]; if ([connectedScreens count] > 1) { UIScreen *mainScreen = [UIScreen mainScreen]; for (UIScreen *aScreen in connectedScreens) { if (aScreen != mainScreen) { // We've found an external screen ! [self setupMirroringForScreen:aScreen]; break; } } } } - (void)screenDidConnect:(NSNotification *)aNotification { } - (void)screenDidDisconnect:(NSNotification *)aNotification { } - (void)screenModeDidChange:(NSNotification *)aNotification { } - (void)setupMirroringForScreen:(UIScreen *)anExternalScreen { } - (void)disableMirroringOnCurrentScreen { } |
setupOutputScreen
observes three notifications to tell you when an external display is connected, disconnected or changed. However, the notifications only cover changes to the display state — they won’t tell you if you already have a display plugged in.
To cover the case of displays that are already connected, you need to loop through [UIScreen screens]
which returns an array of all screens connected to the device. If you find a screen that is NOT the main screen, then you can assume this is the external display. Once you populate the empty setupMirroringForScreen:
method, it will send a different scene to that screen.
Your next task is to populate all the empty methods you added above starting with screenDidConnect:
.
Add the following code to screenDidConnect:
:
NSLog(@"A new screen got connected: %@", [aNotification object]); [self setupMirroringForScreen:[aNotification object]]; |
The object
property of the notification contains the UIScreen
object of the new connected display. When you receive the notification, simply log the change and call the same setupMirroringForScreen:
to set up the mirroring.
Add the following code to screenDidDisconnect:
NSLog(@"A screen got disconnected: %@", [aNotification object]); [self disableMirroringOnCurrentScreen]; |
Here you’re simply performing the reverse of screenDidConnect:
: log the notification and disable mirroring of the display. disableMirroringOnCurrentScreen
is still just a shell — you’ll flesh it out later.
Next, add the following code to screenModeDidChange:
:
NSLog(@"A screen mode changed: %@", [aNotification object]); [self disableMirroringOnCurrentScreen]; [self setupMirroringForScreen:[aNotification object]]; |
This method performs a reset by disabling the screen and setting it up again. This ensures the new screen mode and settings are the ones used in the scene.
Before you fill in the logic behind setupMirroringForScreen:
you’ll need some properties to store the states of your various objects.
Add the following code to the top of ATViewController.m:
#import "ATAirPlayScene.h" |
Next, find the following line, located just below the includes
:
@property (nonatomic, strong) ATMyScene *scene; |
…and add the following properties directly below that line:
@property (nonatomic, strong) UIWindow *mirroredWindow; @property (nonatomic, strong) UIScreen *mirroredScreen; @property (nonatomic, strong) SKView *mirroredScreenView; @property (nonatomic, strong) ATAirPlayScene *mirroredScene; |
These three properties store your secondary UIWindow
and UIScreen
objects, the corresponding SKView
for that screen, and the Sprite Kit scene with the interface that displays the question and answers to the external display.
Add the following code to setupMirroringForScreen:
self.mirroredScreen = anExternalScreen; // Find max resolution CGSize max = {0, 0}; UIScreenMode *maxScreenMode = nil; for (UIScreenMode *current in self.mirroredScreen.availableModes) { if (maxScreenMode == nil || current.size.height > max.height || current.size.width > max.width) { max = current.size; maxScreenMode = current; } } self.mirroredScreen.currentMode = maxScreenMode; |
In the code above, you first store the screen sent to the method in mirroredScreen
for later use. Next, you loop through the screen’s availableModes
to find the maximum supported screen mode and then set that as the screen’s currentMode
.
This method is not quite complete; there’s still a little to add.
Add the following code directly after the code you added above:
// Setup window in external screen self.mirroredWindow = [[UIWindow alloc] initWithFrame:self.mirroredScreen.bounds]; self.mirroredWindow.hidden = NO; self.mirroredWindow.layer.contentsGravity = kCAGravityResizeAspect; self.mirroredWindow.screen = self.mirroredScreen; self.mirroredScreenView = [[SKView alloc] initWithFrame:self.mirroredScreen.bounds]; // Create and configure the scene. self.mirroredScene = [ATAirPlayScene sceneWithSize:self.mirroredScreenView.bounds.size]; self.mirroredScene.scaleMode = SKSceneScaleModeAspectFill; // Present the scene. [self.mirroredScreenView presentScene:self.mirroredScene]; [self.mirroredWindow addSubview:self.mirroredScreenView]; |
The above code illustrates how easy it is to present something to the new screen. First, you create a new UIWindow
with the size of the secondary screen. Since windows are set to hidden
by default, you need to un-hide them by setting the property to NO
.
According to CALayer class reference, contentsGravity
“specifies how the layer’s contents are positioned or scaled within its bounds”. In your implementation of screenModeDidChange:
you disable and set up the window again when the screen changes, so you only have to set the contentsGravity
to aspect fill. Finally, you set the screen of this new window to the passed-in screen.
Next you create a SKView
with the same size as the window. SKView
is Sprite Kit’s UIView
subclass; if you were creating a project without Sprite Kit, you’d create a new UIView
instance here instead of SKView
.
Finally, you create a new ATAirPlayScene
instance, instruct Sprite Kit to present this scene in the newly created view, and add the view to the new window.
There’s only one empty method remaining: disableMirroringOnCurrentScreen
.
Add the following code to disableMirroringOnCurrentScreen
:
[self.mirroredScreenView removeFromSuperview]; self.mirroredScreenView = nil; self.mirroredScreen = nil; self.mirroredScene = nil; self.mirroredWindow = nil; [self.scene enableStartGameButton:NO]; |
This method cleans up all the properties you created in the previous method. You also call enableStartGameButton:
to disable the start button; you haven’t yet seen this but you’ll come across it later as part of the game logic.
This button is only enabled on the device with the secondary display and only when there’s more than one player connected. If you lose a display, then you need to disable this button.
The final piece is to get the ball rolling and call the setupOutputScreen
you just wrote. To do this, add the following line to the end of viewDidLoad
:
[self setupOutputScreen]; |
Build and run your project; you should see the same screen as before:
In the simulator menu, choose Hardware\TV Out\640×480, and a new window opens with the simulated TV output.
At this point, a bug in the simulator may cause the app to crash. This is only an issue in the simulator and won’t happen when you use a real Apple TV or a cable, so don’t worry too much about it. Run the project again without quitting the simulator and you should now see the following on both displays:
If you want to see this on your Apple TV, run the project on a physical device, open Control Center and choose your Apple TV in the AirPlay menu.
Connecting Other Players
Now comes the fun part: getting more devices to connect and play the game.
The main class of GameKit’s peer-to-peer communication is GKSession
. Quoting from the documentation: “A GKSession object provides the ability to discover and connect to nearby iOS devices using Bluetooth or Wi-fi.”
As with most communication protocols, a GameKit session has the concept of a “server” and a “client”. From the documentation: “Sessions can be configured to broadcast a session ID (as a server), to search for other peers advertising with that session ID (as a client), or to act as both a server and a client simultaneously (as a peer)”.
In your game, since there should be only one person connected to an external display, your device will start a session as a server whenever a device connects to an external display. If a device isn’t connected to a display, it starts a session as a client and starts looking for a server.
Why not just use straight peer-to-peer networking? If every device was a peer — that is, a client and a server simultaneously — it would be incredibly complex to manage all connected device and control gameplay. Having a single server to control the game greatly simplifies the game logic.
Add the following code to the bottom of ATViewController.m:
#pragma mark - GKSession master/slave - (BOOL)isServer { return self.mirroredScreen != nil; } |
The above code uses the mirroredScreen
property that is set and cleared by the secondary screen notifications to determine if this device is a server or not.
Before you can start the GKSession
, there’s a few more things that you’ll need. A GKSession
reports peer discovery and communication using a delegate. Therefore, you need to implement the delegate protocol in a class that will receive all these events.
Since ATViewController
is controlling your game, this is the best class to act as the delegate of the GKSession
.
Open ATViewController.h and add the following header right after the SpriteKit header:
#import <GameKit/GameKit.h> |
Now, find the following line:
@interface ATViewController : UIViewController |
…and add the GKSessionDelegate
protocol to it so that it looks like the line below:
@interface ATViewController : UIViewController <GKSessionDelegate> |
Go back to ATViewController.m and add the following protocol method stubs to the end of the class:
#pragma mark - GKSessionDelegate /* Indicates a state change for the given peer. */ - (void)session:(GKSession *)session peer:(NSString *)peerID didChangeState:(GKPeerConnectionState)state { } /* Indicates a connection request was received from another peer. Accept by calling -acceptConnectionFromPeer: Deny by calling -denyConnectionFromPeer: */ - (void)session:(GKSession *)session didReceiveConnectionRequestFromPeer:(NSString *)peerID { } /* Indicates a connection error occurred with a peer, which includes connection request failures, or disconnects due to timeouts. */ - (void)session:(GKSession *)session connectionWithPeerFailed:(NSString *)peerID withError:(NSError *)error { } /* Indicates an error occurred with the session such as failing to make available. */ - (void)session:(GKSession *)session didFailWithError:(NSError *)error { } - (void)receiveData:(NSData *)data fromPeer:(NSString *)peer inSession:(GKSession *)session context:(void *)context { } |
You’ll populate these methods later; you’ve simply added them now to avoid compilation errors.
Before you set up a GKSession
, you’ll need to add a few properties to store some several objects.
Add the following properties near the top of the file, in the same block as the other properties:
@property (nonatomic, strong) GKSession *gkSession; @property (nonatomic, strong) NSMutableDictionary *peersToNames; @property (nonatomic, assign) BOOL gameStarted; |
The first property holds the GKSession
; the second is a dictionary that stores the ID of your peers and their respective advertised names; and the last one is a boolean that indicates if the game has started yet. You’ll use all of these properties in the following steps.
Add the following code immediately after the stub methods you added above:
- (void)startGKSession { // Just in case we're restarting the session as server self.gkSession.available = NO; self.gkSession = nil; // Configure GameKit session. self.gkSession = [[GKSession alloc] initWithSessionID:@"AirTrivia" displayName:[[UIDevice currentDevice] name] sessionMode:self.isServer ? GKSessionModeServer : GKSessionModeClient]; [self.gkSession setDataReceiveHandler:self withContext:nil]; self.gkSession.delegate = self; self.gkSession.available = YES; self.peersToNames = [[NSMutableDictionary alloc] init]; if (self.isServer) { self.peersToNames[self.gkSession.peerID] = self.gkSession.displayName; } } |
The first few lines are simply cleanup code for the case where the session is being restarted.
Next, you initialize the session object. SessionID
is an ID unique to this app so that multiple devices with all kinds of GameKit apps can find each other.
The displayName
parameter tells GKSession
how this device should be identified to other peers. You can put whatever you like in this parameter, but here you’ll just use the device name for simplicity. The last parameter specifies the sessionMode
, which indicates whether the device is a server or a client.
Once the GKSession
is initialized, you tell your GKSession
that ATViewController
will be responsible for retrieving data from all peers and will also be the delegate for all events. Next, you set the session to be available; this signals GKSession
to begin broadcasting over Wi-Fi and Bluetooth to try to find peers.
Finally, you initialize the peerToNames
dictionary that tracks the other devices. If the current device is a server, it should be added to the dictionary to start.
Now you need to call this method in viewDidLoad
to start the session when the game starts.
Add the following line to the end of viewDidLoad
:
[self startGKSession]; |
You also need to call this method when the device switches between client and server modes.
Add the same line to the end of setupMirroringForScreen:
:
[self startGKSession]; |
Now that the Game Kit setup is complete, it’s time to start filling in those delegate methods!
Add the following code to session:peer:didChangeState:
:
BOOL refresh = NO; switch (state) { case GKPeerStateAvailable: if (!self.gameStarted) { [self.gkSession connectToPeer:peerID withTimeout:60.0]; } break; case GKPeerStateConnected: if (!self.gameStarted) { self.peersToNames[peerID] = [self.gkSession displayNameForPeer:peerID]; refresh = YES; } break; case GKPeerStateDisconnected: case GKPeerStateUnavailable: [self.peersToNames removeObjectForKey:peerID]; refresh = YES; break; default: break; } if (refresh && !self.gameStarted) { [self.mirroredScene refreshPeers:self.peersToNames]; [self.scene enableStartGameButton:self.peersToNames.count >= 2]; } |
This method executes whenever a peer changes state. Possible states for peers are:
GKPeerStateAvailable
: A new peer has been found and is available; in this case, you callconnectToPeer:withTimeout:
ofGKSession
. If the connection is successful you will get another state change callback withGKPeerStateConnected
.GKPeerStateConnected
: The peer is now connected. In this case you add the peer name to thepeerToNames
dictionary and set the refresh flag to YES.GKPeerStateDisconnected
andGKPeerStateUnavailable
: A peer has disconnected for some reason or has become unavailable. In this case you remove the name from thepeerToNames
dictionary and set the refresh flag to YES.
Finally, if the game has not started yet and the refresh flag is YES, send the updated peerToNames
dictionary to the scene on the secondary screen and instruct scene on the device to enable the start game button if there are at least two connected players.
In order to establish a connection, one peer needs to ask the other to connect, as it’s being done in the method above when a peer is made available. The other side has to accept this connection request in order for the two to communicate.
Add the following code to session:didReceiveConnectionRequestFromPeer:
:
if (!self.gameStarted) { NSError *error = nil; [self.gkSession acceptConnectionFromPeer:peerID error:&error]; if (error) { NSLog(@"Error accepting connection with %@: %@", peerID, error); } } else { [self.gkSession denyConnectionFromPeer:peerID]; } |
This delegate method executes on one device when the other device calls connectToPeer:withTimeout:
. If the game has not yet started, accept the connection and report any errors that might occur. If the game has already started, refuse the connection.
When one device accepts the connection, the other will receive another state change notification of GKPeerStateConnected
and the new device will be added to the list of players.
To test this, you’ll need to run two copies of the app: one as the server, and another as the client. The easiest way to do this is run the simulator as a server and have a physical device run another copy of the app as the client.
If you don’t have a paid developer account and can’t run apps on a device, you can try to run two simulators on the same machine in a pinch. It’s not impossible, but it’s not the most straightforward task, either. If you want to go this route, take a look at this Stack Overflow answer for ways to accomplish this.
Build and run the app on your simulator; you’ll see the same familiar starting screen:
Now start the app on your device. If your device and your computer are on the same network, you should see something similar to the following on your simulator:
The device name appears on the “TV” and the iOS Simulator now shows a “Start Game” button. Your device will still display the “Waiting for players!” label.
The next step is to add more communication between the server and the client so that the gameplay can start.
Communicating With Other Devices
Now that you have two copies of the app open, it’s time to add some communication between them with GKSession
as the intermediary.
GKSession
has two methods to send data to peers, namely:
(BOOL)sendData:(NSData *)data toPeers:(NSArray *)peers withDataMode:(GKSendDataMode)mode error:(NSError **)error
…and…
(BOOL)sendDataToAllPeers:(NSData *)data withDataMode:(GKSendDataMode)mode error:(NSError **)error
Both methods send data wrapped in an NSData
object to one or more peers. For this project you’re going to use the first method to see how the the more complicated method works. Another advantage of the first method is that you can send a message to yourself. Although it sounds strange, this comes in handy when the server triggers its own response, as if a client sent some data.
The server can have many peers (including itself) but a client will only ever have one peer: the server. In both cases, using the method that sends a message to all peers covers the common case for both server and client.
An NSData
object can hold any kind of data; therefore you’ll be sending commands as NSString
objects wrapped in NSData
, and vice-versa when receiving data, to help with debugging.
Add the following method to the bottom of ATViewController.m:
#pragma mark - Peer communication - (void)sendToAllPeers:(NSString *)command { NSError *error = nil; [self.gkSession sendData:[command dataUsingEncoding:NSUTF8StringEncoding] toPeers:self.peersToNames.allKeys withDataMode:GKSendDataReliable error:&error]; if (error) { NSLog(@"Error sending command %@ to peers: %@", command, error); } } |
As the name suggests, this method sends an NSString to all connected peers. The NSString instance method dataUsingEncoding:
converts the string into a null-terminated UTF-8 stream of bytes in an NSData object.
On the receiving end, the GKSession
delegate callback receiveData:fromPeer:inSession:context:
you added in the previous section is still empty. Your job is to add the receiving logic.
Add the following code to receiveData:fromPeer:inSession:context:
:
NSString *commandReceived = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; NSLog(@"Command %@ received from %@ (%@)", commandReceived, peer, self.peersToNames[peer]); |
So far you’re simply decoding the raw data back to an NSString and logging the result.
To test this, you can send a command and check the results in the console.
Add the following code to the bottom of startGame
in ATViewController.m:
[self sendToAllPeers:@"TEST COMMAND"]; |
You call startGame
when the user taps the “Start Game” button on the server device. This instructs the server to send this command to all peers.
Build and run the app; first on the simulator, then on the device. The final device you start will be the one logging to the visible console, and you want to make sure the message makes it to the device.
Once the app is running on the simulator, tap the “Start Game” button. You should see the following messages appear on the console:
Wasn’t that easy? Now that you have messages moving around, you only need to add a few commands and wire things up to create the trivia game.
Adding the Game Logic
Since this is a trivia game, you’re going to need a few questions with multiple answers. The easiest ones I could find with no weird licenses attached was an exercise page for a CS course on Georgia Tech called Trivia Database Starter.
I converted the CSV to a friendly plist, which you’ll find in the project as questions.plist. The plist contains an array of arrays. Every inner array has the question as the first element, the right answer as the second (no peeking!) and some wrong answers after that.
Open ATViewController.m and add the following properties to the existing block of properties at the top of the file:
@property (nonatomic, strong) NSMutableArray *questions; @property (nonatomic, strong) NSMutableDictionary *peersToPoints; @property (nonatomic, assign) NSInteger currentQuestionAnswer; @property (nonatomic, assign) NSInteger currentQuestionAnswersReceived; @property (nonatomic, assign) NSInteger maxPoints; |
Here’s what each property stores:
questions
– The remaining questions and answers. It’s mutable, as each time a question is asked it will be removed from the array. Then you won’t repeat a question, and you’ll know when to end the game.peersToPoints
– The current score, stored as the number of points for each peer.currentQuestionAnswer
– The index of the correct answer for the current question.currentQuestionAnswersReceived
– A count of how many answers have been received.maxPoints
– The current high score, to make it easy to find the winner inpeerToPoints
later on.
All of the game properties are ready; now you can add the code to start the gameplay.
Remove the test line you added previously and add the following code to startGame
in its place:
if (!self.gameStarted) { self.gameStarted = YES; self.maxPoints = 0; self.questions = [[NSArray arrayWithContentsOfFile: [[NSBundle mainBundle] pathForResource:@"questions" ofType:@"plist"]] mutableCopy]; self.peersToPoints = [[NSMutableDictionary alloc] initWithCapacity:self.peersToNames.count]; for (NSString *peerID in self.peersToNames) { self.peersToPoints[peerID] = @0; } } |
If the game has not yet started, set the flag to YES and reset maxPoints
. You then load in the list of questions from the plist. You’ll need a mutable copy of the questions so they can be removed from the array as they’re used. Then you intialize peersToPoints
so that everyone starts with 0 points.
There aren’t any commands yet, so the game is ready to begin, but it hasn’t really started yet. You’ll do that next.
First, add the following command constants to the top of ATViewController.m after all of the includes
:
static NSString * const kCommandQuestion = @"question:"; static NSString * const kCommandEndQuestion = @"endquestion"; static NSString * const kCommandAnswer = @"answer:"; |
You’ll see in a moment how these are used.
Next, add the following method immediately after startGame
:
- (void)startQuestion { // 1 int questionIndex = arc4random_uniform((int)[self.questions count]); NSMutableArray *questionArray = [self.questions[questionIndex] mutableCopy]; [self.questions removeObjectAtIndex:questionIndex]; // 2 NSString *question = questionArray[0]; [questionArray removeObjectAtIndex:0]; // 3 NSMutableArray *answers = [[NSMutableArray alloc] initWithCapacity:[questionArray count]]; self.currentQuestionAnswer = -1; self.currentQuestionAnswersReceived = 0; while ([questionArray count] > 0) { // 4 int answerIndex = arc4random_uniform((int)[questionArray count]); if (answerIndex == 0 && self.currentQuestionAnswer == -1) { self.currentQuestionAnswer = [answers count]; } [answers addObject:questionArray[answerIndex]]; [questionArray removeObjectAtIndex:answerIndex]; } // 5 [self sendToAllPeers:[kCommandQuestion stringByAppendingString: [NSString stringWithFormat:@"%lu", (unsigned long)[answers count]]]]; [self.scene startQuestionWithAnswerCount:[answers count]]; [self.mirroredScene startQuestion:question withAnswers:answers]; } |
Here’s how starting a new trivia question works:
- First, choose a random question from the list of remaining questions.
questionArray
holds a copy the question data; the selected question is removed from the master list. - The question text is the first element of the array, followed by the possible answers. Here you store the question text and remove it from the array. Now
questionArray
contains the answers, with the correct answer as the first element. - Initialize a mutable array to hold the shuffled list of answers, and reset a few properties.
- Inside the loop, randomly choose an answer from the array. If it’s the first element — i.e., the correct answer — and the first element has not yet been removed, store the answer’s index. Then add it to the shuffled
answers
array and remove it from the available answers array. - Finally, send the Question command to all peers along with the number of possible answers. As an example, the command will look something like “question:4″. You then update the scene and send the question and the shuffled list of answers to the scene on the secondary screen.
Next, add a call to the above method to the end of startGame
inside the if
block:
[self startQuestion]; |
This takes care of the server actions at the start of the game.
Now, your clients need to act accordingly when they receive a command from the server.
Add the code below to the bottom of receiveData:fromPeer:inSession:context:
:
if ([commandReceived hasPrefix:kCommandQuestion] && !self.isServer) { NSString *answersString = [commandReceived substringFromIndex:kCommandQuestion.length]; [self.scene startQuestionWithAnswerCount:[answersString integerValue]]; } |
Assuming there will never be more than nine possible answers, the final character of string like “question:4″ will represent the number of answers. answersString
stores this character, which you convert to a numeric value and pass to startQuestionWithAnswerCount:
so that the scene can present the number of answer buttons specified.
Build and run your project; first on the simulator, then on your device. As soon as you see the “Start Game” button, tap it. You should see something like the following on the simulator:
The screen on the device should display the same elements as the simulator’s primary screen. You might get a different number of buttons than shown here depending on the question. Tap on a button on the device or simulator, and the screen will change to the following:
But right now, nothing else happens. This is because ATMyScene
only has logic to remove the buttons and call a method in ATViewController
when you click on an answer — but that method in ATViewController
is still empty.
Find sendAnswer:
in ATViewController.m and add the following code to it:
[self sendToAllPeers:[kCommandAnswer stringByAppendingString: [NSString stringWithFormat:@"%ld", (long)answer]]]; |
The above code is very straightforward; you only need to send the Answer command with the selected answer index.
The server picks up this message inside receiveData:fromPeer:inSession:context:
.
Add the following code to the end of that method:
if ([commandReceived hasPrefix:kCommandAnswer] && self.isServer) { NSString *answerString = [commandReceived substringFromIndex:kCommandAnswer.length]; NSInteger answer = [answerString integerValue]; if (answer == self.currentQuestionAnswer && self.currentQuestionAnswer >= 0) { self.currentQuestionAnswer = -1; NSInteger points = 1 + [self.peersToPoints[peer] integerValue]; if (points > self.maxPoints) { self.maxPoints = points; } self.peersToPoints[peer] = @(points); [self endQuestion:peer]; } else if (++self.currentQuestionAnswersReceived == self.peersToNames.count) { [self endQuestion:nil]; } } |
Here, you check to see if the command is the “answer” command. If the answer given is the correct one, reset currentQuestionAnswer
to -1 to prepare for the next question. After giving the player a point, you may need to update maxPoints
if the player’s score is the new high score. Finally, you call the stubbed-out endQuestion:
.
If the answer is incorrect, but the number of answers received is the same as the number of players, the current roundof questions is over and you call endQuestion:
with a nil
argument.
Implementing endQuestion:
is the next obvious step.
Add the following code directly after receiveData:fromPeer:inSession:context:
:
- (void)endQuestion:(NSString *)winnerPeerID { [self sendToAllPeers:kCommandEndQuestion]; NSMutableDictionary *namesToPoints = [[NSMutableDictionary alloc] initWithCapacity:self.peersToNames.count]; for (NSString *peerID in self.peersToNames) { namesToPoints[self.peersToNames[peerID]] = self.peersToPoints[peerID]; } [self.mirroredScene endQuestionWithPoints:namesToPoints winner:winnerPeerID ? self.peersToNames[winnerPeerID] : nil]; [self.scene endQuestion]; dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, 4 * NSEC_PER_SEC); dispatch_after(popTime, dispatch_get_main_queue(), ^(void){ [self startQuestion]; }); } |
This method first sends the command that tells the clients to end the current question; you’ll deal with that in a moment. Next, it creates a dictionary that maps names to points and sends it to the secondary screen scene to show the current standings and which player, if any, guessed the correct answer. Finally, it schedules a block to run four seconds later that calls startQuestion
to show the next question and start the loop again.
The clients will need to deal with the end question command.
Add the following code to the end of receiveData:fromPeer:inSession:context:
:
if ([commandReceived isEqualToString:kCommandEndQuestion] && !self.isServer) { [self.scene endQuestion]; } |
When a client receives this command, it simply needs to call endQuestion
to end the question and hide the answer buttons.
Build and run your app; first on the simulator, and then on your device. Start the game and try answering some questions correctly and incorrectly. If you answer incorrectly, you’ll need to do it on both the device and the simulator to make the app move to the next question.
You should see screens like the following during the gameplay:
If you play long enough you’ll encounter a crash on the simulator. This is because there’s no code to handle the end-game condition when there are no more questions. That’s the final piece of the puzzle!
Add the following code to the beginning of startQuestion
:
if (self.questions.count == 0) { NSMutableString *winner = [[NSMutableString alloc] init]; for (NSString *peerID in self.peersToPoints) { NSInteger points = [self.peersToPoints[peerID] integerValue]; if (points == self.maxPoints) { if (winner.length) { [winner appendFormat:@", %@", self.peersToNames[peerID]]; } else { [winner appendString:self.peersToNames[peerID]]; } } } [self.mirroredScene setGameOver:winner]; return; } |
If you run out of questions, this method composes a string with the winner — or winners — and displays in on the secondary monitor.
Build and run your app one more time; run through the game and when you reach the end, you should see a screen like the following:
So, how’s your knowledge of CS trivia? ;]
Where To Go From Here?
Congratulations — you’ve just built a multiplayer client/server game using Game Kit that features a shared secondary display! Here’s the final project for you to download.
Now that you have the fundamentals of client-server game programming in Game Kit, you should be able to build more apps that take advantage of a second screen; games, in particular, can benefit greatly from this type ref gaming paradigm. You can use the screen as your game view, the device as a controller, and perhaps add some information as you would typically see in a HUD.
GameKit’s peer-to-peer communication opens the door to a lot of multiplayer games or apps; you can see how easy it is to implement multiplayer using Apple’s APIs. It’s also worth knowing that GKSession has been deprecated in iOS7 and has been replaced by the Multipeer Connectivity Framework. The two frameworks have a lot of similarities so your knowledge of GKSession will translate really well to the new MCSession class and its companion classes.
I hope you enjoyed the tutorial. You’re now ready to go off on your own and create wonderful things with AirPlay and GameKit’s peer-to-peer communication.
If you have any questions or comments, please share them in the discussion below!
Airplay Tutorial: An Apple TV Multiplayer Quiz Game is a post from: Ray Wenderlich
The post Airplay Tutorial: An Apple TV Multiplayer Quiz Game appeared first on Ray Wenderlich.