NTTドコモR&Dの技術ブログです。

UnityでAgoraのSignaling(RTM)を使ってサイコロゲームを作ってみた

※ 本記事は 2026/3/31 以前にNTTコノキューにて記載した記事になります

はじめに

こんにちは、NTTコノキューの中矢です。業務ではUnity、C#と格闘しています。

本記事では、Agoraと呼ばれる通話や配信を簡単に実装できるAPI/SDKのうち、Signaling(RTM)を利用して、2人以上で操作できる同期サイコロゲームを作り、Signaling(RTM)でのデータ通信を学ぶことを目的とします。

この記事が少しでも、Agoraを利用してみたい人の参考になれば幸いです。

AgoraとSignaling(RTM)とは

Agora(アゴラ)とは、「高品質な通話・配信・会話型AIの機能を簡単に実装できる『Agora』超低遅延&高拡張性のAPI/SDK」です。

Agoraの概要:https://jp.vcube.com/sdk

AgoraのSDKをUnityなどに組み込めば、音声通話、ビデオ通話、配信などのオンライン体験やリアルタイムコミュニケーション機能の実装が簡単に作ることができます。

Agoraの特徴はSD-RTN(Software-Defined Real-Time Network)と呼ばれるネットワーク基盤で、この方式により超低遅延かつ高い信頼性の通信を実現しているとのことです。

SD-RTNの概要:https://www.agora.io/en/the-agora-platform-advantage/

本記事ではAgoraで利用できる機能のうち、Signalingと呼ばれる低遅延なリアルタイムのイベント通信の機能を利用し、ちょっとした同期通信機能のあるサイコロゲームを作ります。また、SignalingはRTM(Real-Time Messaging)とも呼ばれているようで、検索するとRTMで引っかかることも多いです。

Signalingの概要:https://docs.agora.io/en/signaling/overview/product-overview?platform=unity

サンプルで作ったサイコロゲームアプリ

本記事でサンプルとして作ったサイコロゲーム(Androidアプリ)は、AgoraのSignalingのクイックスタートを参考に作ったものです。そのため、RTMのクイックスタートのサンプルは動かせる知識がある前提で解説します。

Signalingのクイックスタート:https://docs.agora.io/en/signaling/get-started/sdk-quickstart?platform=unity

このサイコロゲームは「Join」ボタンでAgoraのRTMのチャンネル(通信部屋)に参加します。

そしてチャンネルに参加している最中に「DiceRoll」ボタンを押すと、サイコロが振られ、結果が出たら同じチャンネルに参加している端末のサイコロがダイスロールのアニメーションをし、サイコロの目の結果が同期します。

最後に「Leave」ボタンでチャンネルから退出します。

本記事では、このサイコロゲームを題材に、AgoraのSignaling(RTM)でデータ送信を行う例を解説します。

環境

  1. Windows 11
  2. Unity 2022.3.50f1
  3. Signaling SDK Version 2.1.9 (Latest)

サンプルアプリの組み立て方

まずSwitch PlatformでAndroid向けに設定し、シーン上にCanvasを設置、以下のUIオブジェクトを追加します。この後、オブジェクトにコードをアタッチしていきますが、それぞれのコードは最後に全文記載します。

  1. DiceImage:サイコロの画像を反映するImage
  2. DiceRollButton:ダイスロールアニメーション再生ボタン(チャンネル参加中はデータ送信も行う)
  3. JoinButton:チャンネル参加
  4. LeaveButton:チャンネル退出

続いてDiceRollButtonに「DiceGameDataController.cs」、JoinButtonに「JoinButton.cs」、LeaveButtonに「LeaveButton.cs」をアタッチします。

そして、ButtonコンポーネントのOnClickにおいて、DiceRollButtonでは「DiceGameDataController.OnDiceRoll」メソッド、JoinButtonでは「JoinButton.OnJoinRtmChannel」メソッド、LeaveButtonでは「LeaveButton.OnLeaveRtmChannel」メソッドを設定します。

次に、空のオブジェクトで「Agora」と「DiceGame」を作成します。

続いて、Agoraオブジェクトには「DiceGameRtmEngine.cs」をアタッチし、DiceGameオブジェクトには「DiceAnimationPlayer.cs」をアタッチします。

また、DiceGameRtmEngineにはAgoraのSignalingのクイックスタート同様、Token Builderで発行したトークンを設定します。

Token Builder:https://agora-token-generator-demo.vercel.app/

DiceAnimationPlayerには、DiceImageオブジェクトの参照と、そのImageに反映させるテクスチャを設定します。ここでテクスチャはテキトーに作った正方形の画像を設定しています。(本記事ではテキトーにサイコロ画像を作成し反映させました)

最後にアタッチしたコンポーネントが必要としているC#スクリプトの参照を設定することでサンプルが完成します。

クイックスタートと同様にUser IDを別々にして、アプリを2つ立ち上げることで、サイコロの結果が同期する簡単なゲームを楽しむことができます。

Signaling(RTM)のデータ送信方法の例

本記事ではAgoraのSignaling(RTM)のTopicを利用したメッセージ送信を行います。

Topicのメッセージ送信はクイックスタートを参考に、DiceGameRtmEngineクラス内の以下のメソッドで処理します。

public async void PublishBinaryMessage(string message)
{
   byte[] byteMsg = System.Text.Encoding.UTF8.GetBytes(message);
   if (streamChannel == null) return;
  
   TopicMessageOptions options = new TopicMessageOptions();
   options.customType = "PlainBinary";
   options.sendTs = 0;
   try
   {
       var result = await streamChannel.PublishTopicMessageAsync(topicName, byteMsg, options);
       Debug.Log("Publish Binary Message Success!");
   }
   catch (RTMException e)
   {
       Debug.Log($"{e.Status.Operation} is failed");
       Debug.Log($"The error code is {e.Status.ErrorCode}, due to: {e.Status.Reason}");
   }
}

ここでは、string型で受け取ったメッセージをUTF8でバイナリデータにエンコードし、Topic上でメッセージを送信しています。

本サンプルで送信しているメッセージは以下のDiceGameDataクラスであり、これをNewtonsoft.jsonでJSONにシリアライズしてstring型にしてから、PublishBinaryMessageメソッドへ送っています。

[Unity]Newtonsoft.Jsonの基本的な使い方:https://qiita.com/kazuma_f/items/55a0b7ff628ab596e6ee

public class DiceGameData
{
   public int diceResultNumber;


   public DiceGameData(int diceResultNumber)
   {
       this.diceResultNumber = diceResultNumber;
   }
}

Newtonsoft.jsonでクラス情報をJSONにシリアライズしてstring型にし、PublishBinaryMessageメソッドへ送信するコードはDiceGameDataControllerクラスに記載されており、以下のようになります。

public void OnDiceRoll()
{
   diceAnimationPlayer.StopRollDice();
   int resultDiceNumber = diceAnimationPlayer.DiceRoll();
   DiceGameData diceGameData = new (resultDiceNumber);
   string diceGameDataJson = JsonConvert.SerializeObject(diceGameData);
   diceGameRtmEngine.PublishBinaryMessage(diceGameDataJson);
}

これが、AgoraのRTMエンジンにデータ送信する方法の例になります。

ちなみに、JoinButtonクラスでは、Topicでメッセージを送信するためのセットアップをしており、StreamChannelを作って、そのチャンネルに参加し、Topicに参加して、Topicをサブスクライブしています。

public async void OnJoinRtmChannel()
{
   diceGameRtmEngine.CreateStChannel();
   await diceGameRtmEngine.JoinStChannel();
   await diceGameRtmEngine.JoinTopic();
   await diceGameRtmEngine.SubscribeTopic();
}

ここでTopicをサブスクライブすることにより、DiceGameRtmEngineクラスの以下のAgoraのOnMessageEventにてデータ受信しています。

// Implement the message event handler
private void OnMessageEvent(MessageEvent eve)
{
   var channelName = eve.channelName;
   var channelType = eve.channelType;
   var topic = eve.channelTopic;
   var publisher = eve.publisher;
   var messageType = eve.messageType;
   var customType = eve.customType;
   var message = eve.message;
   if (messageType == RTM_MESSAGE_TYPE.BINARY)
   {
       var binaryMessage = message.GetData<byte[]>();
       diceGameDataController.OnDiceRollResult(System.Text.Encoding.UTF8.GetString(binaryMessage));
       Debug.Log($"You have received a binary type message: {System.Text.Encoding.UTF8.GetString(binaryMessage)},from: {publisher} in channel:{channelName}");
       Debug.Log($"The channel type is {channelType}");
   }
}

Agoraから手に入るデータはByteであるため、それをUTF8でデコードし、string型にします。

それをOnDiceRollResultメソッド内で以下のようにJSONテキストデータをデシリアライズしDiceGameDataクラスにして、サイコロ結果のデータを取得しています。

public void OnDiceRollResult(string diceGameDataJson)
{
   diceAnimationPlayer.StopRollDice();
   DiceGameData diceGameData = JsonConvert.DeserializeObject<DiceGameData>(diceGameDataJson);
   diceAnimationPlayer.PlayAnimationDiceRollResultSubscribe(diceGameData.diceResultNumber);
}

これにより、AgoraのSignaling(RTM)を使ったデータ送受信を実現しています。

まとめ

本記事ではAgoraのSignalingのクイックスタートのコードを参考に端末間で同期するサイコロゲームを作成、SignalingのTopicでデータを送受信する方法の例を解説しました。

本記事ではNewtonsoft.jsonと併用し、AgoraのRTMで簡単にデータ送受信するUnityクライアント側の実装例を示しましたが、あくまで一例のため、本記事を足掛かりに色んなAgoraの使い方を模索するとよいかもしれません。

サンプルコード全文

ここではサンプルコード全文を載せます。少々長いオマケですが、ご興味あればサンプルを組み上げて、実際に動かしてみるといいかもしれません。

DiceGameDataController.cs

using UnityEngine;
using Newtonsoft.Json;


public class DiceGameDataController : MonoBehaviour
{
   [SerializeField] private DiceGameRtmEngine diceGameRtmEngine;
   [SerializeField] private DiceAnimationPlayer diceAnimationPlayer;


   public void OnDiceRoll()
   {
       diceAnimationPlayer.StopRollDice();
       int resultDiceNumber = diceAnimationPlayer.DiceRoll();
       DiceGameData diceGameData = new (resultDiceNumber);
       string diceGameDataJson = JsonConvert.SerializeObject(diceGameData);
       diceGameRtmEngine.PublishBinaryMessage(diceGameDataJson);
   }
  
   public void OnDiceRollResult(string diceGameDataJson)
   {
       diceAnimationPlayer.StopRollDice();
       DiceGameData diceGameData = JsonConvert.DeserializeObject<DiceGameData>(diceGameDataJson);
       diceAnimationPlayer.PlayAnimationDiceRollResultSubscribe(diceGameData.diceResultNumber);
   }
}


public class DiceGameData
{
   public int diceResultNumber;


   public DiceGameData(int diceResultNumber)
   {
       this.diceResultNumber = diceResultNumber;
   }
}

JoinButton.cs

using UnityEngine;


public class JoinButton : MonoBehaviour
{
   [SerializeField] private DiceGameRtmEngine diceGameRtmEngine;
  
   public async void OnJoinRtmChannel()
   {
       diceGameRtmEngine.CreateStChannel();
       await diceGameRtmEngine.JoinStChannel();
       await diceGameRtmEngine.JoinTopic();
       await diceGameRtmEngine.SubscribeTopic();
   }
}

LeaveButton.cs

using UnityEngine;


public class LeaveButton : MonoBehaviour
{
   [SerializeField] private DiceGameRtmEngine diceGameRtmEngine;
  
   public async void OnLeaveRtmChannel()
   {
       await diceGameRtmEngine.UnsubscribeTopic();
       await diceGameRtmEngine.LeaveTopic();
       await diceGameRtmEngine.LeaveStChannel();
       diceGameRtmEngine.ReleaseStChannel();
   }
}

DiceGameRtmEngine.cs AgoraのSignalingのクイックスタートを参考にしていますが、少々改造しています。

using UnityEngine;
using Cysharp.Threading.Tasks;
using Agora.Rtm;


public class DiceGameRtmEngine : MonoBehaviour
{
   [SerializeField]
   private string appId = "your_appId"; // Get an App ID from Agora console
   [SerializeField]
   private string userId = "your_userId"; // Change the user ID to a numeric string
   [SerializeField]
   private string token = "your_token"; // Login token
   [SerializeField]
   private string rtcToken = "your_RTC_token"; // Token to join a stream channel
   [SerializeField]
   private string msChannelName = "MessageDiceChannel";
   [SerializeField]
   private string stChannelName = "StreamDiceChannel";
   [SerializeField]
   private string topicName = "DiceTopic";
   [SerializeField]
   private DiceGameDataController diceGameDataController;
  
   private IRtmClient rtmClient;
   private IStreamChannel streamChannel;


   private async void Awake()
   {
       await Initialize();
   }


   // Initialize RTM
   private async UniTask Initialize()
   {
       if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(appId))
       {
           Debug.Log("We need a userId and appId to initialize!");
           return;
       }
       RtmLogConfig logConfig = new RtmLogConfig();
       // Set log file path
       logConfig.filePath = "myFilePath";
       // Set the file size of the agore.log file
       logConfig.fileSizeInKB = 512;
       // Set the log report level
       logConfig.level = RTM_LOG_LEVEL.INFO;
       RtmConfig config = new RtmConfig();
       config.appId = appId;
       config.userId = userId;
       config.logConfig = logConfig;
       // Create the RTM Client
       try
       {
           rtmClient = RtmClient.CreateAgoraRtmClient(config);
           Debug.Log("RTM Client Initialize Sucessfull");
       }
       catch (RTMException e)
       {
           Debug.Log($"{e.Status.Operation} is failed");
           Debug.Log($"The error code is {e.Status.ErrorCode}, due to: {e.Status.Reason}");
       }
       // Add listener
       if (rtmClient == null) return;
      
       // Add the message event listener
       rtmClient.OnMessageEvent += OnMessageEvent;
       // Add the presence event listener
       rtmClient.OnPresenceEvent += OnPresenceEvent;
       // Add the connection state change listener
       rtmClient.OnConnectionStateChanged += OnConnectionStateChanged;
       // Log in to the RTM server
       try
       {
           var result = await rtmClient.LoginAsync(token);
           Debug.Log("Login Successfully");
       }
       catch (RTMException e)
       {
           Debug.Log($"{e.Status.Operation} is failed");
           Debug.Log($"The error code is {e.Status.ErrorCode}, due to: {e.Status.Reason}");
           Debug.Log("Login failed");
       }
      
       //Subscribe to a Message Channel
       SubscribeOptions options = new SubscribeOptions()
       {
           withMessage = true,
           withPresence = true
       };


       try
       {
           var result = await rtmClient.SubscribeAsync(msChannelName, options);
       }
       catch (RTMException e)
       {
           Debug.Log($"{e.Status.Operation} is failed");
           Debug.Log($"The error code is {e.Status.ErrorCode}, due to: {e.Status.Reason}");
       }
   }
   // Implement the event listener handler
   // Implement the connection state change event handler
   private void OnConnectionStateChanged(string channelName, RTM_CONNECTION_STATE state, RTM_CONNECTION_CHANGE_REASON reason)
   {
       Debug.Log($"Channel:{channelName} connection state have changed to:{state} because of {reason}");
   }
  
   // Implement the presence event handler
   private void OnPresenceEvent(PresenceEvent eve)
   {
       Debug.Log($"The Event : {eve.type} Happend");
   }
  
   // Implement the message event handler
   private void OnMessageEvent(MessageEvent eve)
   {
       var channelName = eve.channelName;
       var channelType = eve.channelType;
       var topic = eve.channelTopic;
       var publisher = eve.publisher;
       var messageType = eve.messageType;
       var customType = eve.customType;
       var message = eve.message;
       if (messageType == RTM_MESSAGE_TYPE.BINARY)
       {
           var binaryMessage = message.GetData<byte[]>();
           diceGameDataController.OnDiceRollResult(System.Text.Encoding.UTF8.GetString(binaryMessage));
           Debug.Log($"You have received a binary type message: {System.Text.Encoding.UTF8.GetString(binaryMessage)},from: {publisher} in channel:{channelName}");
           Debug.Log($"The channel type is {channelType}");
       }
   }
  
   public async void PublishBinaryMessage(string message)
   {
       byte[] byteMsg = System.Text.Encoding.UTF8.GetBytes(message);
       if (streamChannel == null) return;
      
       TopicMessageOptions options = new TopicMessageOptions();
       options.customType = "PlainBinary";
       options.sendTs = 0;
       try
       {
           var result = await streamChannel.PublishTopicMessageAsync(topicName, byteMsg, options);
           Debug.Log("Publish Binary Message Success!");
       }
       catch (RTMException e)
       {
           Debug.Log($"{e.Status.Operation} is failed");
           Debug.Log($"The error code is {e.Status.ErrorCode}, due to: {e.Status.Reason}");
       }
   }
  
   public void CreateStChannel()
   {
       // Create a Stream Channel
       if (rtmClient == null) return;


       try
       {
           streamChannel = rtmClient.CreateStreamChannel(stChannelName);
           Debug.Log("Create Stream Channel:" + stChannelName + "Success !");
       }
       catch (RTMException e)
       {
           Debug.Log($"{e.Status.Operation} is failed");
           Debug.Log($"The error code is {e.Status.ErrorCode}, due to: {e.Status.Reason}");
       }
   }
  
   public async UniTask JoinStChannel()
   {
       // Join a Stream Channel
       if (streamChannel == null) return;
      
       JoinChannelOptions options = new JoinChannelOptions();
       options.withPresence = true;
       options.token = rtcToken;


       try
       {
           var result = await streamChannel.JoinAsync(options);
           var response = result.Response;
           Debug.Log($"User:{response.UserId} Join stream channel success! at Channel:{response.ChannelName}");
       }
       catch (RTMException e)
       {
           Debug.Log($"{e.Status.Operation} is failed");
           Debug.Log($"The error code is {e.Status.ErrorCode}, due to: {e.Status.Reason}");
       }
   }
  
   public async UniTask JoinTopic()
   {
       // Join a topic
       if (streamChannel == null) return;
      
       JoinTopicOptions options = new JoinTopicOptions();
       options.qos = RTM_MESSAGE_QOS.ORDERED;
       options.syncWithMedia = false;
       try
       {
           var result = await streamChannel.JoinTopicAsync(topicName, options);
           var response = result.Response;
           Debug.Log($"User:{response.UserId} Join Topic:{response.Topic} success! at Channel:{response.ChannelName}");
       }
       catch (RTMException e)
       {
           Debug.Log($"{e.Status.Operation} is failed");
           Debug.Log($"The error code is {e.Status.ErrorCode}, due to: {e.Status.Reason}");
       }
   }
  
   public async UniTask SubscribeTopic()
   {
       // Subscribe a publisher from a topic
       TopicOptions options = new TopicOptions();
       try
       {
           var result = await streamChannel.SubscribeTopicAsync(topicName, options);
           var response = result.Response;
           Debug.Log($"User:{response.UserId} Subscribe Topic:{response.Topic} success! at Channel:{response.ChannelName}");
       }
       catch (RTMException e)
       {
           Debug.Log($"{e.Status.Operation} is failed");
           Debug.Log($"The error code is {e.Status.ErrorCode}, due to: {e.Status.Reason}");
       }
   }
  
   public void ReleaseStChannel()
   {
       // Release a Stream Channel
       if (rtmClient == null || streamChannel == null) return;


       try
       {
           var status = streamChannel.Dispose();
           streamChannel = null;
           Debug.Log("Dispose Channel Success!");
       }
       catch (RTMException e)
       {
           Debug.Log($"{e.Status.Operation} is failed");
           Debug.Log($"The error code is {e.Status.ErrorCode}, due to: {e.Status.Reason}");
       }
   }
  
   public async UniTask LeaveStChannel()
   {
       // Leave a Stream Channel
       if (streamChannel == null) return;


       try
       {
           var result = await streamChannel.LeaveAsync();
           var response = result.Response;
           Debug.Log($"User:{response.UserId} Leave stream channel success! at Channel:{response.ChannelName}");
       }
       catch (RTMException e)
       {
           Debug.Log($"{e.Status.Operation} is failed");
           Debug.Log($"The error code is {e.Status.ErrorCode}, due to: {e.Status.Reason}");
       }
   }
  
   public async UniTask LeaveTopic()
   {
       // Leave a topic
       if (streamChannel == null) return;


       try
       {
           var result = await streamChannel.LeaveTopicAsync(topicName);
           var response = result.Response;
           Debug.Log($"User:{response.UserId} Leave Topic:{response.Topic} success! at Channel:{response.ChannelName}");
       }
       catch (RTMException e)
       {
           Debug.Log($"{e.Status.Operation} is failed");
           Debug.Log($"The error code is {e.Status.ErrorCode}, due to: {e.Status.Reason}");
       }
   }
  
   public async UniTask UnsubscribeTopic()
   {
       // Unsubscribe a publisher from a topic
       TopicOptions options = new TopicOptions();
       try
       {
           var result = await streamChannel.UnsubscribeTopicAsync(topicName, options);
           var response = result.Response;
           Debug.Log("Unsubscribe Topic Success!");
       }
       catch (RTMException e)
       {
           Debug.Log($"{e.Status.Operation} is failed");
           Debug.Log($"The error code is {e.Status.ErrorCode}, due to: {e.Status.Reason}");
       }
   }
  
   private void OnDestroy()
   {
       // Dispose a Stream Channel
       if (rtmClient != null && streamChannel != null)
       {
           try
           {
               var status = streamChannel.Dispose();
               Debug.Log("Dispose Channel Success!");
           }
           catch (RTMException e)
           {
               Debug.Log($"{e.Status.Operation} is failed");
               Debug.Log($"The error code is {e.Status.ErrorCode}, due to: {e.Status.Reason}");
           }
       }


       if (rtmClient == null) return;


       try
       {
           var status = rtmClient.Dispose();
           Debug.Log("Dispose rtmClient Success!");
       }
       catch (RTMException e)
       {
           Debug.Log($"{e.Status.Operation} is failed");
           Debug.Log($"The error code is {e.Status.ErrorCode}, due to: {e.Status.Reason}");
       }
   }
}

DiceAnimationPlayer.cs UniRxでサイコロのアニメーションを実現していたりします。

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UniRx;
using Random = UnityEngine.Random;


public class DiceAnimationPlayer : MonoBehaviour
{
   private readonly float waitTime = 0.1f;
   private readonly int updateMaxCount = 10;
   private readonly int[] defaultDiceNumbers = { 1, 2, 3, 4, 5, 6 };
   private readonly List<int> diceNumbers = new ();
  
   [SerializeField] private Image diceImage;
   [SerializeField] private List<Sprite> diceTextures;
  
   private IDisposable intervalSubscription;
   private int recentSpriteIndex;


   private void Awake()
   {
       int randomIndex = Random.Range(1, diceTextures.Count);
       recentSpriteIndex = randomIndex;
       diceImage.sprite = diceTextures[randomIndex - 1];
   }
  
   public void StopRollDice()
   {
       intervalSubscription?.Dispose();
   }


   public int DiceRoll()
   {
       int resultDiceNumber = Random.Range(1, diceTextures.Count + 1);
       PlayAnimationDiceRollResultSubscribe(resultDiceNumber);
       return resultDiceNumber;
   }


   public void PlayAnimationDiceRollResultSubscribe(int resultDiceNumber)
   {
       intervalSubscription?.Dispose();
       intervalSubscription = Observable.Interval(TimeSpan.FromSeconds(waitTime))
           .Take(updateMaxCount - 1)
           .Subscribe(x =>
               {
                   PlayAnimationDiceRoll(x, resultDiceNumber);
               },
               () =>
               {
                   diceImage.sprite = diceTextures[resultDiceNumber - 1];
               }).AddTo(this);
   }


   private void PlayAnimationDiceRoll(long updateCount, int resultDiceNumber)
   {
       diceNumbers.Clear();
       diceNumbers.AddRange(defaultDiceNumbers);
       if (updateCount == updateMaxCount - 2)
       {
           diceNumbers.Remove(resultDiceNumber);
       }
       diceNumbers.Remove(recentSpriteIndex);
       int randomIndex = Random.Range(0, diceNumbers.Count);
       diceImage.sprite = diceTextures[diceNumbers[randomIndex] - 1];
       recentSpriteIndex = diceNumbers[randomIndex];
   }
}