Chat & MicroStorage (GPW2) - Guide

[SMPL-GPW2-02]

This documents how to understand and apply the benefits of Chat - Overview in game development. Or watch this video.

šŸ“˜

Variations On A Theme

There are two repos for the "Global Price Wars" game each with distinct learning goals.

  1. Chat GPW Sample Project - Simpler project, the GPWBasicDataFactory uses random, local data values
  2. Chat GPW2 Sample Project With MicroStorage - Complex project, the GPWMicroStorageDataFactory uses Beamable Microservices and MicroStorage with data values stored in shared database. You are currently viewing the documentation for this project

Download

These learning resources provide a better way to build live games in Unity.

SourceDetail
1. Download the Chat GPW2 Sample Project With MicroStorage
2. Open in Unity Editor ( Version 2020.3.23f1 )
3. Open the Beamable Toolbox
4. Sign-In / Register To Beamable. See Step 1 - Getting Started for more info
5. Rebuild the Unity Addressables : Unity ā†’ Window ā†’ Asset Management ā†’ Groups, then Build ā†’ Update a Previous Build
6. Open Unity ā†’ Window ā†’ Beamable ā†’ Open Configuration Manager.
---- Set Beamable ā†’ Microservice ā†’ Enable Storage Preview = true
7. Open Unity ā†’ Window ā†’ Beamable ā†’ Open Microservices Manager
---- Choose "Build And Rerun" for the GPWDataService
8. Open Unity ā†’ Window ā†’ Beamable ā†’ Open Microservices Manager
---- Choose "Run" for the GPWDataStorage
9. Open the Scene01Intro Scene
10. Play The Scene: Unity ā†’ Edit ā†’ Play
11 Click the "Start" Button
12. Enjoy!

Note: Supports Mac & Windows and includes the Beamable SDK

Rules of the Game

  • 1 player starts the game with limited turns
  • After the final turn, the game is over
  • The player's final score is calculated as Cash + Bank - Debt
  • Each turn, the player makes decision to increase the total cash
    • Use money in Cash. Buy items at a low price, Sell items at a high price
    • Move money into the Bank. The bank pays interest to the player after each turn
    • Move money out of Debt. The debtors charge interest to the player after each turn
    • Chat with other players to better understand the market price of items

Pro Tip: Sell all owned items before the final turn to increase the final score.

Screenshots

Scenes

Scene01IntroScene02GameScene03ChatScene04SettingsScene05Leaderboard

Design Overview

Many Beamable features are used to implement the game design and game rules.

Features

šŸ“˜

Related Features

More details are covered in related feature page(s).

ā€¢ Chat - Allow players to communicate in-game
ā€¢ Connectivity - Indicates status of network connection availability
ā€¢ Content - Allow game maker to store project-specific data
ā€¢ Inventory - Allow player to manage inventory
ā€¢ Leaderboards - Allow player to manage leaderboard
ā€¢ Microservices - Create and deploy your own code which we host
ā€¢ MicroStorage - Native Mongo Database integrated with C#Microservices

Content

Using the Beamable Content feature, this game stores core data with ease and flexibility.

Remote ConfigurationLocationProduct

Class Organization

Each scene uses the GPWController class to interact with the local data model and the remote services.

Here is a high-level chart showing the partial structure.

Design of Data

The data within the RuntimeDataStorage is vital for the core game loop.

The game design requires that all users in the same game (e.g. same chat session) to see a shared world of consistent pricing. This is accomplished using the data structure and data factory below.

Data Structure
The structure contains all the locations of the game. Within each location is information about each product; including its price and quantity.

  • List < LocationContentView >
    • LocationData (e.g. "Africa")
    • List < ProductContentViews >
      • ProductData (e.g. "Chocolate")
      • MarketGoods
        • Price (e.g. "10")
        • Quantity (e.g. "3")

Data Factory
The IDataFactory gets data as shown in the diagram below.

  • A. The RuntimeDataStorage calls to GetLocationContentViews() at the start of each game session
  • B. Here in the GPW game the GPWBasicDataFactory creates pseudorandom price/quantity for all products. A random-seed value is shared across all game clients for consistent pricing. This solution is easier to understand, but likely not robust enough for a production game.

Development

Here is an overview of project organization.

  • Project Window - Suggested entry points for game makers include the Scenes folder and Scripts folder
  • Project Configuration - The Configuration class holds settings used throughout the game. Game makers can adjust settings at runtime or edit-time
  • Project Scene (Chat) - The Scene03Chat.unity scene holds all chat-related functionality
Project WindowProject ConfigurationChat Scene

Code

The core chat functionality is centralized in the sample game project's GameServices class.

Here are some highlights of the Beamable SDK's ChatService class.

Subscribe to Chat Changes

IBeamableAPI beamableAPI = await Beamable.API.Instance;
ChatService chatService = beamableAPI.Experimental.ChatService;
chatService.Subscribe(ChatService_OnChanged);

Handle Chat Changes

private void ChatService_OnChanged(ChatView chatView)
{
    _chatView = chatView;
    foreach (RoomHandle roomHandle in _chatView.roomHandles)
    {
        if (IsLocalPlayerInRoom(roomHandle.Name))
        {
            roomHandle.OnMessageReceived -= RoomHandle_MessageReceived;
            roomHandle.OnMessageReceived += RoomHandle_MessageReceived;
        }
    }
}

Send Chat Message

IBeamableAPI beamableAPI = await Beamable.API.Instance;
ChatService chatService = beamableAPI.Experimental.ChatService;
_chatService.SendMessage("MyRoomName", "HelloWorld!");

Chat Manager

The Scene03ChatManager.cs is the main entry point to the chat scene logic. It relies on the GameServices class for the chat-related operations.

using System;
using System.Text;
using System.Threading.Tasks;
using Beamable.Common.Api;
using Beamable.Experimental.Api.Chat;
using Beamable.Samples.Core.Data;
using Beamable.Samples.Core.UI;
using Beamable.Samples.GPW.Data.Storage;
using Beamable.Samples.GPW.Views;
using UnityEngine;

namespace Beamable.Samples.GPW
{
   /// <summary>
   /// Handles the main scene logic: Chat 
   /// </summary>
   public class Scene03ChatManager : MonoBehaviour
   {
      //  Properties -----------------------------------
      public Scene03ChatUIView Scene03ChatUIView { get { return _scene03ChatUIView; } }

      //  Fields ---------------------------------------
      [SerializeField]
      private Scene03ChatUIView _scene03ChatUIView = null;
      
      //  Unity Methods   ------------------------------
      protected async void Start()
      {
         
         // Clear UI
         _scene03ChatUIView.ScrollingText.SetText("");
         _scene03ChatUIView.ScrollingText.HyperlinkHandler.OnLinkClicked.AddListener(HyperlinkHandler_OnLinkClicked);
         
         // Top Navigation
         _scene03ChatUIView.GlobalChatToggle.onValueChanged.AddListener(GlobalChatButton_OnClicked);
         _scene03ChatUIView.LocationChatToggle.onValueChanged.AddListener(LocationChatButton_OnClicked);
         _scene03ChatUIView.DirectChatToggle.onValueChanged.AddListener(DirectChatButton_OnClicked);
         SetChatMode(ChatMode.Global);
         
         // Input
         _scene03ChatUIView.ChatInputUI.OnValueSubmitted.AddListener(ChatInputUI_OnValueSubmitted);
         _scene03ChatUIView.ChatInputUI.OnValueCleared.AddListener(ChatInputUI_OnValueCleared);

         // Bottom Navigation
         _scene03ChatUIView.BackButton.onClick.AddListener(BackButton_OnClicked);
         
         // Load
         _scene03ChatUIView.DialogSystem.DelayBeforeHideDialogBox =
            (int)_scene03ChatUIView.Configuration.DelayAfterDataLoading * 1000;
         
         await ShowDialogBoxLoadingSafe();
         SetupBeamable();
      }


      //  Other Methods  -----------------------------
      private async void SetupBeamable()
      {
         // Setup Storage
         GPWController.Instance.PersistentDataStorage.OnChanged.AddListener(PersistentDataStorage_OnChanged);
         GPWController.Instance.RuntimeDataStorage.OnChanged.AddListener(RuntimeDataStorage_OnChanged);
         GPWController.Instance.GameServices.OnChatViewChanged.AddListener(GameServices_OnChatViewChanged);
         
         // Every scene initializes as needed (Max 1 time per session)
         if (!GPWController.Instance.IsInitialized)
         {
            await GPWController.Instance.Initialize(_scene03ChatUIView.Configuration);
         }
         else
         {
            GPWController.Instance.PersistentDataStorage.ForceRefresh();
            GPWController.Instance.RuntimeDataStorage.ForceRefresh();
         }
      }

      
      private async Task<EmptyResponse> ShowDialogBoxLoadingSafe()
      {
         // Get roomname, and fallback to blank
         string roomName = "";
         if (!_scene03ChatUIView.DialogSystem.HasCurrentDialogUI && GPWController.Instance.HasCurrentRoomHandle)
         {
            RoomHandle roomHandle = GPWController.Instance.GetCurrentRoomHandle();
            roomName = roomHandle.Name;
         }

         if (_scene03ChatUIView.DialogSystem.HasCurrentDialogUI)
         {
            await _scene03ChatUIView.DialogSystem.HideDialogBoxImmediate();
         }
         _scene03ChatUIView.DialogSystem.ShowDialogBoxLoading(roomName);
         return new EmptyResponse();
      }
      
      
      private async void SetChatMode(ChatMode chatMode)
      {
         // Change mode
         GPWController.Instance.RuntimeDataStorage.RuntimeData.ChatMode = chatMode;

         // THis mode can be reached by clicking chat text too
         // so update the button to look selected
         if (chatMode == ChatMode.Direct)
         {
            _scene03ChatUIView.DirectChatToggle.isOn = true;
         }
         
         // Show mode specific prompt
         await ShowDialogBoxLoadingSafe();
         
         // Update
         GPWHelper.PlayAudioClipSecondaryClick();
         GPWController.Instance.RuntimeDataStorage.ForceRefresh();
      }
      
      
      private async void RenderChatOutput()
      {
         if (!GPWController.Instance.GameServices.HasChatView)
         {
            return;
         }
         
         if (!GPWController.Instance.HasCurrentRoomHandle)
         {
            return;
         }
         
         RoomHandle roomHandle = GPWController.Instance.GetCurrentRoomHandle();

         ChatMode chatMode = GPWController.Instance.RuntimeDataStorage.RuntimeData.ChatMode;
         StringBuilder stringBuilder = new StringBuilder();
         stringBuilder.AppendLine("---- RenderChatOutput Room ----");
         stringBuilder.AppendLine($"Title: {roomHandle.Name}");
         stringBuilder.AppendLine($"Topic: {GPWHelper.GetChatRoomTopic (chatMode)}");
         stringBuilder.AppendLine($"Players: {roomHandle.Players.Count}");
         stringBuilder.AppendLine($"Messages: {roomHandle.Messages.Count}");
         stringBuilder.AppendLine().AppendLine();
         
         foreach (Message message in roomHandle.Messages)
         {
            long playerDbid = message.gamerTag;
            string alias = "";
            try
            {
               alias = await GPWController.Instance.GameServices.GetOrCreateAlias(playerDbid);
            }
            catch (Exception e)
            {
               Debug.Log("E: " + e.Message);
            }
            
            //Temporarily override alias to reduce confusion. Its by the local player but 
            //from a previous account. 
            if (!GPWController.Instance.GameServices.IsLocalPlayerDbid(playerDbid) &&
                alias == GPWHelper.DefaultLocalAlias)
            {
               alias = MockDataCreator.CreateNewRandomAlias(GPWHelper.DefaultRemoteAliasPrefix);
            }
                         
            if (GPWController.Instance.RuntimeDataStorage.RuntimeData.ChatMode == ChatMode.Direct ||
                GPWController.Instance.GameServices.IsLocalPlayerDbid(playerDbid))
            {
               stringBuilder.AppendLine($"[{alias}]: " + message.content);
            }
            else
            {
               // When NOT in direct chat, and NOT the local player, renders clickable text
               // Clicks are handled above by "HyperlinkHandler_OnLinkClicked"
               stringBuilder.AppendLine($"[{TMP_HyperlinkHandler.WrapTextWithLink(alias, playerDbid.ToString())}]: " + message.content);
            }
         }
         
         _scene03ChatUIView.ScrollingText.SetText(stringBuilder.ToString());
         
         await _scene03ChatUIView.DialogSystem.HideDialogBox();
         _scene03ChatUIView.ChatInputUI.Select();
      }
      
      
      //  Event Handlers -------------------------------
      private async void HyperlinkHandler_OnLinkClicked(string href)
      {
         if (GPWController.Instance.RuntimeDataStorage.RuntimeData.ChatMode == ChatMode.Direct)
         {
            throw new Exception("HyperlinkHandler_OnLinkClicked() ChatMode cannot be ChatMode.Direct. ");
         }

         SetChatMode(ChatMode.Direct);
         
         RoomHandle roomHandle = GPWController.Instance.GetCurrentRoomHandle();
         long dbid1 = GPWController.Instance.GameServices.LocalPlayerDbid;
         long dbid2 = long.Parse(href);
         await GPWController.Instance.GameServices.JoinDirectRoomWithOnly2Players(dbid1, dbid2);
      }
      
      
      private async void ChatInputUI_OnValueSubmitted(string message)
      {
         RoomHandle roomHandle = GPWController.Instance.GetCurrentRoomHandle();
         await GPWController.Instance.GameServices.SendMessage(roomHandle.Name, message);
         _scene03ChatUIView.ChatInputUI.Select();
      }


      private void ChatInputUI_OnValueCleared()
      {
         GPWHelper.PlayAudioClipSecondaryClick();
      }
      
         
      private void GlobalChatButton_OnClicked(bool isOn)
      {
         if (isOn)
         {
            SetChatMode(ChatMode.Global);
         }
      }
      
      
      private void LocationChatButton_OnClicked(bool isOn)
      {
         if (isOn)
         {
            SetChatMode(ChatMode.Location);
         }
      }
      
      
      private void DirectChatButton_OnClicked(bool isOn)
      {
         if (isOn)
         {
            SetChatMode(ChatMode.Direct);
         }
      }
      
      
      private void BackButton_OnClicked()
      {
         StartCoroutine(GPWHelper.LoadScene_Coroutine(
            _scene03ChatUIView.Configuration.Scene02GameName,
            _scene03ChatUIView.Configuration.DelayBeforeLoadScene));
      }
      
      
      private void PersistentDataStorage_OnChanged(SubStorage subStorage)
      {
         PersistentDataStorage persistentDataStorage = subStorage as PersistentDataStorage;
         _scene03ChatUIView.PersistentData = persistentDataStorage.PersistentData;
         _scene03ChatUIView.LocationContentView = GPWController.Instance.LocationContentViewCurrent;
         RenderChatOutput();
      }
      
      
      private void RuntimeDataStorage_OnChanged(SubStorage subStorage)
      {
         RuntimeDataStorage runtimeDataStorage = subStorage as RuntimeDataStorage;
         _scene03ChatUIView.RuntimeData = runtimeDataStorage.RuntimeData;
         RenderChatOutput();
      }
      
      
      private void GameServices_OnChatViewChanged(ChatView chatView)
      {
         RenderChatOutput();
      }
   }
}

Additional Experiments

Here are some optional experiments game makers can complete in the sample project.

Did you complete all the experiments with success? We'd love to hear about it. Contact us.

DifficultySceneNameDetail
BeginnerGameTweak Configurationā€¢ Update the Configuration.asset values in the Unity Inspector Window

Note: Experiment and have fun!
BeginnerVariousAdd New Graphicsā€¢ Change the colors, fonts, and sprites for cosmetics
IntermediateGameAdd a New Product itemā€¢ Open the Content Manager
ā€¢ Duplicate an existing ProductContent
ā€¢ Update all field values
ā€¢ Update the existing RemoteConfiguration
ā€¢ Publish the content
IntermediateGameAdd a New Location itemā€¢ Open the Content Manager
ā€¢ Duplicate an existing LocationContent
ā€¢ Update all field values
ā€¢ Update the existing RemoteConfiguration
ā€¢ Publish the content
AdvancedChatAdd user-to-user item transactionsā€¢ Design and implement new functionality
AdvancedVariousStore player progress between sessionsā€¢ Add Beamable Cloud Save to store the existing PersistentData object
AdvancedVariousAdd shared database so the local price and local quantity update across all playersā€¢ Add Beamable Microservices With a DLL of any 3rd party database

Advanced

This section contains any advanced configuration options and workflows.

Microservices

The Beamable Microservices feature's purpose: Create and deploy your own code which we host.

This feature is a gateway to doing server-side logic within your Beamable project. The specific usage in "Global Price Wars" is to connect the client-side code with the MicroStorage.

GPWDataService
This class wraps access to the MicroStorage including;

  • HasLocationContentViews() - Determines if the database is properly populated
  • GetLocationContentViews() - Fetches data from database
  • CreateLocationContentViews() - Populates the database
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Beamable.Samples.GPW;
using Beamable.Samples.GPW.Data;
using Beamable.Samples.GPW.Data.Factories;
using MongoDB.Driver;
using UnityEngine;

namespace Beamable.Server
{
    [Microservice("GPWDataService")]
    public class GPWDataService : Microservice
    {
        private const string CollectionName = "location_content_views_wrapper";
            
        [ClientCallable]
        public bool IsMicroServiceReady()
        {
            return true;
        }
        
        [ClientCallable]
        public bool IsMicroStorageReady()
        {
            return Storage != null;
        }
        
        /// <summary>
        /// Determines if data yet exists in the database
        /// </summary>
        [ClientCallable]
        public async Task<bool> HasLocationContentViews()
        {
            var locationContentViews = await GetLocationContentViews_Internal(true);
            return locationContentViews != null &&
                   locationContentViews.LocationContentViews != null
                   && locationContentViews.LocationContentViews.Count > 0;
        }

        /// <summary>
        /// Get the data from the database
        /// </summary>
        [ClientCallable]
        public async Task<LocationContentViewCollection> GetLocationContentViews()
        {
            return await GetLocationContentViews_Internal(false);
        }

        private async Task<LocationContentViewCollection> GetLocationContentViews_Internal(bool isSuppressErrors)
        {
            List<LocationContentView> locationContentViews = null;
        
            try
            {
                var mongoDatabase = await Storage.GetDatabase<GPWDataStorage>();
                var mongoCollection = mongoDatabase.GetCollection<LocationContentViewsWrapper>(CollectionName);
                var result = await mongoCollection.FindAsync(_ => true);
                var locationContentViewsWrappers = result.ToList();

                // This means there is no db data. Sometimes that is ok
                if (locationContentViewsWrappers.Count == 0)
                {
                    return null;
                }
                // This means there is exactly 1 db data. Sometimes ideal
                else if (locationContentViewsWrappers.Count == 1)
                {
                    var locationContentViewsWrapper = locationContentViewsWrappers[0];
                
                    if (locationContentViewsWrapper.LocationContentViewCollection != null)
                    {
                        locationContentViews = locationContentViewsWrappers[0].LocationContentViewCollection
                            .LocationContentViews;
                    }
                    else
                    {
                        Debug.LogError($"GetLocationContentViews_Internal() failed.");
                    }
                }
                // This means there is exactly > 1 db data. That is never expected.
                else
                {
                    Debug.LogError($"GetLocationContentViews_Internal() failed. " +
                                   $"Count = {locationContentViewsWrappers.Count}");
                }
      
            }
            catch (Exception e)
            {
                // Do nothing. This means the LocationContentViewsWrapper does not yet exist in the db.
                // That is sometimes expected (e.g. first run) and is ok.
                if (!isSuppressErrors)
                {
                    Debug.Log($"GetLocationContentViews () failed. Error={e.Message}");
                }
            }
        
            LocationContentViewCollection locationContentViewCollection = new LocationContentViewCollection();
            locationContentViewCollection.LocationContentViews = locationContentViews;
            return locationContentViewCollection;
        }

        /// <summary>
        /// Create the data in the database.
        /// 
        /// In production it is recommended...
        /// * 1. Use the attribute [AdminOnlyCallable] is recommended to protect
        /// vital methods like this. Such a method requires a user with Admin privileges as the caller.
        /// * 2. Don't allow GetLocationContentViews() if there is already data in the database. 
        /// 
        /// However, in development here...
        /// * 1. For ease-of-use in the sample project [ClientCallable] is used to allow
        /// any users with any privileges to call.
        /// * 2. We allow creation of data regardless if data is present in the database
        /// 
        /// </summary>
        [ClientCallable]
        public async Task<bool> CreateLocationContentViews(
            List<LocationData> locationDatas, List<ProductData> productDatas)
        {
            //NOTE: For ease-of-use for this sample project, this GPWBasicDataFactory
            //is reused here to create the data which will be inserted into the database
            IDataFactory dataFactory = new GPWBasicDataFactory();
            List<LocationContentView> locationContentViews =
                await dataFactory.GetLocationContentViews(locationDatas, productDatas);
        
            
            bool isSuccess = false;
            try
            {
                var db = Storage.GetDatabase<GPWDataStorage>();
                
                //The wrapper helps the DB
                var collection = db.GetCollection<LocationContentViewsWrapper>(CollectionName);

                // Delete any/all previous data
                await collection.DeleteManyAsync(_ => true);

                //And this custom collection helps some method-return-value serialization
                LocationContentViewCollection locationContentViewCollection = new LocationContentViewCollection();
                locationContentViewCollection.LocationContentViews = locationContentViews;

                // Insert the one new value
                collection.InsertOne(new LocationContentViewsWrapper()
                {
                    LocationContentViewCollection = locationContentViewCollection
                });
                isSuccess = true;
            }
            catch (Exception e)
            {
                Debug.LogError(e.Message);
            }
            return isSuccess;
        }
    }
}

MicroStorage

The Beamable MicroStorage feature's purpose: Offer a native Mongo database integrated with C#Microservices.

The specific usage in "Global Price Wars" is to store shared data (e.g. LocationData, ProductData) with players to provide a consistent 'world market'.

šŸš§

Gotchas

Here are hints to help explain some of the trickier concepts:

ā€¢ Each game client's play session begins with the same initial shared database state
ā€¢ However, for simplicity of development, the database state does not update as players buy/sell items

GPWDataStorage
This class wraps access to the database and defines any low-level, database-specific types such as LocationContentViewsWrapper.

using System;
using Beamable.Samples.GPW.Data;
using MongoDB.Bson;

namespace Beamable.Server
{
    [StorageObject("GPWDataStorage")]
    public class GPWDataStorage : MongoStorageObject
    {
    }
    
    /// <summary>
    /// This class wraps BOTH:
    /// * the concepts of MongoDB (ex. ObjectID) which is required
    /// * and the concepts of the game's custom datatypes (ex. LocationContentView)
    /// </summary>
    [Serializable]
    public class LocationContentViewsWrapper
    {
        public ObjectId Id;
        public LocationContentViewCollection LocationContentViewCollection;
    }
}

Learning Resources

These learning resources provide a better way to build live games in Unity.

SourceDetail
1. Download the Chat GPW2 Sample Project With MicroStorage
2. Open in Unity Editor ( Version 2020.3.23f1 )
3. Open the Beamable Toolbox
4. Sign-In / Register To Beamable. See Step 1 - Getting Started for more info
5. Rebuild the Unity Addressables : Unity ā†’ Window ā†’ Asset Management ā†’ Groups, then Build ā†’ Update a Previous Build
6. Open Unity ā†’ Window ā†’ Beamable ā†’ Open Configuration Manager.
---- Set Beamable ā†’ Microservice ā†’ Enable Storage Preview = true
7. Open Unity ā†’ Window ā†’ Beamable ā†’ Open Microservices Manager
---- Choose "Build And Rerun" for the GPWDataService
8. Open Unity ā†’ Window ā†’ Beamable ā†’ Open Microservices Manager
---- Choose "Run" for the GPWDataStorage
9. Open the Scene01Intro Scene
10. Play The Scene: Unity ā†’ Edit ā†’ Play
11 Click the "Start" Button
12. Enjoy!

Note: Supports Mac & Windows and includes the Beamable SDK