Skip to main content
All CollectionsGame Design
Using brainCloud’s Replay System to Add Virtual Teammates or Opponent Ghosts to Any Multiplayer Game
Using brainCloud’s Replay System to Add Virtual Teammates or Opponent Ghosts to Any Multiplayer Game

Boost gameplay excitement by using the replays of other players in your live multiplayer matches.

John Harley avatar
Written by John Harley
Updated over a week ago

In the implementation described below, you'll get a step by step guide of how to use BrainCloud’s Playback Stream service to add Replay Ghosts to your game. Based on your game design, you can even physicalize those ghosts so they feel like real teammates that help players to beat the level! This is a great way to add a sensation of coop teamwork to matches, even when players are playing solo.

To build this into your app you will need to use the Playback Stream service, Leaderboard service, and Lobby service. Different kinds of apps may use different services depending on design choices and details of implementation.

Overview

BrainCloud’s Playback Stream service is normally used for one-way multiplayer match replays, as demonstrated in the brainCloud Clashers demo. However, this service is versatile and can be used for any scenario where recordings of player gameplay could enhance the game experience, such as ghosted opponents to race against, or virtual teammates that fight with you.

Ideal examples:

  • A racing game can use ghosts of the fastest player’s replays to challenge the live players

  • An MMO can use player recordings to make a hubworld feel more crowded with players going about their quests and activities

  • A turn-based RPG could turn recordings of old players into monsters for new players to defeat

Our Tech Demo: Invaders

In the Invaders demo, recordings of player gameplay are saved to brainCloud, and then played back in future games to provide assistance to the live players. This gives the player the benefit of help from co-op multiplayer teammates even when no other players are readily available.

In our demonstration, each player’s personal best is saved, with the best players’ replays offered back to the player by way of a leaderboard. When players start a round they will be able to bring replays of the top players into their live game, who will reenact the same gameplay that earned their high score.

There are 4 essential steps in this Playback Stream implementation:

  1. Record player gameplay

  2. Send the recording to brainCloud for later use

  3. Retrieve the recording when it is needed

  4. Act out the recording during the game to create a teammate

Supporting Features to Maximize the Fun of Replays

We implemented these extra features to surface the replay system to the player and boost fun. Your design team will want to consider how best the following scenarios should be solved for your game.

  1. Offer a list of the top 10 highest scoring replays - pick as many as you want to join your game!

    • Protip: Your Design team should consider which replays they feel would be the most compelling for players to choose to bring into their own game: for example: Friends, Clan-mates, Top Players, or Personal Replays

  2. Showcase a featured player’s record - hand picked by the developers to boost a player’s fame

    • Protip: Your Design team may want to offer time in the featured slot as a prize for your most valued players.

  3. Replay actors can be destroyed by enemy fire mid-replay - such as when they take fatal damage from enemies that had been vanquished earlier in their own run

    • Protip: Your Design team should decide whether gameplay is best served by having physicalized teammates or ghostly teammates that are invulnerable to actions in the current run.

Implementation

Record Player Gameplay

In the Invaders game, players are only allowed to move along the horizontal axis and shoot upwards. The less actions players have available to them, the easier this becomes to implement.

First we create a new object to hold the data we want to record each frame:

  • xDelta represents the direction the player moved each frame: left, right, or staying still.

  • createBullet represents whether or not the player shot out a bullet this frame.

  • frameID starts at 0 and increases by 1 every frame. This can be used for debugging.

We also create an object that holds some general information about the recording:

Next we create these objects inside our player controller when the game starts. We can also take this time to include some of the general information, such as the name of the player or the position the player starts in.

Now we need to keep track of which actions happen every frame. We do this in FixedUpdate() because this let us make sure the timing between frames stays consistent:

  • We get the player’s move direction by measuring where they are compared to where they were last frame.

  • We detect whether the player shot or not using shotRecently. This boolean is set to true when the player shoots and is reset back to false at the end of every frame.

  • Note: We also want to keep track of the player’s score during gameplay to post to the leaderboard. In this demo, a player’s score is increased whenever they destroy an alien.

When the gameplay is over, we grab the score and record and move on to the next step. The gameplay could end for multiple reasons such as the player dying or the enemies reaching the bottom of the screen. To catch all of the different cases, the function can be called from OnDestroy().

Send The Recording To brainCloud

This implementation makes frequent use of brainCloud’s Leaderboard service. The IDs of the saved recordings are put into the Extra Data of each player’s leaderboard entry. We only need to submit a recording if the score that it earned is the player’s highest so far.

To make the leaderboard for the app, navigate to App > Design > Leaderboards > Leaderboard Configs in the brainCloud portal. Add a new leaderboard config and remember your Leaderboard ID for later. In this demo it is “InvaderHighScore”.

We will first grab the player’s previous score from the leaderboard. If the recording’s score is less than the previous best, we break out of the function. This will save unnecessary API calls.

  • The game object this script is attached to persists throughout a session, so previousHighScore will only reset back to its default value of -1 when the game is closed and reopened.

  • Note: In this case we compare previousScore to newScore using > instead of >=. This will favour the most recent recording in the case of a tie. Consider carefully how you filter which recordings you save.

Now that we know we want to save this stream, we create it on brainCloud using StartStream() and attach it to our leaderboard entry by putting the stream ID in the third argument of PostScoreToLeaderboard().

We also need to add the gameplay that we recorded to the stream that was just created. To minimize the amount of data sent to brainCloud, we will compress multiple similar frames into one event.

  • The list runLengths will keep track of how many consecutive frames are similar to each other.

  • Players can only ever create a bullet for one frame at a time, so we will consider the frame a bullet was created to be similar to the immediately following frames. We must remember that only one bullet was created per event when we retrieve the stream from brainCloud later!

  • Note: This type of compression may not be as effective for every type of gameplay. An effective compression algorithm takes advantage of expected patterns and assumptions in its data.

After calculating which frames will be grouped into events, we format the data and send the events to brainCloud. They must be written in JSON format.

  • Each event will look like the following on the brainCloud portal:

Now that we have created our stream and sent all of our recorded gameplay to brainCloud, we end the stream and delete the stream that was previously attached to the leaderboard.

  • We reset createdRecordId and finishedAddingEvents back to their default values so that the SubmitRecord() coroutine can be used again this session.

  • previousHighScore and previousRecordId are set to the score and ID we just submitted so that we don’t have to fetch them again next time!

The essential steps are halfway complete! At this point whenever a player completes a game and achieves a personal high score, you should be able to see their leaderboard entry with the ID of their replay under the Extra Data column. Find the leaderboard entries on the portal under App > Global > Leaderboards > Leaderboards.

To find the recordings and their events go to the User tab and browse for your desired user. Find the streams under User > Multiplayer > One-Way MP. The Match ID should be the same as the Extra Data in the leaderboard entry.

Retrieve The Recording

When the app needs to retrieve a recording, we use the ReadStream operation from the Playback Stream service to get the data from brainCloud. We need to have the ID of the replay that will be read from. The Invaders demo gets the stream IDs by letting players select from leaderboard entries on a menu in the lobby before the game begins. More information about that menu can be found in the Top 10 Replay Selection Menu step. Keep in mind that the way your recordings are selected is a design choice that will be unique to your app or game’s purpose.

  • Note: An example of a different way to get replay IDs would be through the GetRecentStreamsForInitiatingPlayer operation.

The server will be in charge of instantiating the actors that play out the recordings, so we will read the stream data on the server. We send the replay IDs to the server using an RPC function.

  • If your game uses peer to peer multiplayer or is singleplayer, this can be done client-side instead.

We will be using a cloud code script to read the streams, as there could be almost a dozen streams that need to be read at the same time. Grouping multiple operations together with a cloud script is cheaper than calling each operation individually from the app. We will send the replay IDs to the cloud script as an array called ‘stream_ids’.

  • If your app will only read one stream at a time, it may be more convenient to get the streams in the app and skip making a cloud script.

  • This request is being made by the server, which means it must use S2S instead of the Client API. Requests using S2S must be formatted in JSON.

  • The service and operation are ‘script’ and ‘RUN’ respectively. The data of the request is the parameters of the run operation, which includes the data that will be used by our cloud script.

To make a cloud script navigate to Design > Cloud Code > Scripts and press the ‘Create Script’ button. For this demo, the script needs to be callable by S2S. The other settings can be left as their default values.

Cloud scripts access the parameters that were sent to them using properties of ‘data’. In our cloud script data.stream_ids is the array of IDs that will be turned into streams. The function main() returns response, which is the JSON that our app will receive. We add each stream to the response under the key “streams”.

  • This script uses sysReadStream() instead of readStream() because it is called from the server. Only operations that are supported by S2S can be called in cloud scripts that are run from a server.

Make sure to save the script when you are finished editing. If you already have some IDs from playtesting, you can test if the script works by pressing “DEBUG” and putting the IDs into the parameters section of the page. Press “QUICK AUTH” and then “EXECUTE”.

  • Make sure the parameters are formatted in the way the script expects them to be: inside an array with a key of ‘stream_ids’.

In the script’s callback function, we find the array of streams and loop through each one to start parsing out their data.

Here we will start parsing the data to transform it from JSON to something usable. We begin by checking if there is enough data to work with, and log a warning if we do not.

We fill out the general data from the summary first, then add in all of the frames from the events.

  • Within each stream is multiple events, and within each event is multiple frames. The quantity of frames in an event is determined by runLength. This is why the function includes one loop nested inside another.

  • A frame only creates a bullet when it is the first of its run length group, expressed here as ii == 0. This is one of the assumptions we made for higher compression in the previous step.

We hand the data that we parsed out to the next step, where it will be used to instantiate a ‘ghost’ actor.

Act Out The Recording

After instantiating the prefab that will act out the recording, we pass the recording it will act out as well as how much time has passed since the start of the game.

  • The cloud may take a few milliseconds to respond to our requests, which will delay the instantiation of the ghost. We will compensate for this by keeping track of how many frames have passed and skipping those frames while acting.

  • Fortunately, in the Invaders demo the first 150 frames will always be dedicated to waiting for the 3 second countdown to pass. This is plenty of time to ensure the delay will never affect gameplay.

  • Note: Your game or app should also take into consideration a small amount of delay from cloud responses. This might be done via a loading screen or making predictions on the client.

There are two variables we can immediately use: startPosition and username. We also start a coroutine which will handle acting out each of the frames.

  • Unity’s Netcode for GameObjects includes a component that automatically syncs the transform of the prefab. We apply the start position before the prefab is spawned on the network to avoid seeing the position interpolated.

  • We apply the username after the prefab is spawned because unlike the transform, we need to sync the text component through an RPC function.

For every frame we need to copy the direction the player moved as well as shoot a bullet if the player shot a bullet. We then wait for the next fixed update to act out the following frame.

  • The for loop starts at startFrame. This is what accounts for the delay from the cloud responses.

Once the recording has run out of frames, we call the retreat function. When the actor retreats, it moves downwards until it is sufficiently below the camera, then gets destroyed. Your game or app should have some fallback behaviour in case the data ends.

Top 10 Replay Selection Menu

The menu will display a list of ten options to choose from. Each entry will show players the score that was achieved by the replay, as well as the name of the player that performed the gameplay. Players will select a replay to add to their game by clicking a button attached to the entry.

Displaying The Leaderboard

The entries will be made of prefabs. On the root of the prefab we attach a script and drag three objects from the prefab’s children into serialized fields to be used for later.

Our new script will store the score of the entry, and the name and the ID of the player that created the entry. These values will be set when we retrieve them from brainCloud.

  • The button is deactivated by default, and is activated in this function. The labels for score and name will be empty before they are set. This means the entry will appear blank if something causes it to not be initialized, such as the leaderboard not having enough players to fill out all ten entries.

Each entry’s button will send its player’s ID to the LobbyControl script when the button is pressed. Make sure the following function is subscribed to the OnClick event of the button in the inspector.

Now that the prefab is complete, they can be placed in the lobby scene. In this example the playback selectors have been grouped under a scroll area. We also take this opportunity to make sure the lobby control script has references to each of the selectors to be used for later. We do this by adding a serialized list to the lobby control script called leaderBoardSelectors and dragging each of the selectors in through the inspector.

  • If your app uses a fewer amount of leaderboard placements, you may not need a scroll area if you can fit them all on one screen.

  • If your app uses too many leaderboard placements to manually set up one at a time, you can instantiate them at runtime with a script.

To initialize the values stored on the selectors, we request the top 10 leaderboard entries from the cloud. This function is called from Awake(), and we will receive the leaderboard data a few milliseconds later.

  • The argument amount will be 10, to match the amount of selectors we have in the scene.

  • “InvaderHighScore” is the ID of the leaderboard we made on the portal when we sent the recordings to brainCloud.

  • The operation GetGlobalLeaderboardPage will get leaderboard entries ranging from 0 to amount - 1, which will be the first to tenth place entries.

We parse out the data from JSON to get an array that contains information about the 10 entries. The values we care about are the rank of the entry, the name and ID of the player that made the entry, and the score of the entry.

  • Once all of the selectors have been updated, we know that all of the UI should be fully initialized. Later there will be some functions that cannot run without the UI being initialized, so we take note that it has been finished with the boolean foundTopUserInfo.

We set the values of each entry, then we update the labels so that they display the most recent information. If not enough leaderboard entries were retrieved to fill all 10 selectors, the remaining selectors will be left blank.

  • The rank is used to determine which selector is being updated.

Storing The User IDs

Remember that the buttons on each selector are connected to the LobbyControl singleton. The lobby control script combines the user ID with the list of all previous user IDs and sends that list to the BrainCloudManager singleton.

We will store the list of all of the selected user IDs in the lobby’s settings. This will allow players that join the lobby after some selections have already been made to read the list of selections. If the player that made the selection is not the owner of the lobby they do not have permission to change the lobby settings, so we send a signal instead.

  • We format the array of IDs into a dictionary because the UpdateSettings operation will use that dictionary as the JSON that overwrites the old settings.

The UpdateSettings and SendSignal functions don’t need callback functions. Instead, they are both received as types of events in the RTT lobby callback. Using brainCloud’s lobby service requires using RTT, which stands for Real-Time Tech. The RTT lobby callback may receive any type of lobby event, but we will only focus on three for this feature: “SIGNAL”, “SETTINGS_UPDATE”, and “MEMBER_JOIN”.

In the case of a signal, we know a player that was not the lobby owner wanted to change the lobby settings. Every member of a lobby receives every lobby event, but only the lobby owner has permission to update the lobby settings. We break early if the current client is not the lobby owner. Then the lobby owner updates the settings with the data that was passed with the signal.

In the case of a settings update, we take the most recently added element in the array of replay user IDs and add it to the client’s list. IDs are only ever added one at a time, so this will keep every client’s list synced with each other.

In the case of a member joining, that member needs to receive all of the IDs that have already been selected before they joined. This is why we stored a copy of the array of IDs in the lobby’s settings.

  • We need to check to see if the lobby settings include the key “replay_users” because if no replays have been selected, the lobby settings will never have been updated and will still be empty.

We delay adding the IDs to the client’s list because some of that client’s UI may not have been initialized yet.

  • The boolean foundTopUserInfo was set after receiving information about the leaderboard entries from brainCloud.

When we try to add a new user ID to the client’s list, we first check to see if it is a duplicate. If not, it successfully gets added to the list. We also display to the user a count of how many playbacks they are about to bring with them into the game. Finally, we hide the button associated with the ID that was just added to prevent players from pressing a redundant button.

Turning User IDs to Replay IDs

When the players are ready to start their game and no more entries are going to be selected, we send all of the stored user IDs to the PlaybackFetcher script.

  • The following process only needs to be done by one client, and we choose that one client to be the lobby owner out of convenience.

  • The PlaybackFetcher singleton persists throughout the game session and will not be destroyed when the scene changes from the lobby to the game.

To turn the user IDs into replay IDs, we will find each user’s entry on the leaderboard and get the attached replay ID.

  • We save this step for as late as possible because replays are deleted whenever a player achieves a new highscore. Holding onto the user IDs is safer because users should not normally get deleted. This means the time between getting the replay IDs and using them should be only a few milliseconds.

When brainCloud responds with the data, we parse it from JSON into a list of strings. We then convert that list into an array that can be sent through an RPC function once the client connects to the server.

  • The array storedIds was used earlier when retrieving the recordings from brainCloud.

This “Top 10 Replay Selection Menu” is only one way to choose which replays to use during gameplay, and is very specific to this type of use case. It is likely that if your game or app uses replays for a different purpose that this method may not suit your needs.

Featured Replay Selection Menu

One user will be chosen by the developers to have their replay accessible to bring into the game. This serves as an alternative to the Top 10 Replay Selection Menu, however many of the systems used to make that menu will be reused here. While these two menus could be implemented independently, this tutorial will assume the previous menu has already been implemented.

To allow designers to pick one user to be featured, we will make a global property that contains the user ID of a player. Using a global property has the advantage of allowing the developers to change the featured player without updating the app. Navigate through the brainCloud portal to Apps > Design > Cloud Data > Global Properties and click the add button.

The name of the property in this demo is “FeaturedPlayer”. We will need to use this exact name later to read the property in the app. We will not need to use the category of global properties in this example, but if your app has many properties you may benefit from filling out the category field. In the ‘DATA’ tab, enter the ID of a user that has already recorded a replay.

  • In the Invaders demo, only one user is featured at a time. For this reason, the global property is String type instead of JSON type. If your app relies on a featured user system more heavily than Invaders, consider using the JSON type.

Place one of the selector prefabs we made for the top 10 menu into the scene, along with a “Featured” label. Make sure the lobby control script has a reference to the new selector.

In the Awake() function we start a coroutine that will find which user is currently being featured and then get their user ID, username, and score from the leaderboard. The ReadSelectedProperties operation expects an array of properties as its first argument. This is where we place the name of the global property we made earlier.

  • The GetPlayerSocialLeaderboard also expects an array, so we format the featured user’s ID into a single-element array.

After parsing out the ID, username, and score from the player’s leaderboard entry we pass that information to the LobbyControl singleton to initialize the featured player selector. Then we use the foundFeaturedUserInfo boolean to mark that the UI has been initialized.

  • This operation can return multiple entries, but in our case it will only ever return one. Instead of looping over every entry, we just get the entry at index 0.

  • The BrainCloudManager singleton is persistent throughout a session. We set the variable featuredUser back to its default value, an empty string, so that the coroutine can work multiple times per session.

The new selector should now display the username and score of the featured user. To make it usable as a menu item, most of the system is already complete from when we made the leaderboard selectors. The featured selector’s button already either updates the lobby settings or sends a signal to the lobby owner to update the lobby settings.

When a new player joins the lobby, they should only add all of the selected user IDs to their list after both types of selectors have been initialized.

When a new ID is added to a client’s list, the button of a selector with that ID is disabled to make sure players cannot press a button that does not do anything. We have to update this function to include the new selector.

  • We need to check both the leaderboard selectors and the featured selectors because it is possible that the featured player could also be on the leaderboard. In that case, we want to disable both buttons when one of them is used.

Replay Actors Ending Early

The Invaders demo’s current implementation has replay actors end only when the original recording ended. To make the actors feel more like real players, we can have them take damage and get destroyed by the enemy bullets.

Looking into the EnemyBullet script, we can see the bullet checks whether the collider it hits has either a player control script or shield script before dealing damage. We will add a check to see if the collider has a player replay control script.

If the bullet hits a replay actor, the Despawn() function will remove the bullet on the server and clients. We then call a function on the player replay to handle being hit.

Each time the replay player gets hit by a bullet, they will lose one life. When they run out of lives we stop all coroutines, which ends the function that does the acting, ActPlayBack(). We then despawn them from the network.

When the player replay is hit and dies, we instantiate some large particles. If they do not die, we instantiate smaller particles. These particles get spawned on the client’s side using an RPC function.

  • We cannot pass prefabs through arguments of RPC functions, but we can pass an integer that correlates to the type of particles we want.

Did this answer your question?