Unity + .NET Core + MagicOnion v3 environment construction hands-on
Introduction
I think MagicOnion is a framework that is comfortable to touch and very easy to use once the environment is built.
However, the initial construction is a little complicated, and I thought that if someone who had no experience with both Unity and the server (.NET Core) tried it, they would often stumble somewhere.
Therefore, this article was written with the aim of creating a material that allows people who have no experience with either of them to build an environment without having to stumble.
If you find yourself stumbling on the way, please let us know on Twitter.
Hands-on takes about 30 minutes to an hour, starting with Unity and Visual Studio installed.
table of contents
1. About the environment
This is the OS, tool, and software version used to write this article.
- Windows 10
- Unity 2019.4.7f1
- Visual Studio 2019 16.7
- MagicOnion 3.0.12
- MessagePack 2.1.152
- gRPC 2.23.0
2. Building on the Unity side
2.1. Create a new project, change PlayerSettings, create various folders
This time we will create a 3D project.
The project name is arbitrary, but it should be Sample.Unity
to distinguish it from the server-side project.
Enter any save destination as the save destination.
Open PlayerSettings
when Unity starts.
Select Player
and change the following two places.
–Change APICompatibilityLevel to .NET 4.x
–Check Allow unsafe Code
Next, create the following folder.
-
Replace Sample.Unity with your own project name.
- Sample.Unity\Assets\Editor
- Sample.Unity\Assets\Plugins
- Sample.Unity\Assets\Scripts
- Sample.Unity\Assets\Scripts\Generated
- Sample.Unity\Assets\Scripts\ServerShared
- Sample.Unity\Assets\Scripts\ServerShared\Hubs
- Sample.Unity\Assets\Scripts\ServerShared\MessagePackObjects
- Sample.Unity\Assets\Scripts\ServerShared\Services
Make sure that the folder structure is as shown below.
2.2. Installing MagicOnion
Download MagicOnion.Client.Unity.unitypackage
from GitHub.
After downloading, double-click to import.
2.3. Installing MessagePack for C
Download MessagePack.Unity.2.1.152.unitypackage
from GitHub.
After downloading, double-click to import.
A warning will be displayed because there is a file that overlaps with MagicOnion, but import it as it is.
2.4. Installing gRPC
From gRPC’s Daily Builds (2019/08/01) grpc_unity_package. Download 2.23.0-dev.zip
.
After downloading, unzip it and copy the following folder to Sample.Unity \ Assets \ Plugins
.
- Google.Protobuf
- Grpc.Core
- Grpc.Core.Api
2.5. Preparing a script to connect to the server
Create a SampleController
script in ʻAssets \ Scenes`.
Copy the code for SampleController below.
Start () connects to the server and OnDestroy () disconnects.
using Grpc.Core;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SampleController : MonoBehaviour
{
private Channel channel;
void Start()
{
this.channel = new Channel("localhost:12345", ChannelCredentials.Insecure);
}
async void OnDestroy()
{
await this.channel.ShutdownAsync();
}
}
Add an empty GameObject to SampleScene.
Add SampleController to the added GameObject.
2.6. Preparation of class for checking the operation of code sharing between Unity ⇔ server
When using MagicOnion, it is common to share classes created on the Unity side with the server side.
This time, set to share all the scripts created under Assets \ Scripts \ ServerShared.
The code sharing settings will be set later on the server side, but first prepare a class for checking the operation.
Create a Player
script in ʻAssets \ ServerShared \ MessagePackObjects`.
Copy and paste the Player code below.
using MessagePack;
using UnityEngine;
namespace Sample.Shared.MessagePackObjects
{
[MessagePackObject]
public class Player
{
[Key(0)]
public string Name { get; set; }
[Key(1)]
public Vector3 Position { get; set; }
[Key(2)]
public Quaternion Rotation { get; set; }
}
}
When using MagicOnion, the class used for communication between Client ⇔ server is defined as such MessagePackObject.
Keep in mind that you only need two things to define as a MessagePackObject:
–Give MessagePackObjectAttribute to class
–Give each property a KeyAttribute and number them in order
This is the end of the construction on the Unity side.
3. Server-side construction
Next, proceed with the construction work on the server side.
3.1. Add a server-side project to your solution
Right-click on the solution and add a new project.
Select the console app (.NET Core) and click Next.
Enter any project name. (Here, it is Sample.Server)
Please specify the root of the project as the location.
3.2. Installing MagicOnion
Install MagicOnion from NuGet.
Open Tools-> NuGet Package Manager-> Manage NuGet Packages for your solution.
Click Browse and search for MagicOnion.
Select MagicOnion.Hosting from the search results and check Sample.Server.
Select the version 3.0.12 and install it.
A preview of your changes will appear, press OK.
You will be asked to agree to the license, so I agree.
3.3. Editing Program.cs
Overwrite and save Program.cs with the following contents.
Now when you launch the server-side project, MagicOnion will launch.
using MagicOnion.Hosting;
using Microsoft.Extensions.Hosting;
using System;
using System.Threading.Tasks;
namespace Sample.Server
{
class Program
{
static async Task Main(string[] args)
{
await MagicOnionHost.CreateDefaultBuilder()
.UseMagicOnion()
.RunConsoleAsync();
}
}
}
3.4. Add class library project to solution
Use this class library to share code between Unity and server.
Right-click on the solution and add a new project.
Select the Class Library (C # .NET Standard).
Enter any project name. (Here, it is Sample.Shared)
Please specify the root of the project as the location.
The automatically created Class1.cs is unnecessary and should be deleted.
3.5. Install MagicOnion.Abstractions in class library
Search for and install MagicOnion.Abstractions from NuGet in the same way you installed MagicOnion earlier.
Select Sample.Shared for the target project and 3.0.12 for the version.
3.6. Install MessagePack.UnityShims in class library
Install MessagePack.UnityShims in Sample.Shared in the same way.
3.7. Refer to the code on the Unity side from the class library
Double-click Sample.Shared to open Sample.Shared.csproj and add the settings in the red frame.
Please copy here for the settings to be added.
- Replace Sample.Unity with the project name on your Unity side.
<ItemGroup>
<Compile Include="..\Sample.Unity\Assets\Scripts\ServerShared\**\*.cs" />
</ItemGroup>
If you look at Solution Explorer in this state, you can see that the files under the ServerShared folder prepared on the Unity side can be loaded into the class library.
3.8. Browse the class library from the server-side project
Right-click on Sample.Server and select Add, Project Reference.
Select Sample.Shared and press OK.
Now you have a server code-shared with Unity.
4. API implementation and operation check
From here, we will implement the API and check its operation.
MagicOnion can use two types of communication, normal API communication and real-time communication, so let’s test each one.
4.1. Ordinary API communication
Let’s start with normal API communication.
4.1.1. Create API definition on Unity side
Create a SampleService script under Assets \ Scripts \ ServerShared \ Services.
Please copy and save the contents of SampleService as follows.
This time, let’s define an API that adds and an API that multiplies.
using MagicOnion;
namespace Sample.Shared.Services
{
public interface ISampleService : IService<ISampleService>
{
UnaryResult<int> SumAsync(int x, int y);
UnaryResult<int> ProductAsync(int x, int y);
}
}
4.1.2. Implement the API on the server side
Right-click on Sample.Server and add a new folder.
Name it Services.
Then add the class in the Services folder.
Name it SampleService.cs.
Please copy and save the contents of SampleService.cs as follows.
using MagicOnion;
using MagicOnion.Server;
using Sample.Shared.Services;
namespace Sample.Server.Services
{
public class SampleService : ServiceBase<ISampleService>, ISampleService
{
public UnaryResult<int> SumAsync(int x, int y)
{
return UnaryResult(x + y);
}
public UnaryResult<int> ProductAsync(int x, int y)
{
return UnaryResult(x * y);
}
}
}
4.1.3. Implement the code that calls the API on the Unity side
Add code to SampleController that calls SampleService.
Please copy and paste the code below to overwrite it.
using Grpc.Core;
using MagicOnion.Client;
using Sample.Shared.Services;
using System.Threading.Tasks;
using UnityEngine;
public class SampleController : MonoBehaviour
{
private Channel channel;
private ISampleService sampleService;
void Start()
{
this.channel = new Channel("localhost:12345", ChannelCredentials.Insecure);
this.sampleService = MagicOnionClient.Create<ISampleService>(channel);
this.SampleServiceTest(1, 2);
}
async void OnDestroy()
{
await this.channel.ShutdownAsync();
}
async void SampleServiceTest(int x, int y)
{
var sumReuslt = await this.sampleService.SumAsync(x, y);
Debug.Log($"{nameof(sumReuslt)}: {sumReuslt}");
var productResult = await this.sampleService.ProductAsync(2, 3);
Debug.Log($"{nameof(productResult)}: {productResult}");
}
}
4.1.4. Confirmation of API communication operation
First, start the server.
Right-click Sample.Server and click Set as Startup Project
.
This will change the button that was normally labeled ʻUnity Attach to
Sample.Server`.
You can start the server by pressing this button.
- To restore the startup project, right-click ʻAssembly-CSharp` and specify it as the startup project.
- If you want to start the server while attaching to Unity, use
Multi-startup project
. (See below)
Then play the Unity Scene with the server running.
The log is displayed in the Unity Console.
4.2. Real-time communication
Then try real-time communication.
4.2.1. Create API definition on Unity side
As with normal API communication, first create the API definition.
Create a SampleHub script under Assets \ Scripts \ ServerShared \ Hubs.
Please copy and save the contents of SampleHub as follows.
This time, we will create four APIs: log in to the game, speak in chat, update location information, and disconnect from the game.
using MagicOnion;
using Sample.Shared.MessagePackObjects;
using System.Threading.Tasks;
using UnityEngine;
namespace Sample.Shared.Hubs
{
/// <summary>
/// CLient ->Server API
/// </summary>
public interface ISampleHub : IStreamingHub<ISampleHub, ISampleHubReceiver>
{
/// <summary>
///Tell the server to connect to the game
/// </summary>
Task JoinAsync(Player player);
/// <summary>
///Tell the server to disconnect from the game
/// </summary>
Task LeaveAsync();
/// <summary>
///Send a message to the server
/// </summary>
Task SendMessageAsync(string message);
/// <summary>
///Tell the server that you have moved
/// </summary>
Task MovePositionAsync(Vector3 position);
}
/// <summary>
/// Server ->Client API
/// </summary>
public interface ISampleHubReceiver
{
/// <summary>
///Tell the client that someone has connected to the game
/// </summary>
void OnJoin(string name);
/// <summary>
///Tell the client that someone has disconnected from the game
/// </summary>
void OnLeave(string name);
/// <summary>
///Tell the client what someone said
/// </summary>
void OnSendMessage(string name, string message);
/// <summary>
///Tell the client that someone has moved
/// </summary>
void OnMovePosition(Player player);
}
}
4.2.2 Implement the API on the server side
Create a Hubs folder under Sample.Server and create SampleHub.cs in it in the same way as when implementing a normal API.
Please copy and save the contents of SampleHub.cs as follows.
using MagicOnion.Server.Hubs;
using Sample.Shared.Hubs;
using Sample.Shared.MessagePackObjects;
using System;
using System.Threading.Tasks;
using UnityEngine;
public class SampleHub : StreamingHubBase<ISampleHub, ISampleHubReceiver>, ISampleHub
{
IGroup room;
Player me;
public async Task JoinAsync(Player player)
{
//All rooms are fixed
const string roomName = "SampleRoom";
//Join the room&Hold room
this.room = await this.Group.AddAsync(roomName);
//Keep your information
me = player;
//Notify all members participating in the room that they have participated
this.Broadcast(room).OnJoin(me.Name);
}
public async Task LeaveAsync()
{
//Remove yourself from members in the room
await room.RemoveAsync(this.Context);
//Notify all members that they have left the room
this.Broadcast(room).OnLeave(me.Name);
}
public async Task SendMessageAsync(string message)
{
//Notify all members of what they said
this.Broadcast(room).OnSendMessage(me.Name, message);
await Task.CompletedTask;
}
public async Task MovePositionAsync(Vector3 position)
{
//Update information on the server
me.Position = position;
//Notify all members of updated player information
this.Broadcast(room).OnMovePosition(me);
await Task.CompletedTask;
}
protected override ValueTask OnConnecting()
{
// handle connection if needed.
Console.WriteLine($"client connected {this.Context.ContextId}");
return CompletedTask;
}
protected override ValueTask OnDisconnected()
{
// handle disconnection if needed.
// on disconnecting, if automatically removed this connection from group.
return CompletedTask;
}
}
4.2.3. Implement code that calls API on Unity side
Add code to the SampleController that calls each SampleHub API.
Please copy and paste the code below to overwrite it.
using Grpc.Core;
using MagicOnion.Client;
using Sample.Shared.Hubs;
using Sample.Shared.MessagePackObjects;
using Sample.Shared.Services;
using UnityEngine;
public class SampleController : MonoBehaviour, ISampleHubReceiver
{
private Channel channel;
private ISampleService sampleService;
private ISampleHub sampleHub;
void Start()
{
this.channel = new Channel("localhost:12345", ChannelCredentials.Insecure);
this.sampleService = MagicOnionClient.Create<ISampleService>(channel);
this.sampleHub = StreamingHubClient.Connect<ISampleHub, ISampleHubReceiver>(this.channel, this);
//Comment out ordinary API calls
//There is no problem if you leave it (both work with real-time communication)
//this.SampleServiceTest(1, 2);
this.SampleHubTest();
}
async void OnDestroy()
{
await this.sampleHub.DisposeAsync();
await this.channel.ShutdownAsync();
}
/// <summary>
///A method for testing normal API communication
/// </summary>
async void SampleServiceTest(int x, int y)
{
var sumReuslt = await this.sampleService.SumAsync(x, y);
Debug.Log($"{nameof(sumReuslt)}: {sumReuslt}");
var productResult = await this.sampleService.ProductAsync(2, 3);
Debug.Log($"{nameof(productResult)}: {productResult}");
}
/// <summary>
///Method for testing real-time communication
/// </summary>
async void SampleHubTest()
{
//Try to create your own player information
var player = new Player
{
Name = "Minami",
Position = new Vector3(0, 0, 0),
Rotation = new Quaternion(0, 0, 0, 0)
};
//Connect to the game
await this.sampleHub.JoinAsync(player);
//Try to speak in chat
await this.sampleHub.SendMessageAsync("Hello!");
//Try updating location information
player.Position = new Vector3(1, 0, 0);
await this.sampleHub.MovePositionAsync(player.Position);
//Try disconnecting from the game
await this.sampleHub.LeaveAsync();
}
#region A group of methods called by the server in real-time communication
public void OnJoin(string name)
{
Debug.Log($"{name}Entered the room");
}
public void OnLeave(string name)
{
Debug.Log($"{name}Has left the room");
}
public void OnSendMessage(string name, string message)
{
Debug.Log($"{name}: {message}");
}
public void OnMovePosition(Player player)
{
Debug.Log($"{player.Name}Has moved: {{ x: {player.Position.x}, y: {player.Position.y}, z: {player.Position.z} }}");
}
#endregion
}
4.2.4. Real-time communication operation check
Start the server in the same way as checking the operation of normal API communication, and then play the Scene in Unity.
The log is displayed in the Unity Console.
Now you can check the operation of both normal API communication and real-time communication.
5. IL2CPP compatible (code generation by code generator)
If you run it on UnityEditor, it’s okay as it is, but if you use IL2CPP (for example, when Platform is iOS), you will get an error like this.
IL2CPP does not support dynamic code generation, so you must use a code generator to generate the required code in advance.
5.1. Installing MagicOnion.MSBuild.Tasks
Install MagicOnion.MSBuild.Tasks with NuGet.
Select Sample.Shared for the project and 3.0.12 for the version.
5.2. Installing MessagePack.MSBuild.Tasks
Install MessagePack.MSBuild.Tasks in the same way.
5.3. Editing Sample.Shared.csproj
Double-click Sample.Shared to open Sample.Shared.csproj.
Add the code in the red frame and save it.
Please copy the code below.
<Target Name="GenerateMessagePack" AfterTargets="Compile">
<MessagePackGenerator Input=".\Sample.Shared.csproj" Output="..\Sample.Unity\Assets\Scripts\Generated\MessagePack.Generated.cs" />
</Target>
<Target Name="GenerateMagicOnion" AfterTargets="Compile">
<MagicOnionGenerator Input=".\Sample.Shared.csproj" Output="..\Sample.Unity\Assets\Scripts\Generated\MagicOnion.Generated.cs" />
</Target>
If you build Sample.Server in this state, the code generator will generate the code under Sample.Unity \ Assets \ Scripts \ Generated.
5.4. Use of generated code
Next, configure the settings to use this code.
Create a C # Script in the Scripts folder and name it InitialSettings.
Copy and save the code for InitialSettings below.
using MessagePack;
using MessagePack.Resolvers;
using UnityEngine;
namespace Assets.Scripts
{
class InitialSettings
{
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
static void RegisterResolvers()
{
// NOTE: Currently, CompositeResolver doesn't work on Unity IL2CPP build. Use StaticCompositeResolver instead of it.
StaticCompositeResolver.Instance.Register(
MagicOnion.Resolvers.MagicOnionResolver.Instance,
MessagePack.Resolvers.GeneratedResolver.Instance,
BuiltinResolver.Instance,
PrimitiveObjectResolver.Instance,
MessagePack.Unity.UnityResolver.Instance
);
MessagePackSerializer.DefaultOptions = MessagePackSerializer.DefaultOptions
.WithResolver(StaticCompositeResolver.Instance);
}
}
}
Now it works in the IL2CPP environment.
6. Supports iOS builds
The following additional work is required for building for iOS.
- Disable Bitcode
- Add libz.tbd
Add C # Script to Assets \ Editor and name it BuildIos.
Copy and paste the BuildIos code below.
#if UNITY_IPHONE
using System.IO;
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEditor.iOS.Xcode;
public class BuildIos
{
/// <summary>
/// Handle libgrpc project settings.
/// </summary>
/// <param name="target"></param>
/// <param name="path"></param>
[PostProcessBuild(1)]
public static void OnPostProcessBuild(BuildTarget target, string path)
{
var projectPath = PBXProject.GetPBXProjectPath(path);
var project = new PBXProject();
project.ReadFromString(File.ReadAllText(projectPath));
var targetGuid = project.GetUnityFrameworkTargetGuid();
// libz.tbd for grpc ios build
project.AddFrameworkToProject(targetGuid, "libz.tbd", false);
// libgrpc_csharp_ext missing bitcode. as BITCODE exand binary size to 250MB.
project.SetBuildProperty(targetGuid, "ENABLE_BITCODE", "NO");
File.WriteAllText(projectPath, project.WriteToString());
}
}
#endif
This is the end of environment construction. Thank you for your hard work.
7. About multi-startup projects
This is how to use the multi-startup project, which I omitted from the explanation.
Right-click on the solution and click Startup Project Settings
.
Check Multi-Startup Project
, set the actions of ʻAssembly-CSharp and
Sample.Server to
Start` and press OK.
If you press Start
in this state, you can start the server while attaching the debugger to Unity.
8. Links to articles that I referred to as a postscript
Thank you for reading this long article to the end.
I hope it helps even a little.
If you have succeeded in building an environment and need more technical content or practical code, the following articles are recommended.
-README of MagicOnion on GitHub
-Official MagicOnion Sample
-Cygames Engineers’ Blog MagicOnion-Real-time communication framework for .NET Core / Unity by C #
-I tried to implement real-time communication with Unity + MagicOnion
-GRPC communication with Unity IL2CPP using MagicOnion v2