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.
- Chat GPW Sample Project - Simpler project, the
GPWBasicDataFactory
uses random, local data values- 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.
Source | Detail |
---|---|
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 Scene10. 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
- Use money in
Pro Tip: Sell all owned items before the final turn to increase the final score.
Screenshots
Scenes
Scene01Intro | Scene02Game | Scene03Chat | Scene04Settings | Scene05Leaderboard |
---|---|---|---|---|
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 Configuration | Location | Product |
---|---|---|
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 toGetLocationContentViews()
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 andScripts
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 Window | Project Configuration | Chat 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.
Difficulty | Scene | Name | Detail |
---|---|---|---|
Beginner | Game | Tweak Configuration | • Update the Configuration.asset values in the Unity Inspector WindowNote: Experiment and have fun! |
Beginner | Various | Add New Graphics | • Change the colors, fonts, and sprites for cosmetics |
Intermediate | Game | Add a New Product item | • Open the Content Manager • Duplicate an existing ProductContent • Update all field values • Update the existing RemoteConfiguration • Publish the content |
Intermediate | Game | Add a New Location item | • Open the Content Manager • Duplicate an existing LocationContent • Update all field values • Update the existing RemoteConfiguration • Publish the content |
Advanced | Chat | Add user-to-user item transactions | • Design and implement new functionality |
Advanced | Various | Store player progress between sessions | • Add Beamable Cloud Save to store the existing PersistentData object |
Advanced | Various | Add 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 populatedGetLocationContentViews()
- Fetches data from databaseCreateLocationContentViews()
- 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.
Source | Detail |
---|---|
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 → Addressables → Groups, then Build → New Build → Default Build Script 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 Scene10. Play The Scene: Unity → Edit → Play 11 Click the "Start" Button 12. Enjoy! Note: Supports Mac & Windows and includes the Beamable SDK |
Updated about 1 year ago