Creating Turn-Based Experiences with the Participants API

The participant capability lets you get information about each participant in a group effect.

In a script this is implemented using the ParticipantsModule API, which lets you retrieve the group effect participants and query their status.

Each individual participant in the video call is assigned a unique ID that persists for the entire duration of the video call, even if they drop out.

You can then use this ID to monitor when that specific participant leaves or rejoins the call, or if they join or leave the effect.

This allows you to design experiences that maintain a consistent global state, even if the active participants list changes while the effect is running.

In this article, you'll build upon the example project from Creating a Group Effect with the Multipeer API, by converting it into a turn-based experience in which call participants can only change the global video feed background when it's their turn.

Four participants are seen in Spark AR Player.

Additionally, the project will implement logic that allows call participants to drop in and out of the call, while maintaining their place in the turn order.

The unfinished project

Download the unfinished project to follow along.

In the example project, you’ll find a number of assets already set up, including a canvas group, materials and imported textures.

In the Patch Editor you’ll find a patch graph which sends tap and tap and hold events to a script and a second graph which handles the changing of the user’s background and the visibility of the turn indicator.

The script.js asset already contains the code from the completed Creating a Group Effect with the Multipeer API project.

Creating a list of participants in the call

Open the script.js file in a text editor.

On line 5, import the ParticipantsModule into the script with the following line of code so that you can make use of the Participants API.

const Participants = require('Participants');
        

You can then use the getAllOtherParticipants() method to retrieve an array with all of the other participants in the call.

Paste the following code on line 18.

// Get the other call participants
const participants = await Participants.getAllOtherParticipants();
        

Since the array only returns other participants and not the 'self' participant, we need to retrieve this separately and add it to the main list.

Paste the code below immediately after the previous lines.

// Get the current participant, 'self'
const self = await Participants.self;
 
// Push 'self' to the array, since the previous method only fetched
// other participants
participants.push(self);
        

Creating a list of participants active in the effect

The previous list keeps track of all of the participants in the video call, but we also need to create a second list to keep track of the participants that are currently active in the effect.

To do this we can use the getOtherParticipantsInSameEffect() method.

Paste the code below on line 28.

// Get other participants active in the effect
const activeParticipants = await Participants.getOtherParticipantsInSameEffect();
        

As with the main participant list, we have to manually add the 'self' instance to the array. Add the following immediately after the previous code:

// Push 'self' to the array, since the previous method only fetched
// other participants
activeParticipants.push(self);
        

Sorting the participants list to set a turn order

When creating a turn-based group effect, it’s important that each instance of the effect agrees on whose turn it is next to avoid conflicts when passing the turn over.

One way to achieve this is to sort the list of active participants by their ID number, as each instance is guaranteed to end up with an identical version of the turn order.

To do this we can use JavaScript’s built in sort() function and, since we’ll need to sort the list on startup and each time a participant joins or leaves, we’ll create a custom function that we can call when needed

Paste the following code, starting from line 72.

 // Sorts the active participant list by participant ID
 // This ensures all participants maintain an identical turn order
 function sortActiveParticipantList(isActive) {
 
   activeParticipants.sort(function(a, b){
     if (a.id < b.id) {
       return -1;
 
     } else if (a.id > b.id){
       return 1;
     }
   });
 }
        

Passing the turn over to the next participant

With a synchronized turn order established, we need to allow participants to pass the turn over to the next person.

In our effect, we’ll let participants pass over their turn whenever they tap and hold their device screen.

Before we do that, we need to add a few additional pieces of logic.

Firstly, we’ll create a variable that we can use to track whose turn it is. On line 12 add the following code:

// Initialize turn tracking variable
var turnIndex = 0;
        

We’ll also create a separate message channel that we can send the updated turnIndex value to whenever it changes, to ensure the turn order stays synchronized across the various instances of the effect.

On line 21 paste the code below:

const turnIndexChannel = Multipeer.getMessageChannel('TurnIndexTopic');
        

The example project contains a patch graph which captures Screen Tap and Hold gestures and forwards them to our script, under the name screenTapHoldPulse.

In our script we can then retrieve the gesture's event. Paste the following code on line 18.

// Get the tap and hold event from the Patch Editor
const screenTapHoldPulse = await Patches.outputs.getPulse('screenTapHoldPulse');
        

Next, we’ll subscribe to the returned event and add logic to pass the turn within its callback function.

The logic will check whether the ID of the participant that tapped on the screen matches the ID of the current participant in the turn order and if so, increments the turnIndex variable to pass the turn over.

Paste the code below starting on line 69.

// Subscribe to the tap and hold event
screenTapHoldPulse.subscribe(function() {
 
  // If it's currently my turn
  if (activeParticipants[turnIndex].id === self.id) {
 
    // Increment the turn index to pass the turn over to the next participant
    turnIndex = (turnIndex + 1) % activeParticipants.length;         
  }
});
        

Additionally, we’ll broadcast a message containing the updated turnIndex value to the message channel we created earlier so that other participants are notified of the change in the turn order.

Within the if statement we just added, paste the following code:

// Then, broadcast the new turn index value to other participants
TurnIndexChannel.sendMessage({'turnIndex': turnIndex}).catch(err => {
 
  // If there was an error sending the message, log it to the console
  Diagnostics.log(err);
});
        

Finally, we’ll set up a subscription to monitor when messages are sent to the TurnIndexChannel.

Paste the code below on line 97.

// Whenever we receive a message on turnIndexChannel, update the turn index
turnIndexChannel.onMessage.subscribe(function(msg) {
  turnIndex = msg.turnIndex;
 
});
        

Displaying a turn indicator for the participant taking their turn

While we now have a synchronized turn order and the ability to pass the turn over, there’s currently no way for participants to know whose turn it is.

To fix this, we’ll implement a visual aid that will be displayed on the screen of whichever participant is currently taking their turn.

The example project already contains a basic graphic for this purpose, with its visibility set in the patch graph with a value sent from our script.

Within our script we’ll update this value based on whether the ID of the current participant matches the current ID in the turn order. This way, the graphic will be hidden in each instance of the effect except for the participant whose turn it currently is.

To update the value we’ll create a custom function called setTurnIndicatorVisibility.

Paste the code below on line 117.

// Sets the visibility of the turn indicator graphic
function setTurnIndicatorVisibility() {
  // Check whether this participant's ID matches the ID of the current
  // participant in the turn order and store the result
  let isMyTurn = activeParticipants[turnIndex].id === self.id;
 
  // Send the previous value to the Patch Editor. If the IDs match,
  // the patch graph will display the turn indicator, otherwise the
  // graphic will be hidden
  Patches.inputs.setBoolean('showTurnPanel', isMyTurn);
}
        

Each time the turn order is updated, we need to check which participant should have the graphic displayed.

We’ll do this by calling our new setTurnIndicatorVisibility function from within the Screen Tap and Hold event subscription’s callback function. On line 78 paste the following code:

// Check whether this participant needs to show the turn indicator graphic
setTurnIndicatorVisibility();
        

We’ll also need to call the function whenever an updated turn index value is sent to the turnIndexChannel message channel.

Paste the code below on line 104.

// Check whether this participant needs to show the turn indicator graphic
setTurnIndicatorVisibility();
        

Limiting control to the participant with the current turn

Now that the turn order has been established, we want to make sure that only the participant with the current turn is able to change the background.

We’ll add a check to the screenTapPulse subscription’s callback function to make sure that the participant that triggered the event had the current turn. This way, other participants won’t be able to change the background unless it’s their turn.

To do this, we'll wrap the existing code within the callback with a new if statement.

Replace lines 46 to 66 with the code below:

// Check if the participant that tapped had the current turn
if (activeParticipants[turnIndex].id === self.id) {
    
  // Increment the background index to show the next background image
  backgroundIndex++;
    
  // If the index value is equal to the total number of background images,
  // reset the value so that the displayed image loops.
  if (backgroundIndex >= totalBackgroundCount) {
    backgroundIndex = 0;
  }
    
  // Send the new background index value to the Patch Editor, so that it can be
  // used to update the background image displayed. This only updates the
  // background for the participant that tapped on the screen
  Patches.inputs.setScalar('msg_background', backgroundIndex);
    
  // Broadcast the new background index value to other participants on the
  // previously created backgroundIndexChannel message channel
  backgroundIndexChannel.sendMessage({'background': backgroundIndex }, true).catch(err => {
     
    // If there was an error sending the message, log it to the console
     Diagnostics.log(err);
  });
}
        

Initializing each participant’s effect state

When the effect is first launched, we need to initialize each participant’s effect state to ensure everything is synchronized.

In practice, this means performing an initial sort of the participants list and determining which participant to display the turn graphic for initially.

We can do this by calling the custom functions we created previously. On line 42 paste the following lines of code:

// Do an initial sort of the active participants when the effect starts
sortActiveParticipantList();
 
// Do an initial check of whether this participant should display the
// turn indicator
setTurnIndicatorVisibility();
        

Handling participants joining or leaving the call

Currently, our effect works well if the list of participants stays the same for the duration of the call.

In a group effect however, the list of participants is often dynamic, so we need to make sure that we correctly handle participants joining or leaving without breaking the functionality we’ve built so far.

The custom function we’ll create to handle this will take two arguments, userIndex and isActive. userIndex will be used to pass in a specific participant when we call the function, while isActive will be used to check whether the specified participant is currently active in the effect.

Paste the code below on line 145.

// Sorts the active participant list and restarts the turn sequence
// when there's a change in the participant list.
// If a user joined, isActive will be true. Otherwise it will be false
function onUserEnterOrLeave(userIndex, isActive) {
 
  // Get the participant that triggered the change in the participant list
  let participant = participants[userIndex];
 
  // Store a reference to the participant before any changes to the list are made
  let currentTurnParticipant = activeParticipants[turnIndex];
 
  // Check if the participant exists in the activeParticipants list
  let activeParticipantCheck = activeParticipants.find(activeParticipant => {
    return activeParticipant.id === participant.id
  });
 
}
        

If a participant just joined the effect we’ll add them to the active participant list and then broadcast messages to the channels we set up previously so that the new joiner’s turn order is synchronized with the current participants’ turn order.

If a participant just left the effect we’ll remove them from the list, without broadcasting any messages.

Paste the following code on line 161, within the onUserEnterLeave function:

if (isActive) {
 
  // If the participant is found in the active participants list
  if (activeParticipantCheck === undefined) {
 
    // Add the participant to the active participants list
    activeParticipants.push(participant);
      
    Diagnostics.log("User joined the effect");
  }
} else {
 
  // If the participant is not found in the active participants list
  if (activeParticipantCheck !== undefined) {
 
    // Update the active participants list with the new participant
    let activeIndex = activeParticipants.indexOf(activeParticipantCheck);
      
    activeParticipants.splice(activeIndex, 1);
 
    Diagnostics.log("User left the effect");
  }
}
        

Next, we’ll need to update the turn index value which is used to track the turn order.

If the participant whose turn it was is still in the effect, we’ll update the value of turnIndex to that of the participant’s new index.

Otherwise, if that participant is no longer in the effect then we can keep the same index value, as it will now be referencing the next participant in the turn order.

However, if the participant who left was at the final position of the turn order we need to wrap the index value back to the starting value.

Paste the following code on line 185.

// Sort the active participant list again 
sortActiveParticipantList();   
 
// Check if the participant whose turn it was is still in the effect
if (activeParticipants.includes(currentTurnParticipant)) {
 
  // If they are, change the turnIndex value to that participant's new index value
  turnIndex = activeParticipants.indexOf(currentTurnParticipant);
 
} else {
 
  // If they're not in the effect and they were the last participant in the turn order,
  // wrap the turnIndex value back to the first participant in the turn order
  turnIndex = turnIndex % activeParticipants.length;
}
        

With those pieces of logic implemented, we’ll just check which participant should be displaying the turn graphic by calling the following function on line 201. Make sure it’s positioned after the else statement’s closing curly bracket ( the ‘}’ ) but before the onUserEnterOrLeave function’s closing curly bracket.

// Check which participant should display the turn graphic
setTurnIndicatorVisibility();
        

With our new custom function completed, we now need to actually call it from somewhere within our script.

First, we’ll monitor each participant’s isActiveInSameEffect value and use our new function as the callback, so that it’s called whenever the value of the property changes. Paste the code below on line 42.

// Get each participant in the participant list
participants.forEach(function(participant) {
 
  // Monitor each participant's isActiveInSameEffect status
  // The use of subscribeWithSnapshot here allows us to capture the participant who
  // triggered the event (ie enters or leaves the call) inside of the callback
  participant.isActiveInSameEffect.monitor().subscribeWithSnapshot({
    userIndex: participants.indexOf(participant),
  }, function(event, snapshot) {
 
    // Pass the participant and their active status to the custom function
    onUserEnterOrLeave(snapshot.userIndex, event.newValue);
  });
});
        

Lastly, we’ll monitor whenever a new participant is added to the call and use the new function as the callback here too.

We’ll also add the new participant to the main participant list here.

On line 57, paste the following code:

// Monitor when a new participant joins
Participants.onOtherParticipantAdded().subscribe(function(participant) {
 
  // Add them to the main participant list
  participants.push(participant);
 
  // Monitor their isActiveInSameEffect status
  participant.isActiveInSameEffect.monitor({fireOnInitialValue: true}).subscribeWithSnapshot({
    userIndex: participants.indexOf(participant),
  }, function(event, snapshot) {
 
    // Pass the participant and their isActiveInSameEffect status to the custom function
    onUserEnterOrLeave(snapshot.userIndex, event.newValue);
  });
});
        

Initializing new joiner’s effect state

When a new participant joins the effect late, we want to make sure that their effect state is synchronized with the rest of the participants’ effect state.

We’ll notify other participants about a new joiner by broadcasting a message to a new message channel. Paste the code below on line 25.

const userLoadedChannel = Multipeer.getMessageChannel('UserLoadedTopic');
        

When a participant joins, we want their instance to broadcast a message to this channel. On line 81 add the code below, so that it runs when a participant first launches the effect.

// Once this user has loaded, let other participants know
userLoadedChannel.sendMessage({}).catch(err => {
 
 // If there was an error sending the message, log it to the console 
  Diagnostics.log(err);
});
        

Lastly, we’ll listen out for messages sent to the channel and broadcast messages to the other two channels we set up previously from here.

Additionally, to avoid flooding the channels with messages, we’ll make sure that only the participant with the current turn sends the message that synchronizes the new joiner.

Paste the following code on line 158.

// When a new user joins and has loaded, send them the effect state
userLoadedChannel.onMessage.subscribe((msg) => {
 
  // Only the participant with the current turn will send this message,
  // to avoid flooding the channels
  if(activeParticipants[turnIndex].id === self.id) {
 
    // Send the background index value on the appropriate message channel
    backgroundIndexChannel.sendMessage({'background': backgroundIndex }).catch(err => {
 
      // If there was an error sending the message, log it to the console
      Diagnostics.log(err);
    });
    
    // Send the turn index value on the appropriate message channel
    turnIndexChannel.sendMessage({'turnIndex': turnIndex}).catch(err => {
 
      // If there was an error sending the message, log it to the console
      Diagnostics.log(err);
    });
  }
});
        

With that, our turn-based experience is complete. You can find the complete script below.


The finished script

// Load in the required modules
const Patches = require('Patches');
const Diagnostics = require('Diagnostics');
const Multipeer = require('Multipeer');
const Participants = require('Participants');

(async function () { // Enable async/await in JS [part 1]

  // Initialize background tracking variables
  const totalBackgroundCount = 3;
  var backgroundIndex = 0;

  // Initialize turn tracking variable
  var turnIndex = 0;

  // Get the tap event from the Patch Editor
  const screenTapPulse = await Patches.outputs.getPulse('screenTapPulse');

  // Get the tap and hold event from the Patch Editor
  const screenTapHoldPulse = await Patches.outputs.getPulse('screenTapHoldPulse');

  // Create a message channel
  const backgroundIndexChannel = Multipeer.getMessageChannel('BackgroundIndexTopic');
  const turnIndexChannel = Multipeer.getMessageChannel('TurnIndexTopic');
  const userLoadedChannel = Multipeer.getMessageChannel('UserLoadedTopic');

  // Get the other call participants
  const participants = await Participants.getAllOtherParticipants();

  // Get the current participant, 'self'
  const self = await Participants.self;

  // Push 'self' to the array, since the previous method only fetched
  // other participants
  participants.push(self);

  // Get other participants active in the effect
  const activeParticipants = await Participants.getOtherParticipantsInSameEffect();

  // Push 'self' to the array, since the previous method only fetched
  // other participants
  activeParticipants.push(self);

  // Get each participant in the participant list
  participants.forEach(function(participant) {

    // Monitor each participant's isActiveInSameEffect status
    // The use of subscribeWithSnapshot here allows us to capture the participant who
    // triggered the event (ie enters or leaves the call) inside of the callback
    participant.isActiveInSameEffect.monitor().subscribeWithSnapshot({
      userIndex: participants.indexOf(participant),
    }, function(event, snapshot) {

      // Pass the participant and their active status to the custom function
      onUserEnterOrLeave(snapshot.userIndex, event.newValue);
    });
  });

  // Monitor when a new participant joins
  Participants.onOtherParticipantAdded().subscribe(function(participant) {

    // Add them to the main participant list
    participants.push(participant);

    // Monitor their isActiveInSameEffect status
    participant.isActiveInSameEffect.monitor({fireOnInitialValue: true}).subscribeWithSnapshot({
      userIndex: participants.indexOf(participant),
    }, function(event, snapshot) {

      // Pass the participant and their isActiveInSameEffect status to the custom function
      onUserEnterOrLeave(snapshot.userIndex, event.newValue);
    });
  });

  // Do an initial sort of the active participants when the effect starts
  sortActiveParticipantList();

  // Do an initial check of whether this participant should display the
  // turn indicator
  setTurnIndicatorVisibility();

  // Once this user has loaded, let other participants know
  userLoadedChannel.sendMessage({}).catch(err => {

    // If there was an error sending the message, log it to the console
    Diagnostics.log(err);
  });

  // Subscribe to the screen tap event
  screenTapPulse.subscribe(() => {

    // Check if the participant that tapped had the current turn
    if (activeParticipants[turnIndex].id === self.id) {

      // Increment the background index to show the next background image
      backgroundIndex++;

      // If the index value is equal to the total number of background images,
      // reset the value so that the displayed image loops.
      if (backgroundIndex >= totalBackgroundCount) {
        backgroundIndex = 0;
      }

      // Send the new background index value to the Patch Editor, so that it can be
      // used to update the background image displayed. This only updates the
      // background for the participant that tapped on the screen
      Patches.inputs.setScalar('msg_background', backgroundIndex);

      // Broadcast the new background index value to other participants on the
      // previously created backgroundIndexChannel message channel
      backgroundIndexChannel.sendMessage({'background': backgroundIndex }, true).catch(err => {

        // If there was an error sending the message, log it to the console
        Diagnostics.log(err);
      });
   }

  });

  // Subscribe to the tap and hold event
  screenTapHoldPulse.subscribe(function() {

    // If it's currently my turn
    if (activeParticipants[turnIndex].id === self.id) {

      // Increment the turn index to pass the turn over to the next participant
      turnIndex = (turnIndex + 1) % activeParticipants.length;

      // Check whether this participant needs to show the turn indicator graphic
      setTurnIndicatorVisibility();

      // Then, broadcast the new turn index value to other participants
      TurnIndexChannel.sendMessage({'turnIndex': turnIndex}).catch(err => {

        // If there was an error sending the message, log it to the console
        Diagnostics.log(err);
      });
    }
  });

  // Listen out for messages sent to the backgroundIndexChannel
  backgroundIndexChannel.onMessage.subscribe((msg) => {

    // Retrieve the 'background' attribute from the JSON object received
    backgroundIndex = msg.background;

    // Send the value to the Patch Editor
    Patches.inputs.setScalar('msg_background', msg.background);
  });

  // Whenever we receive a message on turnIndexChannel, update the turn index
  turnIndexChannel.onMessage.subscribe(function(msg) {
    turnIndex = msg.turnIndex;

    // Check whether this participant needs to show the turn indicator graphic
    setTurnIndicatorVisibility();
  });

  // When a new user joins and has loaded, send them the effect state
  userLoadedChannel.onMessage.subscribe((msg) => {

    // Only the participant with the current turn will send this message,
    // to avoid flooding the channels
    if(activeParticipants[turnIndex].id === self.id) {

      // Send the background index value on the appropriate message channel
      backgroundIndexChannel.sendMessage({'background': backgroundIndex }).catch(err => {

        // If there was an error sending the message, log it to the console
        Diagnostics.log(err);
      });

      // Send the turn index value on the appropriate message channel
      turnIndexChannel.sendMessage({'turnIndex': turnIndex}).catch(err => {

        // If there was an error sending the message, log it to the console
        Diagnostics.log(err);
      });
    }
  });

  // Sorts the active participant list by participant ID
  // This ensures all participants maintain an identical turn order
  function sortActiveParticipantList(isActive) {

    activeParticipants.sort(function(a, b){
      if (a.id < b.id) {
        return -1;

      } else if (a.id > b.id){
        return 1;
      }
    });
  }

  // Sets the visibility of the turn indicator graphic
  function setTurnIndicatorVisibility() {
    // Check whether this participant's ID matches the ID of the current
    // participant in the turn order and store the result
    let isMyTurn = activeParticipants[turnIndex].id === self.id;

    // Send the previous value to the Patch Editor. If the IDs match,
    // the patch graph will display the turn indicator, otherwise the
    // graphic will be hidden
    Patches.inputs.setBoolean('showTurnPanel', isMyTurn);
  }

  // Sorts the active participant list and restarts the turn sequence
  // when there's a change in the participant list.
  // If a user joined, isActive will be true. Otherwise it will be false
  function onUserEnterOrLeave(userIndex, isActive) {

    // Get the participant that triggered the change in the participant list
    let participant = participants[userIndex];

    // Store a reference to the participant before any changes to the list are made
    let currentTurnParticipant = activeParticipants[turnIndex];

    // Check if the participant exists in the activeParticipants list
    let activeParticipantCheck = activeParticipants.find(activeParticipant => {
      return activeParticipant.id === participant.id
    });

    if (isActive) {

      // If the participant is found in the active participants list
      if (activeParticipantCheck === undefined) {

        // Add the participant to the active participants list
        activeParticipants.push(participant);

        Diagnostics.log("User joined the effect");
      }
    } else {

      // If the participant is not found in the active participants list
      if (activeParticipantCheck !== undefined) {

        // Update the active participants list with the new participant
        let activeIndex = activeParticipants.indexOf(activeParticipantCheck);

        activeParticipants.splice(activeIndex, 1);

        Diagnostics.log("User left the effect");
      }
    }

    // Sort the active participant list again
    sortActiveParticipantList();

    // Check if the participant whose turn it was is still in the effect
    if (activeParticipants.includes(currentTurnParticipant)) {

      // If they are, change the turnIndex value to that participant's new index value
      turnIndex = activeParticipants.indexOf(currentTurnParticipant);

    } else {

      // If they're not in the effect and they were the last participant in the turn order,
      // wrap the turnIndex value back to the first participant in the turn order
      turnIndex = turnIndex % activeParticipants.length;
    }

    // Check which participant should display the turn graphic
    setTurnIndicatorVisibility();
  }

})(); // Enable async/await in JS [part 2]