Airplay Tutorial: An Apple TV Multiplayer Quiz Game

Learn how to make a multiplayer iOS quiz game that displays one thing to an Apple TV, and uses your device as a controller! By Gustavo Ambrozio.

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

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:

AirTrivia.xcodeproj_—_ATAirPlayScene.m

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 in peerToPoints 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:

  1. 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.
  2. 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.
  3. Initialize a mutable array to hold the shuffled list of answers, and reset a few properties.
  4. 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.
  5. 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:

iOS_Simulator_-_iPhone_Retina__3.5-inch____iOS_7.0.3__11B508_-6

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:

iOS_Simulator_-_iPhone_Retina__3.5-inch____iOS_7.0.3__11B508_-7

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:

iOS_Simulator_-_iPhone_Retina__3.5-inch____iOS_7.0.3__11B508_-8

iOS_Simulator_-_iPhone_Retina__3.5-inch____iOS_7.0.3__11B508_-9

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:

iOS_Simulator_-_iPhone_Retina__3.5-inch____iOS_7.0.3__11B508_-10

So, how’s your knowledge of CS trivia? ;]

Gustavo Ambrozio

Contributors

Gustavo Ambrozio

Author

Over 300 content creators. Join our team.