Technical Documentation: Cysharp/MagicOnion
โน๏ธ Provenance: Hybrid Fusion:
Cysharp/MagicOnion(README + 10 In-Tree Chapters) ยท CodeWiki Reference ยท Recency: Active (< 180 days)
1. Project Overview & Quickstart (Cysharp/MagicOnion)
MagicOnion
Unified Realtime/API framework for .NET platform and Unity.
๐ Documentation (English) | Documentation (Japanese)
About MagicOnion
MagicOnion is a modern RPC framework for .NET platform that provides bi-directional real-time communications such as SignalR and Socket.io and RPC mechanisms such as WCF and web-based APIs.
This framework is based on gRPC, which is a fast and compact binary network transport for HTTP/2. However, unlike plain gRPC, it treats C# interfaces as a protocol schema, enabling seamless code sharing between C# projects without .proto (Protocol Buffers IDL).
Interfaces are schemas and provide API services, just like the plain C# code
Using the StreamingHub real-time communication service, the server can broadcast data to multiple clients
MagicOnion can be adopted or replaced in the following use cases:
- RPC services such as gRPC, used by Microservices, and WCF, commonly used by WinForms/WPF
- API services such as ASP.NET Core Web API targeting various platforms and clients such as Windows WPF applications, Unity games, .NET for iOS, Android, and .NET MAUI
- Bi-directional real-time communication such as Socket.io, SignalR, Photon and UNet
MagicOnion supports API services and real-time communication, making it suitable for various use cases. You can use either of these features separately, but configurations that combine both are also supported.
More information about MagicOnion can be found in the MagicOnion documentation.
Supported Platforms
MagicOnion is designed to run on various .NET platforms. The requirements for the server and client are as follows.
Server-side
MagicOnion server requires .NET 8+.
Client-side
MagicOnion client supports a wide range of platforms, including .NET Framework 4.6.1 to .NET 8 as well as Unity.
- .NET 8+
- .NET Standard 2.1, 2.0
- Unity 2022.3 (LTS) or newer
- Windows, macOS, iOS, Android
- IL2CPP, Mono
Quick Start
This guide shows how to create a simple MagicOnion server and client. The server provides a simple service that adds two numbers, and the client calls the service to get the result.
MagicOnion provides RPC services like Web API and StreamingHub for real-time communication. This section implements an RPC service like Web API.
Server-side: Defining and Implementing a Service
At first, create a MagicOnion server project and define and implement a service interface.
1. Setting up a gRPC server project for MagicOnion
To start with a Minimal API project (see: Tutorial: Create a minimal web API with ASP.NET Core), create a project from the ASP.NET Core Empty template. Add the NuGet package MagicOnion.Server to the project. If you are using the .NET CLI tool to add it, run the following command:
dotnet add package MagicOnion.ServerOpen Program.cs and add some method calls to Services and app.
using MagicOnion;
using MagicOnion.Server;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMagicOnion(); // Add this line(MagicOnion.Server)
var app = builder.Build();
app.MapMagicOnionService(); // Add this line
app.Run();At this point, you are ready to use MagicOnion in your server project.
2. Implementing a Unary Service
Add the IMyFirstService interface to share it between the server and the client. In this case, the namespace that contains the shared interface is MyApp.Shared.
The return type must be UnaryResult<T> or UnaryResult, which is treated as an asynchronous method like Task or ValueTask.
using System;
using MagicOnion;
namespace MyApp.Shared
{
// Defines .NET interface as a Server/Client IDL.
// The interface is shared between server and client.
public interface IMyFirstService : IService
{
// The return type must be `UnaryResult<T>` or `UnaryResult`.
UnaryResult SumAsync(int x, int y);
}
}Add a class that implements the IMyFirstService interface. The client calls this class to process the request.
using MagicOnion;
using MagicOnion.Server;
using MyApp.Shared;
namespace MyApp.Services;
// Implements RPC service in the server project.
// The implementation class must inherit `ServiceBase` and `IMyFirstService`
public class MyFirstService : ServiceBase, IMyFirstService
{
// `UnaryResult<T>` allows the method to be treated as `async` method.
public async UnaryResult SumAsync(int x, int y)
{
Console.WriteLine($"Received:{x}, {y}");
return x + y;
}
}The service definition and implementation are now complete.
It is now ready to start the MagicOnion server. You can start the MagicOnion server by pressing the F5 key or using the dotnet run command. At this time, note the URL displayed when the server starts, as it will be the connection destination for the client.
Client-side: Calling a Unary Service
Create a Console Application project and add the NuGet package MagicOnion.Client.
Share the IMyFirstService interface and use it in the client. You can share the interface in various ways, such as file links, shared libraries, or copy & paste...
In the client code, create a client proxy using MagicOnionClient based on the shared interface and call the service transparently.
At first, create a gRPC channel. The gRPC channel abstracts the connection, and you can create it using the GrpcChannel.ForAddress method. Then, create a MagicOnion client proxy using the created channel.
using Grpc.Net.Client;
using MagicOnion.Client;
using MyApp.Shared;
// Connect to the server using gRPC channel.
var channel = GrpcChannel.ForAddress("https://localhost:5001");
// Create a proxy to call the server transparently.
var client = MagicOnionClient.Create(channel);
// Call the server-side method using the proxy.
var result = await client.SumAsync(123, 456);
Console.WriteLine($"Result: {result}");[!TIP]
When using MagicOnion client in Unity applications, see also Works with Unity.
More detailed documentation
More information about MagicOnion can be found in the MagicOnion documentation.
License
This library is under the MIT License.
2. In-Tree Documentation Chapters (Cysharp/MagicOnion)
File: README.md
MagicOnion
Unified Realtime/API framework for .NET platform and Unity.
๐ Documentation (English) | Documentation (Japanese)
About MagicOnion
MagicOnion is a modern RPC framework for .NET platform that provides bi-directional real-time communications such as SignalR and Socket.io and RPC mechanisms such as WCF and web-based APIs.
This framework is based on gRPC, which is a fast and compact binary network transport for HTTP/2. However, unlike plain gRPC, it treats C# interfaces as a protocol schema, enabling seamless code sharing between C# projects without .proto (Protocol Buffers IDL).
Interfaces are schemas and provide API services, just like the plain C# code
Using the StreamingHub real-time communication service, the server can broadcast data to multiple clients
MagicOnion can be adopted or replaced in the following use cases:
- RPC services such as gRPC, used by Microservices, and WCF, commonly used by WinForms/WPF
- API services such as ASP.NET Core Web API targeting various platforms and clients such as Windows WPF applications, Unity games, .NET for iOS, Android, and .NET MAUI
- Bi-directional real-time communication such as Socket.io, SignalR, Photon and UNet
MagicOnion supports API services and real-time communication, making it suitable for various use cases. You can use either of these features separately, but configurations that combine both are also supported.
More information about MagicOnion can be found in the MagicOnion documentation.
Supported Platforms
MagicOnion is designed to run on various .NET platforms. The requirements for the server and client are as follows.
Server-side
MagicOnion server requires .NET 8+.
Client-side
MagicOnion client supports a wide range of platforms, including .NET Framework 4.6.1 to .NET 8 as well as Unity.
- .NET 8+
- .NET Standard 2.1, 2.0
- Unity 2022.3 (LTS) or newer
- Windows, macOS, iOS, Android
- IL2CPP, Mono
Quick Start
This guide shows how to create a simple MagicOnion server and client. The server provides a simple service that adds two numbers, and the client calls the service to get the result.
MagicOnion provides RPC services like Web API and StreamingHub for real-time communication. This section implements an RPC service like Web API.
Server-side: Defining and Implementing a Service
At first, create a MagicOnion server project and define and implement a service interface.
1. Setting up a gRPC server project for MagicOnion
To start with a Minimal API project (see: Tutorial: Create a minimal web API with ASP.NET Core), create a project from the ASP.NET Core Empty template. Add the NuGet package MagicOnion.Server to the project. If you are using the .NET CLI tool to add it, run the following command:
dotnet add package MagicOnion.ServerOpen Program.cs and add some method calls to Services and app.
using MagicOnion;
using MagicOnion.Server;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMagicOnion(); // Add this line(MagicOnion.Server)
var app = builder.Build();
app.MapMagicOnionService(); // Add this line
app.Run();At this point, you are ready to use MagicOnion in your server project.
2. Implementing a Unary Service
Add the IMyFirstService interface to share it between the server and the client. In this case, the namespace that contains the shared interface is MyApp.Shared.
The return type must be UnaryResult<T> or UnaryResult, which is treated as an asynchronous method like Task or ValueTask.
using System;
using MagicOnion;
namespace MyApp.Shared
{
// Defines .NET interface as a Server/Client IDL.
// The interface is shared between server and client.
public interface IMyFirstService : IService
{
// The return type must be `UnaryResult<T>` or `UnaryResult`.
UnaryResult SumAsync(int x, int y);
}
}Add a class that implements the IMyFirstService interface. The client calls this class to process the request.
using MagicOnion;
using MagicOnion.Server;
using MyApp.Shared;
namespace MyApp.Services;
// Implements RPC service in the server project.
// The implementation class must inherit `ServiceBase` and `IMyFirstService`
public class MyFirstService : ServiceBase, IMyFirstService
{
// `UnaryResult<T>` allows the method to be treated as `async` method.
public async UnaryResult SumAsync(int x, int y)
{
Console.WriteLine($"Received:{x}, {y}");
return x + y;
}
}The service definition and implementation are now complete.
It is now ready to start the MagicOnion server. You can start the MagicOnion server by pressing the F5 key or using the dotnet run command. At this time, note the URL displayed when the server starts, as it will be the connection destination for the client.
Client-side: Calling a Unary Service
Create a Console Application project and add the NuGet package MagicOnion.Client.
Share the IMyFirstService interface and use it in the client. You can share the interface in various ways, such as file links, shared libraries, or copy & paste...
In the client code, create a client proxy using MagicOnionClient based on the shared interface and call the service transparently.
At first, create a gRPC channel. The gRPC channel abstracts the connection, and you can create it using the GrpcChannel.ForAddress method. Then, create a MagicOnion client proxy using the created channel.
using Grpc.Net.Client;
using MagicOnion.Client;
using MyApp.Shared;
// Connect to the server using gRPC channel.
var channel = GrpcChannel.ForAddress("https://localhost:5001");
// Create a proxy to call the server transparently.
var client = MagicOnionClient.Create(channel);
// Call the server-side method using the proxy.
var result = await client.SumAsync(123, 456);
Console.WriteLine($"Result: {result}");[!TIP]
When using MagicOnion client in Unity applications, see also Works with Unity.
More detailed documentation
More information about MagicOnion can be found in the MagicOnion documentation.
License
This library is under the MIT License.
File: docs/docs/advanced/customize-serialization-encryption.md
Customizing message serialization and encryption
MagicOnion uses MessagePack for serialization by default, but it also provides extension points to customize serialization.
It allows for customization, such as encryption and the using of serializers other than MessagePack.
How to customize
Customizing serialization involves implementing a serializer that implements the IMagicOnionSerializerProvider interface and the IMagicOnionSerializer interface that the Create method returns.
You can use the implemented serializer provider by setting it to the MagicOnionSerializerProvider.Default property or passing it as an argument to MagicOnionClient and StramingHubClient.
API
///
/// Provides a serializer for request/response of MagicOnion services and hub methods.
///
public interface IMagicOnionSerializerProvider
{
IMagicOnionSerializer Create(MethodType methodType, MethodInfo? methodInfo);
}
///
/// Provides a processing for message serialization.
///
public interface IMagicOnionSerializer
{
void Serialize<T>(IBufferWriter writer, in T? value);
T? Deserialize<T>(in ReadOnlySequence bytes);
}
public static class MagicOnionSerializerProvider
{
///
/// Gets or sets the <see cref="IMagicOnionSerializerProvider"/> to be used by default.
///
public static IMagicOnionSerializerProvider Default { get; set; } = MessagePackMagicOnionSerializerProvider.Default;
}Example code
The following code is a simple example of performing XOR encryption:
/* Detailed source-code truncated for AI context efficiency. */File: docs/docs/advanced/magiconionoptions.md
MagicOnionOptions
MagicOnionOptions can pass to MagicOnionEngine.BuildServerServiceDefinition(MagicOnionOptions option).
| Property | Description |
|---|---|
IList<MagicOnionFilterDescriptor> GlobalFilters |
Global MagicOnion filters. |
bool EnableCurrentContext |
Enable ServiceContext.Current option by AsyncLocal, default is false. |
IList<StreamingHubFilterDescriptor> Global StreamingHub filters. |
GlobalStreamingHubFilters |
IGroupRepositoryFactory DefaultGroupRepositoryFactory |
Default GroupRepository factory for StreamingHub, default is ``. |
bool IsReturnExceptionStackTraceInErrorDetail |
If true, MagicOnion handles exception ownself and send to message. If false, propagate to gRPC engine. Default is false. |
MessagePackSerializerOptions SerializerOptions |
MessagePack serialization resolver. Default is used ambient default(MessagePackSerializer.DefaultOptions). |
File: docs/docs/advanced/map-service-endpoints.md
Map Service Endpoints
Map specific types or services in an assembly
By default, MagicOnion server automatically searches for services contained in the assembly that is started and registers and exposes all found services. However, in some cases, you may want to expose only specific types or types contained in specific assemblies.
MapMagicOnionService method has an overload that maps only services contained in specific types or assemblies. By specifying this overload, you can manually register services.
app.MapMagicOnionService([ typeof(MyService), typeof(MyHub) ]);
app.MapMagicOnionService([ typeof(MyService).Assembly ]);Setting endpoint metadata
MapMagicOnionService method returns a builder that allows you to set ASP.NET Core endpoint metadata. For example, methods such as RequireHost and RequireAuthorization are available.
It is possible to provide different services on multiple ports as shown below.
// Consumers endpoints
app.MapMagicOnionService([typeof(GreeterService), typeof(ChatHub)]);
// Administration endpoints
app.MapMagicOnionService([typeof(AdministrationService)])
.RequireHost("*:6000")
.RequireAuthorization();File: docs/docs/advanced/memorypack.md
MemoryPack support
MagicOnion also supports MemoryPack as a message serializer. (preview)
dotnet add package MagicOnion.Serialization.MemoryPackSet MemoryPackMagicOnionSerializerProvider to MagicOnionSerializerProvider on the client and server to serialize using MemoryPack.
MagicOnionSerializerProvider.Default = MemoryPackMagicOnionSerializerProvider.Instance;
// or
await StreamingHubClient.ConnectAsync(channel, receiver, serializerProvider: MemoryPackMagicOnionSerializerProvider.Instance);
MagicOnionClient.Create(channel, MemoryPackMagicOnionSerializerProvider.Instance);If you want to use MagicOnion.Client.SourceGenerator, you need to specify Serializer = GenerateSerializerType.MemoryPack to the attribute. The generated code will use MemoryPack instead of MessagePack.
The application must also call MagicOnionMemoryPackFormatterProvider.RegisterFormatters() on startup.
File: docs/docs/advanced/raw-grpc.md
Raw gRPC APIs
MagicOnion can define and use primitive gRPC APIs (ClientStreaming, ServerStreaming, DuplexStreaming). Especially DuplexStreaming is used underlying StreamingHub. If there is no reason, we recommend using StreamingHub.
ServerStreaming
ServerStreaming is a streaming pattern where the server sends multiple values to the client. The client sends a single request, and the server can return multiple responses.
Server-side implementation
To implement ServerStreaming, use GetServerStreamingContext<T>() to get the streaming context.
public async Task<ServerStreamingResult<WeatherData>> GetWeatherUpdatesAsync(string location, int count)
{
var stream = GetServerStreamingContext<WeatherData>();
// Send weather data for the specified count
for (int i = 0; i < count; i++)
{
var weatherData = new WeatherData
{
Temperature = Random.Shared.Next(-10, 35),
Humidity = Random.Shared.Next(30, 90),
Timestamp = DateTime.UtcNow
};
await stream.WriteAsync(weatherData);
// Wait for 1 second (simulating real-time data)
await Task.Delay(1000);
}
return stream.Result();
}Client-side implementation
On the client side, use ResponseStream.ReadAllAsync() to receive all values sent from the server.
var client = MagicOnionClient.Create(channel);
var stream = await client.GetWeatherUpdatesAsync("Tokyo", 5);
await foreach (var weatherData in stream.ResponseStream.ReadAllAsync())
{
Console.WriteLine($"Temperature: {weatherData.Temperature}ยฐC, Humidity: {weatherData.Humidity}%, Time: {weatherData.Timestamp}");
}Use cases
ServerStreaming is useful in scenarios such as:
- Real-time data feeds (stock prices, sensor data, etc.)
- Sending large amounts of data in chunks
- Progress update notifications
- Log streaming
ClientStreaming
ClientStreaming is a streaming pattern where the client sends multiple values to the server. The client sends multiple messages, and the server returns a single response.
Server-side implementation
To implement ClientStreaming, use GetClientStreamingContext() to get the streaming context.
public async Task<ClientStreamingResult<SensorData, AnalysisResult>> AnalyzeSensorDataAsync()
{
var stream = GetClientStreamingContext<SensorData, AnalysisResult>();
var allData = new List<SensorData>();
// Receive all data from the client
await foreach (var data in stream.ReadAllAsync())
{
Logger.Debug($"Received sensor data: {data.Value} at {data.Timestamp}");
allData.Add(data);
}
// Analyze the received data
var result = new AnalysisResult
{
Average = allData.Average(d => d.Value),
Max = allData.Max(d => d.Value),
Min = allData.Min(d => d.Value),
Count = allData.Count
};
return stream.Result(result);
}Client-side implementation
On the client side, use RequestStream.WriteAsync() to send multiple values and call CompleteAsync() at the end to complete the stream.
var client = MagicOnionClient.Create(channel);
var stream = await client.AnalyzeSensorDataAsync();
// Send sensor data
for (int i = 0; i < 10; i++)
{
var sensorData = new SensorData
{
Value = Random.Shared.NextDouble() * 100,
Timestamp = DateTime.UtcNow
};
await stream.RequestStream.WriteAsync(sensorData);
await Task.Delay(100); // Simulate sensor reading interval
}
// Complete the stream
await stream.RequestStream.CompleteAsync();
// Receive analysis result from server
var result = await stream.ResponseAsync;
Console.WriteLine($"Average: {result.Average}, Max: {result.Max}, Min: {result.Min}, Count: {result.Count}");Use cases
ClientStreaming is useful in scenarios such as:
- File uploads (in chunks)
- Batch data submission
- Sensor data collection
- Bulk log submission
DuplexStreaming
DuplexStreaming is a bidirectional streaming pattern where both client and server can send and receive multiple messages simultaneously. This is the underlying technology for MagicOnion's StreamingHub.
Server-side implementation
To implement DuplexStreaming, use GetDuplexStreamingContext() to get the streaming context.
public async Task<DuplexStreamingResult<ChatMessage, ChatMessage>> ChatAsync()
{
var stream = GetDuplexStreamingContext<ChatMessage, ChatMessage>();
// Task to receive messages from the client
var receiveTask = Task.Run(async () =>
{
await foreach (var message in stream.ReadAllAsync())
{
Logger.Debug($"Received: {message.User}: {message.Content}");
// Echo back (return received message with server response)
var response = new ChatMessage
{
User = "Server",
Content = $"Echo: {message.Content}",
Timestamp = DateTime.UtcNow
};
await stream.WriteAsync(response);
}
});
// Send welcome message
await stream.WriteAsync(new ChatMessage
{
User = "Server",
Content = "Welcome to the chat!",
Timestamp = DateTime.UtcNow
});
await receiveTask;
return stream.Result();
}Client-side implementation
On the client side, handle sending and receiving in parallel.
var client = MagicOnionClient.Create(channel);
var stream = await client.ChatAsync();
// Task to receive messages from the server
var receiveTask = Task.Run(async () =>
{
await foreach (var message in stream.ResponseStream.ReadAllAsync())
{
Console.WriteLine($"[{message.Timestamp}] {message.User}: {message.Content}");
}
});
// Send user input
while (true)
{
var input = Console.ReadLine();
if (input == "exit") break;
var message = new ChatMessage
{
User = "Client",
Content = input,
Timestamp = DateTime.UtcNow
};
await stream.RequestStream.WriteAsync(message);
}
// Complete the stream
await stream.RequestStream.CompleteAsync();
await receiveTask;Use cases
DuplexStreaming is useful in scenarios such as:
- Real-time chat
- Bidirectional game communication
- Collaboration tools
- Real-time monitoring systems
Notes
Consider StreamingHub: When you need DuplexStreaming, StreamingHub is often more suitable in many cases. StreamingHub provides a higher-level API built on top of DuplexStreaming.
Error handling: Exceptions during streaming need to be handled properly. Implement measures for connection drops and timeouts.
Resource management: Long-running streaming connections should manage resources properly and set timeouts as needed.
Concurrent processing: In DuplexStreaming, sending and receiving happen concurrently, so pay attention to thread safety.
Sample Code
Server Sample
/* Detailed source-code truncated for AI context efficiency. */Client sample
static async Task ClientStreamRun(IMyFirstService client)
{
var stream = await client.ClientStreamingSampleAsync();
for (int i = 0; i < 3; i++)
{
await stream.RequestStream.WriteAsync(i);
}
await stream.RequestStream.CompleteAsync();
var response = await stream.ResponseAsync;
Console.WriteLine("Response:" + response);
}
static async Task ServerStreamRun(IMyFirstService client)
{
var stream = await client.ServerStreamingSampleAsync(10, 20, 3);
await foreach (var x in stream.ResponseStream.ReadAllAsync())
{
Console.WriteLine("ServerStream Response:" + x);
}
}
static async Task DuplexStreamRun(IMyFirstService client)
{
var stream = await client.DuplexStreamingSampleAsync();
var count = 0;
await foreach (var x in stream.ResponseStream.ReadAllAsync())
{
Console.WriteLine("DuplexStream Response:" + x);
await stream.RequestStream.WriteAsync(count++);
if (x == "finish")
{
await stream.RequestStream.CompleteAsync();
}
}
}File: docs/docs/best-practices/hub-context-pattern.md
Hub-Context pattern
Overview
There is a pattern of implementing a game server using MagicOnion called the Hub-Context pattern.
In this pattern, a class called Context is prepared to hold the state, and the game logic refers to the state of the Context to manage the game state without holding the state in StreamingHub itself.
This pattern has the following characteristics:
- StreamingHub holds the minimum necessary state
- Context holds the game state
- Context has a command queue that receives commands from clients
- Clients add commands to the command queue of Context via StreamingHub
- A loop is executed to update the game state by referring to the Context from within the loop
- Example: Execute commands added to the command queue and update the state of the Context
- Example: Update the state of the Context at regular intervals
Benefits
The benefits of this pattern are that "management of the start and end of the game state is independent of StreamingHub" and "Minimizing consideration of concurrent execution".
Management of the start and end of the game state is independent of StreamingHub
For example, when implementing a battle server for a battle royale, players need to enter the "battlefield" after the match is made. In this case, the problem arises as to who should create the battlefield. One common solution is to create the battlefield in the first player's processing when connecting to StreamingHub, but there are several considerations to be made, such as when multiple players connect at the same time or when a player is disconnected.
When the match is made, the battlefield is created in the server or between servers, and the player only needs to join the field. This makes it simple and easy to understand. In this example, the "battlefield" is the Context.
In addition, StreamingHub is affected by disconnections and reconnections of players, as well as the network and client conditions of the player, so it is safer to manage the game state independently.
Minimizing consideration of concurrent execution
This pattern has a "command queue" that accepts commands from clients and updates the game state by consuming it. By executing the command queue in a loop that advances the game process, the player's operations are executed and the game state is changed. The command queue is implemented with .NET's ConcurrentQueue and can safely add commands from multiple clients. The consumption of the command queue advances the game process from a single loop, so commands are never executed in parallel. This ensures that the Context is always updated by a specific thread and limits the scope of locking.
:::warning
Even if you use a command queue to update the game state from a single thread, you need to perform appropriate locking if you need to refer to the state of the Context directly from StreamingHub.
:::
Implementation example
In this section, we will explain a simple implementation example of the Hub-Context pattern.
In this pattern, the following elements need to be implemented:
GameContext: A class that holds the game state and command queueICommandand*Commandclasses: Command interface that represents game operations and its implementation classesGameLoop: A class that executes a loop to update the game state by referring to theGameContextGameContextRepository: A class that holds theGameContextand theTaskof the loopGameHub: A StreamingHub that accepts operations from clients and adds commands to theGameContextcommand queue
:::warning
This implementation example is written with the minimum code to explain the concept. Please implement validation, error handling, termination processing, cancellation, and performance considerations according to your project.
:::
First, define the GameContext class that holds the game state and command queue.
GameContext holds an ID to uniquely identify it, a flag indicating whether it is completed, and a ConcurrentQueue to hold commands from users. The ICommand interface that represents the command is defined in the next section.
public class GameContext
{
public Guid Id { get; } = Guid.NewGuid();
public bool IsCompleted { get; set; }
public ConcurrentQueue CommandQueue { get; } = new();
}Next, define and implement the command. The command is defined as an ICommand interface. The command has an Execute method that references and updates the game state using GameContext.
The command implementation is defined as a class that implements this interface. In this example, we define a MoveCommand that moves and an AttackCommand that attacks. Commands have parameters (e.g., the ID of the target player, the destination of the move, the opponent of the attack, etc.), and the Execute method uses these values to perform the operation.
public interface ICommand
{
void Execute(GameContext context);
}
public class MoveCommand(Guid playerId, int x, int y) : ICommand
{
public void Execute(GameContext context)
{
// Update game state in GameContext ...
}
}
public class AttackCommand(Guid playerId, Guid targetId) : ICommand
{
public void Execute(GameContext context)
{
// Update game state in GameContext ...
}
}Next, define the mechanism to execute the game loop, which is the mechanism to execute the game loop next. This class executes a loop that updates the game state by referring to GameContext.
The loop is defined as an asynchronous method that takes GameContext as an argument. This loop continues until the IsCompleted flag of GameContext becomes true and executes commands from the CommandQueue. In this example, the loop is executed every 100ms (10fps) using Task.Delay.
public class GameLoop
{
public static async Task RunLoopAsync(GameContext ctx)
{
while (!ctx.IsCompleted)
{
// Do work...
// Consume all commands in the queue.
while (ctx.CommandQueue.TryDequeue(out var command))
{
command.Execute(ctx);
}
// Do work...
// Wait for next frame.
await Task.Delay(TimeSpan.FromMilliseconds(100));
}
}
}The loop in this example only updates the state by consuming the command queue, but in actual games, the server may execute processing based on the passage of time, etc.
Next, define the GameContextRepository that creates and holds the GameContext. This class creates a GameContext and holds the Task of the loop started by the GameLoop. In the CreateAndRun method, a new GameContext is created, the loop is started using the Context, and the Context is returned. The TryGet method gets the GameContext with the specified ID, and the Remove method removes the GameContext with the specified ID.
public class GameContextRepository
{
private readonly ConcurrentDictionary<Guid, (GameContext Context, Task LoopTask)> _contexts = new();
public GameContext CreateAndRun()
{
var context = new GameContext();
var loopTask = GameLoop.RunLoopAsync(context);
_contexts[context.Id] = (context, loopTask);
return context;
}
public bool TryGet(Guid id, out GameContext? context)
{
if (_contexts.TryGetValue(id, out var contextAndLoopTask))
{
context = contextAndLoopTask.Context;
return true;
}
context = null;
return false;
}
public void Remove(Guid id)
{
_contexts.Remove(id, out _);
}
}This GameContextRepository is registered with the DI container by builder.Services.AddSingleton<GameContextRepository>() so that it can be used by other classes such as StreamingHub.
Next, define the GameHub that receives input from the player and adds it to the CommandQueue of GameContext. This class defines GameHub that is a StreamingHub that receives input from the player and adds commands to the CommandQueue of GameContext. The important point here is that the implementation of the Hub method is centered around adding commands to the command queue, and the StreamingHub does not hold more operations and state than necessary.
public interface IGameHub : IStreamingHub
{
ValueTask AttackAsync(Guid targetId);
ValueTask MoveAsync(int x, int y);
}
public interface IGameHubReceiver
{
void OnAttack(Guid playerId, Guid targetId);
void OnMove(Guid playerId, int x, int y);
}
public class GameHub(GameContextRepository gameContextRepository) : StreamingHubBase
{
public ValueTask AttackAsync(Guid targetId)
{
if (gameContextRepository.TryGet(Context.ContextId, out var context))
{
context.CommandQueue.Enqueue(new AttackCommand(Context.ContextId, targetId));
}
return default;
}
public ValueTask MoveAsync(int x, int y)
{
if (gameContextRepository.TryGet(Context.ContextId, out var context))
{
context.CommandQueue.Enqueue(new MoveCommand(Context.ContextId, x, y));
}
return default;
}
}The example above retrieves the Context each time for simplicity, but in cases where there is a join process, it is also possible to hold a reference to the Context in StreamingHub and use it.
Creation of GameContext and the timing to start the loop depend on the game flow, and the actual timing of creation depends on the game specifications. For example, it may be created when the match is completed. In any case, the server can manage the lifecycle of the Context independently of StreamingHub by creating and deleting it through GameContextRepository.
The following is an example of implementing internal API endpoints to start and end the game.
app.MapPost("/internal/create", (GameContextRepository repository) =>
{
// Create new GameContext and write information to the database.
var context = repository.CreateAndRun();
return context.Id;
});
app.MapPost("/internal/complete", (GameContextRepository repository, Guid id) =>
{
// Do something to complete the game
repository.Remove(id);
return Ok();
});At the next section, to call the client from the game logic (processing in commands or loops), you need to be able to handle groups so that you can call the client from the game logic. To achieve this, you need to hold groups in GameContext.
Notifying clients using groups
So far, we have explained the implementation of input from the client and its processing. This section will explain how to handle groups to notify clients.
MagicOnion provides groups associated with StreamingHub, but you can manage groups in the application logic using the Application-managed groups feature. This feature is well-suited to the Hub-Context pattern, and by managing groups in Context, you can manage clients independently of StreamingHub.
In this example, we define a group as a collection of receivers of StreamingHub, and create and delete it when creating GameContext and deleting Context.
The IMulticastGroupProvider for creating groups is registered with the DI container, so it can be used by the constructor of GameContextRepository. In the implementation example, the group is defined as IMulticastSyncGroup<Guid, IGameHubReceiver> to distinguish clients based on the connection ID.
using Cysharp.Runtime.Multicast;
public class GameContext : IDisposable
{
public Guid Id { get; }
public bool IsCompleted { get; set; }
public ConcurrentQueue CommandQueue { get; } = new();
public IMulticastSyncGroup<Guid, IGameHubReceiver> Group { get; }
public GameContext(IMulticastGroupProvider groupProvider)
{
Id = Guid.NewGuid();
Group = groupProvider.GetOrAddSynchronousGroup<Guid, IGameHubReceiver>($"Game/{Id}");
}
public void Dispose()
{
Group.Dispose();
}
}
public class GameContextRepository(IMulticastGroupProvider groupProvider)
{
...
public GameContext CreateAndRun()
{
var context = new GameContext(groupProvider);
var loopTask = GameLoop.RunLoopAsync(context);
_contexts[context.Id] = (context, loopTask);
return context;
}
public void Remove(Guid id)
{
if (_contexts.Remove(id, out var contextAndTask))
{
contextAndTask.Context.Dispose();
}
}
...
}:::warning
When creating a group manually, be sure to call Dispose when it is no longer needed. If you do not call Dispose to delete the group, the group will remain in the provider until it is deleted, causing a memory leak.
:::
The group can be registered as a member of IGameHubReceiver, so you can directly register the Client property (proxy to the client) of StreamingHub as a member of IGameHubReceiver.
To register a client to a group, you need to register the client to the group when the connection is established and remove it when the connection is disconnected.
public class GameHub(GameContextRepository gameContextRepository) : StreamingHubBase
{
public override ValueTask OnConnected()
{
if (gameContextRepository.TryGet(Context.ContextId, out var context))
{
context.Group.Add(Context.ConnectionId, Client);
}
return default;
}
public override ValueTask OnDisconnected()
{
if (gameContextRepository.TryGet(Context.ContextId, out var context))
{
context.Group.Remove(Context.ConnectionId);
}
return default;
}
...
}:::tip
In this implementation example, the ConnectionId (StreamingHub's connection ID) is used as the key to distinguish clients registered in the group, but consider using other keys. When using the connection ID, there is a problem that the connection ID changes when reconnected. This can be avoided by using the ID of an authenticated player, for example.
:::
After registering the client to the group, you can send messages to the client through the group. For example, you can send messages to the client from commands or server processing loops.
public class MoveCommand(Guid playerId, int x, int y) : ICommand
{
public void Execute(GameContext context)
{
// Update game state in GameContext ...
context.Group.All.OnMove(playerId, x, y);
}
}
public class AttackCommand(Guid playerId, Guid targetId) : ICommand
{
public void Execute(GameContext context)
{
// Update game state in GameContext ...
context.Group.All.OnAttack(playerId, targetId);
}
}For more information on groups, see Groups and Application-managed groups.
More effective game loops
The implementation example of the game loop above uses Task.Delay to execute the loop at regular intervals, but this is not suitable for general game implementations.
So, we recommend using LogicLooper library provided by Cysharp. This library provides a mechanism to execute loops at regular intervals like Unity's Update method. By using this library, you can implement more effective game loops.
public class GameLoop
{
public static Task RunLoopAsync(GameContext context)
{
return LogicLooperPool.Shared.RegisterActionAsync(() =>
{
// Do work...
// Consume all commands in the queue.
while (context.CommandQueue.TryDequeue(out var command))
{
command.Execute(context);
}
// Do work...
return !context.IsCompleted;
});
}
}File: docs/docs/filter/client-filter.md
Client Filter
Client Filter is a powerful feature to hook before-after service method invocation. Filter like gRPC client interceptor but more familiar programming model like HttpClient handlers or ASP.NET Core middlewares.
:::info
Currently, the feature is only supported for Unary.
:::
Implementation and Usage
To implement a filter, inherit from IClientFilter and implement the SendAsync method. This is the same programming model as HttpClient's HttpMessageHandler or ASP.NET Core middleware.
In the filter, you call the next delegate to call the next filter or the actual method. You can skip calling next or catch exceptions from calling next to add exception handling.
public class DemoFilter : IClientFilter
{
public async ValueTask<ResponseContext> SendAsync(RequestContext context, Func<RequestContext, ValueTask<ResponseContext>> next)
{
try
{
// Before Request: context.MethodPath/CallOptions/Items
// Console.WriteLine("Request Begin:" + context.MethodPath);
// ...
var response = await next(context); /* Call next filter or method body */
// After Request: response.GetStatus/GetTrailers/GetResponseAs<T>
// var result = await response.GetResponseAs<T>();
// var status = response.GetStatus();
// ...
return response;
}
catch (RpcException ex)
{
/* gRPC Exception */
throw;
}
catch (Exception ex)
{
/* Other Exception */
throw;
}
finally
{
/* Clean-up */
}
}
}You can end the processing in the filter without calling the method by creating a new instance of ResponseContext. This allows you to implement a mock-like implementation.
:::warning
You can change the request header by getting and modifying CallOptions from RequestContext. However, CallOptions is holded per MagicOnionClient instance, so be careful not to add duplicate headers for each request.
:::
To use the implemented filter in the client, specify an array of IClientFilter in the arguments of MagicOnionClient.Create.
var client = MagicOnionClient.Create(channel, new IClientFilter[]
{
new DemoFilter(),
new LoggingFilter(),
new AppendHeaderFilter(),
new RetryFilter()
});Sample Implementation
The following are examples of adding headers, outputting request logs, and retrying.
/* Detailed source-code truncated for AI context efficiency. */File: docs/docs/filter/dependency-injection.md
Dependency Injections
:::info
This feature and document is applies to the server-side only.
:::
MagicOnion filters supports Dependency Injection. There are two ways to activate a filter by using FromTypeFilter, FromServiceFitler or by using IMagicOnionFilterFactory.
The following is an example of how to use FromTypeFilter, FromServiceFitler.
public class MyServiceFilterAttribute : MagicOnionFilterAttribute
{
private readonly ILogger _logger;
// the `logger` parameter will be injected at instantiating.
public MyServiceFilterAttribute(ILogger<MyServiceFilterAttribute> logger)
{
_logger = logger;
}
public override async ValueTask Invoke(ServiceContext context, Func<ServiceContext, ValueTask> next)
{
_logger.LogInformation($"MyServiceFilter Begin: {context.Path}");
await next(context);
_logger.LogInformation($"MyServiceFilter End: {context.Path}");
}
}Register filters using attributes with constructor injection(you can use [FromTypeFilter] and [FromServiceFilter]).
[FromTypeFilter(typeof(MyFilterAttribute))]
public class MyService : ServiceBase, IMyService
{
// The filter will instantiate from type.
[FromTypeFilter(typeof(MySecondFilterAttribute))]
public UnaryResult Foo()
{
return UnaryResult(0);
}
// The filter will instantiate from type with some arguments. if the arguments are missing, it will be obtained from `IServiceProvider`
[FromTypeFilter(typeof(MyThirdFilterAttribute), Arguments = new object[] { "foo", 987654 })]
public UnaryResult Bar()
{
return UnaryResult(0);
}
// The filter instance will be provided via `IServiceProvider`.
[FromServiceFilter(typeof(MyFourthFilterAttribute))]
public UnaryResult Baz()
{
return UnaryResult(0);
}
}The following is an example of how to use IMagicOnionFilterFactory<T>.
This is a clean way of writing when using DI while still having parameters for the attributes.
public class MyServiceFilterAttribute : Attribute, IMagicOnionFilterFactory, IMagicOnionOrderedFilter
{
readonly string label;
public int Order { get; set; } = int.MaxValue;
public MyServiceFilterAttribute(string label)
{
this.label = label;
}
public IMagicOnionServiceFilter CreateInstance(IServiceProvider serviceProvider)
=> new MyServiceFilter(serviceProvider.GetRequiredService>());
class MyServiceFilter : IMagicOnionServiceFilter
{
readonly string label;
readonly ILogger logger;
public MyServiceFilter(string label, ILogger<MyServiceFilterAttribute> logger)
{
this.label = label;
this.logger = logger;
}
public async ValueTask Invoke(ServiceContext context, Func<ServiceContext, ValueTask> next)
{
logger.LogInformation($"[{label}] MyServiceFilter Begin: {context.Path}");
await next(context);
logger.LogInformation($"[{label}] MyServiceFilter End: {context.Path}");
}
}
}[MyServiceFilter("Class")]
public class MyService : ServiceBase, IMyService
{
[MyServiceFilter("Method")]
public UnaryResult Foo()
{
return UnaryResult(0);
}
}File: docs/docs/filter/extensibility.md
Filter extensibility
:::info
This feature and document is applies to the server-side only.
:::
Implementing filters by inheriting from MagicOnionFilterAttribute or StreamingHubFilterAttribute is not the only way to implement filters. You can also implement filters using filter interfaces. These interfaces provide a programming model similar to ASP.NET Core MVC filters.
You can use the following interfaces to implement filters:
IMagicOnionFilterFactory<T>: Factory interface to generate filter instancesIMagicOnionOrderedFilter: Interface to specify the order of filtersIMagicOnionServiceFilter: Interface for Unary service filtersIStreamingHubFilter: Interface for StreamingHub filters
Implementing filter using filter interfaces
You can implement filters by implementing the IMagicOnionServiceFilter and IStreamingHubFilter interfaces. MagicOnionFilterAttribute and StreamingHubFilterAttribute provide implementations to make it easier to use these interfaces.
For example, by implementing these two interfaces, you can implement filters that support both Unary services and StreamingHub.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
class SampleFilterAttribute : IMagicOnionServiceFilter, IStreamingHubFilter, Attribute
{
public async ValueTask Invoke(ServiceContext context, Func<ServiceContext, ValueTask> next)
{
...
}
public async ValueTask Invoke(StreamingHubContext context, Func<StreamingHubContext, ValueTask> next)
{
...
}
}Implementing filter using filter factory
IMagicOnionFilterFactory<T> interface provides a factory method to generate filters. By using this factory, you can flexibly generate filter instances using DI while using the arguments of the filter attribute.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
class SampleFilterAttribute : Attribute, IMagicOnionFilterFactory
{
public string Name { get; set; }
public SampleFilterAttribute(string name)
{
Name = name;
}
public IMagicOnionServiceFilter CreateInstance(IServiceProvider serviceProvider)
{
return new FilterImpl(serviceProvider.GetRequiredService>());
}
class FilterImpl : IMagicOnionServiceFilter, IMagicOnionOrderedFilter
{
readonly string name;
readonly ILogger logger;
public int Order { get; set; } = int.MaxValue;
public FilterImpl(string name, ILogger logger)
{
this.name = name;
this.logger = logger;
}
public async ValueTask Invoke(ServiceContext context, Func<ServiceContext, ValueTask> next)
{
logger.LogInformation($"SampleFilter[{name}] Begin: {context.Path}");
await next(context);
logger.LogInformation($"SampleFilter[{name}] End: {context.Path}");
}
}
}
[SampleFilter("MyService")]
class MyService : ServiceBase
{
...
}--- METRICS ---
- Files Extracted: 11
- Estimated Token Budget: ~13007 tokens
- Recency Window: Active (< 180 days)
- Canonical Reference: https://codewiki.google/github.com/Cysharp/MagicOnion