Sync Framework - Voting
The voting template shows you how to use Sync Framework to build an AR experience that allows multiple users to contribute their input for selection in a shared session. This template is a good place to start with if you want to build a lens for voting, quiz games, or anything else that requires selection input from multiple users, with each user being represented by a character in the session.
With this template, you will create a shared experience that allows users to remotely join a voting session, configure the voting question and options, vote and move their characters in real time, and use a timer to track the status of the voting session.
Testing your voting lenses
The voting template uses userId to track each individual user’s data, so that the vote can be persistent and asynchronous. The userId is tied to a user’s Snapchat account, which means that multiple devices using the same Snapchat account can share the same userId. Therefore, if you are testing your lenses in Lens Studio and push to your device, you will be joining the session from both ends using the same userId, and will only have one character representation in the scene tied to that userId. If you wish to test your lenses with multiple users, you will need to use separate Snapchat accounts on your Lens Studio and your device.
Setting up the voting board
Visual Elements
In this template, there is a setup that looks like a tabletop game board. In the Scene Hierarchy
panel, if you expand the Voting Board object under the SceneRoot, you will see the Visual object being placed under the WorldObjectController as a child of it. The Visual object contains the visual elements used in the voting board, including:
-
A few tappable Buttons, each containing a Raycast Button helper script component. A user can tap on a button to vote for the option it represents. The height of each button reflects the voting score for the corresponding option in real time.
-
A few tappable Areas with colored dashed borders (each containing a Raycast Button helper script component). Those are the areas for users’ characters to stand in and to represent their selections during the voting period.
-
A tappable Standby Area with white dashed border (containing a Raycast Button helper script component). That is where users’ characters are instantiated when they first join the session.
-
A Question Board that shows where the voting question is displayed.
-
A few Text objects to display information such as voting scores, options, time left, voting result, etc.
-
A few Character Prefabs which are used to instantiate users’ characters.
Notice here there is a matching number of Buttons and Areas. Each Button has a child text object describing the option. Each Area has a child text object representing the voting score on the option. You need to make sure that you have an equal number of those objects in order to make the voting session work as expected. You can replace these visual elements with assets of your own choice. Once you have your visual elements ready in the Scene Hierarchy
panel, you can continue to the next steps to set up these elements in your scripts’ input fields.
Voting Controller
The Voting Controller is the script that syncs all users’ voting data throughout the session. If you click on the Voting Controller in the Scene Hierarchy
panel, then navigate to the Inspector panel, you can see a few groups of input fields. You can replace the content in those input fields, however, make sure you add your Buttons, Scores and Options in matching orders, and that you have an equal number of them.
Buttons: This group of inputs allows you to set up which buttons in the scene should respond to user taps. By adding the RaycastButton script component of those buttons into the input field, you are updating voting counts based on tap interactions on those buttons.
Areas: This group of inputs allows you to set up the area in front of each of your selection buttons. When a user taps on a button or an area to make a selection, their character will move to the corresponding area to represent their selection in real time.
Standby Area: This allows you to set up the standby area where the characters are initially instantiated when users first join a session. This is also an interactable area which users can tap on to cancel their vote during the session. When they cancel their vote, their character will move back to the standby area.
Scores: This group of inputs allows you to add the text objects that represent voting scores for each voting option. In this template, the scores text for each option are set up under each Area object as their children, and the scores in the scene are displayed within each area with colored dashed borders. During the voting session, those numbers represent the number of users selecting each option in real time.
Text Edit Controller: This plugs in the script for enabling editing the voting question and option texts, and syncing the edited texts across the session.
Synced Timer: This plugs in the script of the timer that works for Sync Framework.
Timer Start Button: This plugs in the button for starting the synced timer.
Timer Reset Button: This plugs in the button for resetting the synced timer when voting ends.
Edit Button: This plugs in the button that pulls up the Input Modal on a tap, which allows users to edit the voting question and options in the session.
Input Modal: This plugs in the Input Modal (an asset from the Asset Library) we use for collecting users’ text input when editing the question and options.
Result Text: This input field contains the text object that displays the voting result when voting time is up. The result text display varies depending on the voting scores when the timer ends.
The content of the result text is generated by the Voting Controller script when voting ends. When the voting time is up, it checks the highest scores and the number of the highest scores, and generates different result texts based on the results.
The VotingController script uses the StoragePropLookup class to track dynamic voting score changes in a store. In a voting session, users might frequently tap on voting buttons to change their selections, and there might often be multiple users simultaneously interacting with the same buttons. Therefore, having each user frequently getting and setting those voting counts in the store every time a change happens is not a reliable approach to sync data. Due to the slight delay in the network that happens when data is updated in the store, there is a chance that one user attempts to get and update a data value before another user’s update on it has been completely recorded. In such cases, users can step on top of others’ updates, making the voting counts incorrect.
Therefore, the StoragePropLookup is used to track each user’s selected option. Every time a change happens to the optionProperties, meaning every time any user taps to change their selection, it checks all users’ selection, and updates the voting counts.
This is achieved by creating the optionProperties to store user selections, and subscribing an updateOptions() function to any change that happens to the optionProperties.
var optionProperties = new global.StoragePropLookup(
syncEntity,
'user-option-',
global.StorageTypes.int
);
optionProperties.onAnyChange.add(updateOptions);
In the updateOptions() function, it loops through each userId in optionProperties to check each user’s selection, and update the count of how many times each option is voted for.
function updateOptions() {
// Reset all option scores to 0
for (var i = 0; i < buttons.length; i++) {
buttonScores[i] = 0;
}
// Loop through each userId in optionProperties
for (var userId in optionProperties.propertyDic) {
// Update the count of how many times each option was voted for
var userVote = optionProperties.getProperty(userId).currentValue;
if (userVote >= 0) {
buttonScores[userVote] += 1;
}
}
// Update the height of the button according to the count
for (i = 0; i < buttons.length; i++) {
updateButtonHeight(i, buttonScores[i]);
updateText(i, buttonScores[i]);
}
}
To track our local vote, we add our localOptionProp to the store with the key set to our userId. The initial value of it is set to -1 by default, meaning no option selected. This property will appear in the StoragePropLookup for all other users as well, so they can see the vote that we made using our userId.
function initTrackPropertiesFromUserId() {
// Set up current user
var localId = global.sessionController.getLocalUserId();
//Add our selected option to the store
localOptionProp = optionProperties.addProperty(localId, -1);
}
Each time the local user taps to vote, the updateLocalOptions() function is called to update the local user’s option property. Notice here we set up both the Buttons and Areas to respond to user taps.
function statusChange(i) {
buttons[i].onTouchStart.add(function () {
if (getIsVotingAllowed()) {
updateLocalOptions(i);
}
});
areas[i].onTouchStart.add(function () {
if (getIsVotingAllowed()) {
updateLocalOptions(i);
}
});
standbyArea.onTouchStart.add(function () {
if (getIsVotingAllowed()) {
updateLocalOptions(-1);
}
});
}
The updateLocalOptions() function updates localOptionProp, the local user’s option property.
function updateLocalOptions(i) {
localOptionProp.setValueImmediate(syncEntity.currentStore, i);
}
Whenever a user’s option property changes, we go through all users’ option properties in the store, and calculate the total counts for each option in real time. This approach guarantees that each individual user’s data change won’t interfere with others’, regardless of the timing they happen with.
The VotingController does a few other things. For example, when voting ends, the getMax() function is called to get the option that got the highest voting score. There are three possible results:
- There is a winner, meaning there is a single option that has the highest voting score.
- Tie, meaning there are multiple options that have the highest voting scores.
- No one voted, meaning all voting scores are 0.
Based on the voting results, we display different texts in the scene.
function getMax(buttonScores) {
var highest = Math.max.apply(Math, buttonScores);
var winnerCount = 0;
for (var i = 0; i < buttonNum; i++) {
if (buttonScores[i] == highest) {
winnerCount++;
}
}
if (winnerCount == 1) {
maxOptionIndex = buttonScores.indexOf(highest);
} else {
maxOptionIndex = -1;
}
script.maxOptionIndex = maxOptionIndex;
if (highest > 0) {
if (winnerCount == 1) {
for (i = 0; i < buttonNum; i++) {
if (buttonScores[i] == highest) {
textContent.text = 'Winner: '.concat(optionTexts[i]).concat('!');
}
}
} else if (winnerCount > 1) {
textContent.text = 'Tie!';
}
} else {
textContent.text = 'No one voted!';
}
}
The API getMaxOptionIndex is available for other scripts, such as the MaterialController and ConfettiController, so various effects can be displayed based on the voting results. You can create your own scripts for more effects, and use this API to get information about the voting results.
Material Controller
The Material Controller controls the change of materials on multiple objects at the end of the voting round. You have the option to turn this script on and off depending on whether you want this effect. With this effect on, when the timer goes off, if there is a single color that gets most voted for, the changeMaterialsByResult() function in this script will be called and colors of those selected objects will change to be that color with the highest vote.
When tapping on the Reset button to reset the voting, the resetMaterials() function in the Material Controller will be called and all materials will be reset.
The Material Controller allows you to set up what objects in the scene you want to change materials based on the voting result, and what materials you want as the candidates to be voted for. In this example set up, the materials on the question board, the result text, the timer text and a few buttons will change if there is a single color option that gets the highest voting score during the voting time, meaning there is a single winning group, not tied.
Confetti Controller
The Confetti Controller script controls the display of a confetti when the voting ends. You have the option to turn this script on and off depending on whether you want this effect.
With this effect on, there will be a confetti to celebrate the winner if there is one. When there is a single highest voting score at the end of a voting round, and that highest score is greater than 0, the displayConfetti() function in this script will be called to show the effect. At this point, if any user taps on the Reset button to reset the voting, the hideConfetti() function will be called and the confetti will be hidden.
The Confetti Controller script can take a few candidate target positions for the confetti to be instantiated. In this template, the position of the four dashed areas are used as the candidate positions to spawn the confetti. Those areas are accessed through the Voting Controller script. If there is a single option that gets the highest voting score, the confetti will be spawned on the corresponding area. If there is a tie, or no one voted during the voting time, the confetti will not be displayed.
var areas = votingController.getAreas();
var areaObjects = [];
var areaMeshes = [];
for (var i = 0; i < areas.length; i++) {
areaObjects[i] = areas[i].getSceneObject();
areaMeshes[i] = areaObjects[i].getComponent("Component.RenderMeshVisual");
}
<img src="/img/lens-studio/voting-template-13.png" width="450px"/><img src="/img/lens-studio/voting-template-14.png" width="450px"/>
Text Edit Controller
The template enables users to configure their own voting question and options by using the Input Modal with a Text Edit Controller script. While the Input Modal collects users’ text inputs, the Text Edit Controller script syncs the data across the session in real time, so that everyone will immediately see the new text taking effect.
See the following section on Input Modal for how this is used in this template.
The input fields of the Text Edit Controller script plug in all the text objects you may want to allow users to edit, paired with the input texts in the Input Modal that are used to collect user text inputs.
Input texts should plug in the text objects you use as input texts in your Input Modal.
And visual output texts should plug in the text objects you use for displaying your options.
Any user in the session can tap on the Edit button to make edits on the voting question and options. Therefore, there might be times when multiple users attempt to edit the texts at the same time. In this kind of situation, the template handles users’ text edits using network events. At a time, it only allows a single user to claim ownership of those text properties, but still allow other users to modify those properties by sending network events. This way, if multiple users send events to update the texts at around the same time, those events will be queued and changes will be applied in order. In this example, the cleanest approach to do it is to use an EntityEventWrapper, an easy Sync Framework interface for both sending and receiving a specific event.
var editQuestionEvent = syncEntity.getEntityEventWrapper('edit_question');
The function onConfirm() is used to send the event. This function should be called every time any user confirms an edit.
function onConfirm() {
if (syncEntity.isSetupFinished) {
var data = [];
for (var i = 0; i < allTextConfigs.length; i++) {
data[i] = allTextConfigs[i].input.text;
}
editQuestionEvent.send(data);
}
}
You can conveniently call the function using the behaviors on the UI button in the Input Modal. You just need to expose the API in the script where the function was created. Then, you can set it as a callback function triggered on a press up event.
Then an onEditQuestionReceived() function in the TextEditController script is used to receive the event and update property values using the data received.
function onEditQuestionReceived(message) {
if (syncEntity.doIOwnStore()) {
var data = message.data;
for (var i = 0; i < data.length; i++) {
textProps[i].setPendingValue(data[i]);
}
}
}
And finally, you will subscribe to an event reception to call the onEditQuestionReceived() function.
editQuestionEvent.onEventReceived.add(onEditQuestionReceived);
Setting up the Input Modal
Input Modal
In this template, we use the Input Modal asset to enable configuration on the voting question and options. You can get the Input Modal asset from the Asset Library.
In order to make the UI more suitable for your potential needs, this template has a slightly modified version of the Input Modal here to enable configuration on the voting question and options. This modified Input Modal has multiple input fields in one pop-up window. All text edits made through this Input Modal are synced with all users in the session, and persisted across sessions.
The Input Modal UI is hidden by default. Tapping on the Edit button will make the window pop up. Tapping on the Confirm button in the window would close the window and apply changes in all fields. Changes are visible on the voting board for all users immediately.
You can change how your UI looks by manipulating the content in the Input Modal under the UI Camera.
You can also change the behavior of the Input Modal by editing the inputModalController.js script.
Setting up the synced timer
SyncedTimer Helper Script
The SyncedTimer helper script allows you to set up a shared timer for all users in the session. Once anyone in the session starts the timer, it starts for everyone. When the timer ends, no more interaction is allowed and the voting result is announced. The status of the timer is persisted across sessions.
You can change the text displayed on the timer button at different stages, as well as the length of the timer.
The Synced Timer script talks directly to the Voting Controller script to determine the status of the voting depending on which stage of the timer is currently on. It is useful as it makes it easy to sync data between all users based on the status of the timer, and makes it possible to reset the voting session back to the initial status when we need to.
In this template, there is a Start Timer button that allows users to start the timer on a tap, and a Reset button that allows users to reset the timer back to its initial status. When the Reset button is tapped, the timer is reset, all users’ voting counts are set back to 0 and all characters move back to the standby area. The template will also rely on the timer status to tell us whether all users should still be allowed to vote. If the time is up, all tap interactions on option buttons and areas will be blocked until we reset the voting session.
The Synced Timer is useful when you are making shared experiences that need to keep track of time. There is a list of APIs you can use to get the status of the timer, or to make it start and stop. You can take advantage of them to safely change the status of things based on the status of the timer.
script.notifyOnReady = notifyOnReady;
script.getIsSetupFinished = function () {
return syncEntity.isSetupFinished;
};
script.stopTimer = stopTimer;
script.startTimer = startTimer;
script.getIsRunning = getIsRunning;
script.getHasStarted = getHasStarted;
script.getHasFinished = getHasFinished;
script.notifyOnFinish = notifyOnFinish;
script.onTimerJustFinished = onTimerJustFinished;
Setting up the player-owned characters
Character Prefabs
When a user joins the session, there is a random character instantiated on the voting board to represent the user. When the user taps on a button to vote, their character moves to the corresponding area to reflect the user’s selection.
This template provides you with a few character prefabs to randomly pull from. You can replace them with your own character assets.
Each example character prefab contains an animation mixer set up in the same way. There are four animation clips in the animation mixer, arranged in the same order: Idle - Run - Happy - Sad.
If you set up your own characters, make sure you also set up their animation mixers in that manner, as the PersistentOwnedChar script component on the character prefab will access these animation clips by index, making those characters behave differently according to which status they are on.
The PersistentOwnedCharacter script component on each character prefab also provides a few input fields. Those input fields plug in the VotingController scripts, the Areas for characters to move around, and the Animation Mixer that contains the character animations.
The PersistentOwnedCharacter script is the script that controls the behavior of characters in the scene. It accesses each user’s selected option from the VotingController script, and uses a storage property targetPos to store the target position the character should move to.
myOptionProperty = votingController.getOptionPropertyForUser(ownerId.currentOrPendingValue);
var targetPos = syncEntity.addStorageProperty(global.StorageProperty.manualVec3("targetPos", standbyPos));
Anytime the user’s selected option changes, we call the **updateTarget()** function to update the character’s target position it should move to.
myOptionProperty.onAnyChange.add(updateTarget);
In the updateTarget() function, the template will update the value of the storage property targetPos according to the currently selected option. It also updates the variable targetRot, which represents the character’s target rotation to rotate towards, based on its target position and current position. By subtracting the character’s current position from its target position, and using quat.lookAt(), we rotate the character so its forward vector points at its target position. This way, each time the character’s target position is updated, the character will turn and move towards the new target position.
function updateTarget() {
// Update target position based on selected option
var optionIndex = getMyOptionIndex();
if (optionIndex == -1) {
targetPos.setPendingValue(randomPos(standbyAreaPos));
}
if (optionIndex >= 0) {
var pos = areas[optionIndex].getTransform().getLocalPosition();
targetPos.setPendingValue(randomPos(pos));
}
var relativeVec = targetPos.currentOrPendingValue.sub(charPos);
relativeVec.y = 0;
targetRot = quat.lookAt(relativeVec, axis);
}
On ready, the template creates an update event to constantly call the updateMovement() function to move the character towards its target position.
script.createEvent('UpdateEvent').bind(updateMovement);
The updateMovement() function smoothly moves and rotates the character towards its target, and switches between different character animations depending on its states.
function updateMovement() {
charPos = vec3.lerp(
charPos,
targetPos.currentOrPendingValue,
getDeltaTime() * 1
);
charRot = quat.slerp(charRot, targetRot, getDeltaTime() * 3);
transform.setLocalPosition(charPos);
transform.setLocalRotation(charRot);
var dist = targetPos.currentOrPendingValue.distance(charPos);
var distThreshold = 5;
if (dist < distThreshold) {
targetPos.setPendingValue(charPos);
// Make all characters turn to face the camera
transform.setWorldRotation(quat.quatIdentity());
}
if (votingController.getIsVotingAllowed()) {
if (dist < distThreshold) {
// Play idle animation
playAnimationLayer(0);
} else {
// Play running animation
playAnimationLayer(1);
}
} else {
// Make all characters turn to face the camera
transform.setWorldRotation(quat.quatIdentity());
}
}
When a user joins a session they have previously joined before, the function getTarget() will be called to retrieve the previous position of this user’s character. An error value has been added so that the character position is not exactly the same as the target position in order to make quat.lookAt() work as expected.
function getTarget() {
charPos = randomError(targetPos.currentOrPendingValue);
}
When the voting time is up, the function charRespond() will be called to switch character animations as it responds to the voting result. Depending on whether there is a winning group, and whether the user is in the winning group, the character will behave happy or sad.
Each character prefab contains a text object displaying the user’s name. The template uses a DisplayStorageProperty, a Sync Framework helper script to share the user’s name across the network.
There is a TextDepthHelper script set up on the text object. This makes sure the Text components write to depth so they appear correctly in world space, and are two sided so they can be viewed from behind.
There is also a TextRotationHelper on the text object. That is to ensure the username texts always face the camera regardless of the orientation of the characters they are parented to.
You have the option to turn these scripts on and off depending on whether you want the effects.
Character Spawner
The Character Controller spawns a single, persistent character for each user when they join the session. In the input fields, you can add all the prefabs you wish to use for instantiating users’ characters. When a user joins the session, a new character will be spawned using a prefab randomly selected from the array.
Instantiator
The instantiator instantiates prefabs across the network. In its input fields, make sure you have your character prefabs added just as how you added them for the PlayerOwnedCharSpawner script. For the input field "Spawn Under Parent", add the “Visuals” object to it, so that all characters will always be spawned as children of the “Visuals” object.