Skip to content

DotBoxD Unity Integration Guide

A comprehensive guide to integrating DotBoxD into your Unity project for type-safe, high-performance client-server communication.

  1. Overview
  2. Prerequisites
  3. Project Setup
  4. Defining Service Contracts
  5. Server Implementation
  6. Unity Client Setup
  7. Connection Management
  8. Error Handling
  9. IL2CPP and AOT Considerations
  10. Best Practices
  11. Troubleshooting
  12. Advanced Topics

DotBoxD is a plugin system for .NET hosts; its Services (RPC) surface is transport-agnostic and designed with Unity compatibility as a primary goal. It uses compile-time source generation to create type-safe proxies and dispatchers, avoiding the runtime reflection that causes issues with IL2CPP builds.

AOT boundary: the libraries target netstandard2.1 and service proxies/dispatchers are generated. MessagePack’s default and contractless resolvers may still use reflection. IL2CPP deployments must run MessagePack’s formatter generator for every DTO, compose the generated resolver statically, and pass it to MessagePackRpcSerializer.CreateWithResolver. The repository’s NativeAOT smoke validates the .NET AOT path; Unity projects must keep an IL2CPP batch build in their own supported matrix.

  • IL2CPP-capable with generated DTO formatters: no dynamic resolver fallback
  • Source Generators: Compile-time proxy generation
  • Shared Contracts: Same C# interfaces on client and server
  • MessagePack: Fast binary serialization with Unity support
  • Transport Agnostic: TCP, WebSocket, or custom transports
┌─────────────────────────────────────────────────────────────────┐
│ Shared Library │
│ [RpcService] IGameService + Models (PlayerId, PlayerState) │
└─────────────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ Unity Client │ │ .NET Server │
│ ┌───────────────────────┐ │ │ ┌───────────────────────┐ │
│ │ GameServiceProxy (gen)│ │ │ │GameServiceDispatcher │ │
│ └───────────────────────┘ │ │ │ (generated) │ │
│ ┌───────────────────────┐ │ │ └───────────────────────┘ │
│ │ RpcPeer │ │ │ ┌───────────────────────┐ │
│ └───────────────────────┘ │ │ │ RpcHost │ │
│ ┌───────────────────────┐ │ │ └───────────────────────┘ │
│ │ TcpTransport │◄─┼───┼─►│ TcpServerTransport │ │
│ └───────────────────────┘ │ │ └───────────────────────┘ │
└─────────────────────────────┘ └─────────────────────────────┘

  • Unity 2021.3 LTS or newer (for .NET Standard 2.1 support)
  • Scripting Backend: Mono or IL2CPP
  • API Compatibility Level: .NET Standard 2.1
  • .NET 10.0 or newer for the server and Pushdown convenience host used in this guide
  • MessagePack 3.1.7 (the repository-tested version)

Create a solution with shared contracts accessible to both Unity and server:

YourGame/
├── src/
│ ├── YourGame.Shared/ # Shared contracts (netstandard2.1)
│ │ ├── IGameService.cs
│ │ └── Models.cs
│ └── YourGame.Server/ # Server (net8.0)
│ ├── GameService.cs
│ └── Program.cs
├── unity/
│ └── YourGameClient/ # Unity project
│ └── Assets/
│ ├── Plugins/
│ │ ├── DotBoxD.Services.dll
│ │ ├── DotBoxD.Transports.Tcp.dll
│ │ ├── DotBoxD.Codecs.MessagePack.dll
│ │ ├── YourGame.Shared.dll
│ │ └── MessagePack.dll
│ └── Scripts/
│ └── Networking/
└── YourGame.sln

Create YourGame.Shared.csproj:

<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MessagePack" Version="3.1.7" />
</ItemGroup>
<!-- Reference source generator for proxy/dispatcher generation -->
<ItemGroup>
<ProjectReference Include="..\DotBoxD.Services\DotBoxD.Services.csproj" />
<ProjectReference Include="..\DotBoxD.Services.SourceGenerator\DotBoxD.Services.SourceGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>

Create a build script to copy DLLs to Unity:

build-for-unity.sh
#!/bin/bash
UNITY_PLUGINS="unity/YourGameClient/Assets/Plugins"
dotnet build src/YourGame.Shared/YourGame.Shared.csproj -c Release
# Copy DotBoxD libraries
cp src/DotBoxD.Services/bin/Release/netstandard2.1/DotBoxD.Services.dll "$UNITY_PLUGINS/"
cp src/DotBoxD.Transports.Tcp/bin/Release/netstandard2.1/DotBoxD.Transports.Tcp.dll "$UNITY_PLUGINS/"
cp src/DotBoxD.Codecs.MessagePack/bin/Release/netstandard2.1/DotBoxD.Codecs.MessagePack.dll "$UNITY_PLUGINS/"
# Copy shared library
cp src/YourGame.Shared/bin/Release/netstandard2.1/YourGame.Shared.dll "$UNITY_PLUGINS/"
# Copy MessagePack (get from NuGet cache)
cp ~/.nuget/packages/messagepack/3.1.7/lib/netstandard2.0/MessagePack.dll "$UNITY_PLUGINS/"
echo "DLLs copied to Unity"

YourGame.Shared/IGameService.cs
using DotBoxD.Services.Attributes;
namespace YourGame.Shared;
[RpcService]
public interface IGameService
{
// Simple request/response
Task<ServerInfo> GetServerInfoAsync(CancellationToken ct = default);
// With parameters
Task<PlayerState> GetPlayerAsync(string playerId, CancellationToken ct = default);
// With complex request object
Task<JoinResult> JoinGameAsync(JoinRequest request, CancellationToken ct = default);
// Fire-and-forget style (still awaitable)
Task SendInputAsync(PlayerInput input, CancellationToken ct = default);
}

Models must be serializable by MessagePack. Use attributes for best compatibility:

YourGame.Shared/Models.cs
using MessagePack;
namespace YourGame.Shared;
[MessagePackObject]
public class ServerInfo
{
[Key(0)] public string ServerName { get; set; } = "";
[Key(1)] public int PlayerCount { get; set; }
[Key(2)] public int MaxPlayers { get; set; }
[Key(3)] public string Version { get; set; } = "";
}
[MessagePackObject]
public class PlayerState
{
[Key(0)] public string PlayerId { get; set; } = "";
[Key(1)] public string DisplayName { get; set; } = "";
[Key(2)] public float PositionX { get; set; }
[Key(3)] public float PositionY { get; set; }
[Key(4)] public float PositionZ { get; set; }
[Key(5)] public float RotationY { get; set; }
[Key(6)] public int Health { get; set; }
[Key(7)] public int MaxHealth { get; set; }
}
[MessagePackObject]
public class JoinRequest
{
[Key(0)] public string DisplayName { get; set; } = "";
[Key(1)] public string AuthToken { get; set; } = "";
}
[MessagePackObject]
public class JoinResult
{
[Key(0)] public bool Success { get; set; }
[Key(1)] public string? PlayerId { get; set; }
[Key(2)] public string? ErrorMessage { get; set; }
[Key(3)] public PlayerState? InitialState { get; set; }
}
[MessagePackObject]
public class PlayerInput
{
[Key(0)] public float MoveX { get; set; }
[Key(1)] public float MoveY { get; set; }
[Key(2)] public bool Jump { get; set; }
[Key(3)] public bool Fire { get; set; }
[Key(4)] public float AimYaw { get; set; }
[Key(5)] public float AimPitch { get; set; }
[Key(6)] public long Timestamp { get; set; }
}
[RpcService(Name = "Game")]
public interface IGameService
{
[RpcMethod(Name = "Info")]
Task<ServerInfo> GetServerInfoAsync(CancellationToken ct = default);
}

YourGame.Server/GameService.cs
using YourGame.Shared;
namespace YourGame.Server;
public class GameService : IGameService
{
private readonly GameState _gameState;
public GameService(GameState gameState)
{
_gameState = gameState;
}
public Task<ServerInfo> GetServerInfoAsync(CancellationToken ct = default)
{
return Task.FromResult(new ServerInfo
{
ServerName = _gameState.ServerName,
PlayerCount = _gameState.Players.Count,
MaxPlayers = _gameState.MaxPlayers,
Version = "1.0.0"
});
}
public Task<PlayerState> GetPlayerAsync(string playerId, CancellationToken ct = default)
{
if (!_gameState.Players.TryGetValue(playerId, out var player))
{
throw new KeyNotFoundException($"Player '{playerId}' not found");
}
return Task.FromResult(player.ToPlayerState());
}
public Task<JoinResult> JoinGameAsync(JoinRequest request, CancellationToken ct = default)
{
// Validate auth token
if (!ValidateToken(request.AuthToken))
{
return Task.FromResult(new JoinResult
{
Success = false,
ErrorMessage = "Invalid authentication"
});
}
// Create player
var player = _gameState.CreatePlayer(request.DisplayName);
return Task.FromResult(new JoinResult
{
Success = true,
PlayerId = player.Id,
InitialState = player.ToPlayerState()
});
}
public Task SendInputAsync(PlayerInput input, CancellationToken ct = default)
{
_gameState.ProcessInput(input);
return Task.CompletedTask;
}
private bool ValidateToken(string token) => !string.IsNullOrEmpty(token);
}
YourGame.Server/Program.cs
using YourGame.Server;
using YourGame.Shared;
using DotBoxD.Services;
using DotBoxD.Services.Generated;
using DotBoxD.Codecs.MessagePack;
using DotBoxD.Transports.Tcp;
const int Port = 7777;
Console.WriteLine($"Starting game server on port {Port}...");
var gameState = new GameState();
var gameService = new GameService(gameState);
// RpcHost listens and turns every accepted connection into a peer. ForEachPeer
// runs once per connection to provide the services that peer exposes.
await using var host = RpcHost
.Listen(new TcpServerTransport(Port), new MessagePackRpcSerializer())
.ForEachPeer(peer => peer.ProvideGameService(gameService)); // Generated extension method
await host.StartAsync();
Console.WriteLine("Server started. Press Ctrl+C to stop.");
var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
try
{
await Task.Delay(Timeout.Infinite, cts.Token);
}
catch (OperationCanceledException) { }
await host.StopAsync();
Console.WriteLine("Server stopped.");

Assets/Scripts/Networking/NetworkManager.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
using DotBoxD.Services;
using DotBoxD.Services.Generated;
using DotBoxD.Codecs.MessagePack;
using DotBoxD.Transports.Tcp;
using YourGame.Shared;
public class NetworkManager : MonoBehaviour
{
public static NetworkManager Instance { get; private set; }
[Header("Connection Settings")]
[SerializeField] private string serverHost = "localhost";
[SerializeField] private int serverPort = 7777;
[SerializeField] private float connectionTimeout = 10f;
public IGameService GameService { get; private set; }
public bool IsConnected => _peer?.IsConnected ?? false;
public event Action OnConnected;
public event Action OnDisconnected;
public event Action<string> OnConnectionError;
private RpcPeer _peer;
private TcpTransport _transport;
private CancellationTokenSource _cts;
private void Awake()
{
if (Instance != null)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
}
private void OnDestroy()
{
Disconnect();
}
public async Task<bool> ConnectAsync(string host = null, int? port = null)
{
if (IsConnected)
{
Debug.LogWarning("Already connected");
return true;
}
host ??= serverHost;
port ??= serverPort;
_cts = new CancellationTokenSource();
try
{
Debug.Log($"Connecting to {host}:{port}...");
_transport = new TcpTransport(host, port.Value);
var serializer = new MessagePackRpcSerializer();
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token);
connectCts.CancelAfter(TimeSpan.FromSeconds(connectionTimeout));
// Establish the underlying connection, then layer a peer over it.
await _transport.ConnectAsync(connectCts.Token);
_peer = RpcPeer
.Over(_transport.Connection!, serializer, new RpcPeerOptions
{
RequestTimeout = TimeSpan.FromSeconds(connectionTimeout),
// This client only calls out; it never serves inbound calls.
RejectInboundCalls = true
})
.Start();
// Create the service proxy
GameService = _peer.GetGameService();
Debug.Log("Connected to server!");
OnConnected?.Invoke();
return true;
}
catch (OperationCanceledException)
{
Debug.LogWarning("Connection cancelled");
return false;
}
catch (Exception ex)
{
Debug.LogError($"Connection failed: {ex.Message}");
OnConnectionError?.Invoke(ex.Message);
return false;
}
}
public void Disconnect()
{
if (_peer == null) return;
_cts?.Cancel();
try
{
// Disposing the peer stops its read loop; dispose the transport to close the socket.
_peer.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(2));
_transport?.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(2));
}
catch { }
_peer = null;
_transport = null;
GameService = null;
_cts?.Dispose();
_cts = null;
Debug.Log("Disconnected from server");
OnDisconnected?.Invoke();
}
// Helper for fire-and-forget calls with error logging
public async void SendAsync(Func<Task> action)
{
if (!IsConnected)
{
Debug.LogWarning("Not connected");
return;
}
try
{
await action();
}
catch (Exception ex)
{
Debug.LogError($"RPC call failed: {ex.Message}");
}
}
}
Assets/Scripts/Game/GameClient.cs
using System.Threading.Tasks;
using UnityEngine;
using YourGame.Shared;
public class GameClient : MonoBehaviour
{
[SerializeField] private string playerName = "Player";
private string _playerId;
private async void Start()
{
var network = NetworkManager.Instance;
// Connect to server
if (!await network.ConnectAsync())
{
Debug.LogError("Failed to connect!");
return;
}
// Get server info
var serverInfo = await network.GameService.GetServerInfoAsync();
Debug.Log($"Connected to '{serverInfo.ServerName}' ({serverInfo.PlayerCount}/{serverInfo.MaxPlayers})");
// Join the game
var joinResult = await network.GameService.JoinGameAsync(new JoinRequest
{
DisplayName = playerName,
AuthToken = GetAuthToken()
});
if (!joinResult.Success)
{
Debug.LogError($"Failed to join: {joinResult.ErrorMessage}");
return;
}
_playerId = joinResult.PlayerId;
Debug.Log($"Joined as {joinResult.InitialState.DisplayName} (ID: {_playerId})");
// Initialize player position
transform.position = new Vector3(
joinResult.InitialState.PositionX,
joinResult.InitialState.PositionY,
joinResult.InitialState.PositionZ
);
}
private void Update()
{
if (string.IsNullOrEmpty(_playerId)) return;
// Send input to server
var input = new PlayerInput
{
MoveX = Input.GetAxis("Horizontal"),
MoveY = Input.GetAxis("Vertical"),
Jump = Input.GetButtonDown("Jump"),
Fire = Input.GetButton("Fire1"),
AimYaw = transform.eulerAngles.y,
AimPitch = Camera.main.transform.eulerAngles.x,
Timestamp = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
};
// Fire-and-forget with error handling
NetworkManager.Instance.SendAsync(() =>
NetworkManager.Instance.GameService.SendInputAsync(input));
}
private string GetAuthToken()
{
// Implement your authentication logic
return "demo-token";
}
}
Assets/Scripts/UI/ConnectionUI.cs
using UnityEngine;
using UnityEngine.UI;
using TMPro;
public class ConnectionUI : MonoBehaviour
{
[SerializeField] private TMP_InputField hostInput;
[SerializeField] private TMP_InputField portInput;
[SerializeField] private Button connectButton;
[SerializeField] private Button disconnectButton;
[SerializeField] private TMP_Text statusText;
private void Start()
{
var network = NetworkManager.Instance;
network.OnConnected += () => UpdateUI(true);
network.OnDisconnected += () => UpdateUI(false);
network.OnConnectionError += error => statusText.text = $"Error: {error}";
connectButton.onClick.AddListener(OnConnectClicked);
disconnectButton.onClick.AddListener(OnDisconnectClicked);
UpdateUI(false);
}
private async void OnConnectClicked()
{
connectButton.interactable = false;
statusText.text = "Connecting...";
var host = string.IsNullOrEmpty(hostInput.text) ? "localhost" : hostInput.text;
var port = int.TryParse(portInput.text, out var p) ? p : 7777;
var success = await NetworkManager.Instance.ConnectAsync(host, port);
if (success)
{
statusText.text = "Connected!";
}
else
{
connectButton.interactable = true;
}
}
private void OnDisconnectClicked()
{
NetworkManager.Instance.Disconnect();
}
private void UpdateUI(bool connected)
{
connectButton.gameObject.SetActive(!connected);
disconnectButton.gameObject.SetActive(connected);
hostInput.interactable = !connected;
portInput.interactable = !connected;
if (!connected)
{
statusText.text = "Disconnected";
connectButton.interactable = true;
}
}
}

Assets/Scripts/Networking/ReconnectionHandler.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
public class ReconnectionHandler : MonoBehaviour
{
[SerializeField] private float reconnectDelay = 2f;
[SerializeField] private float maxReconnectDelay = 30f;
[SerializeField] private int maxAttempts = 10;
private CancellationTokenSource _cts;
private bool _shouldReconnect = true;
private void Start()
{
NetworkManager.Instance.OnDisconnected += OnDisconnected;
}
private void OnDestroy()
{
_cts?.Cancel();
_cts?.Dispose();
}
private async void OnDisconnected()
{
if (!_shouldReconnect) return;
_cts?.Cancel();
_cts = new CancellationTokenSource();
await AttemptReconnection(_cts.Token);
}
private async Task AttemptReconnection(CancellationToken ct)
{
var delay = reconnectDelay;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
if (ct.IsCancellationRequested) return;
Debug.Log($"Reconnection attempt {attempt}/{maxAttempts}...");
try
{
await Task.Delay(TimeSpan.FromSeconds(delay), ct);
if (await NetworkManager.Instance.ConnectAsync())
{
Debug.Log("Reconnected successfully!");
return;
}
}
catch (OperationCanceledException)
{
return;
}
// Exponential backoff
delay = Math.Min(delay * 1.5f, maxReconnectDelay);
}
Debug.LogError("Failed to reconnect after maximum attempts");
}
public void StopReconnection()
{
_shouldReconnect = false;
_cts?.Cancel();
}
public void EnableReconnection()
{
_shouldReconnect = true;
}
}
Assets/Scripts/Networking/ConnectionHealth.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
public class ConnectionHealth : MonoBehaviour
{
[SerializeField] private float pingInterval = 5f;
public float LastPingMs { get; private set; }
public event Action<float> OnPingUpdated;
private CancellationTokenSource _cts;
private void Start()
{
NetworkManager.Instance.OnConnected += StartPinging;
NetworkManager.Instance.OnDisconnected += StopPinging;
}
private void OnDestroy()
{
StopPinging();
}
private async void StartPinging()
{
_cts = new CancellationTokenSource();
while (!_cts.Token.IsCancellationRequested)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(pingInterval), _cts.Token);
var start = DateTime.UtcNow;
await NetworkManager.Instance.GameService.GetServerInfoAsync(_cts.Token);
LastPingMs = (float)(DateTime.UtcNow - start).TotalMilliseconds;
OnPingUpdated?.Invoke(LastPingMs);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Debug.LogWarning($"Ping failed: {ex.Message}");
}
}
}
private void StopPinging()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
}
}

using DotBoxD.Services.Exceptions;
try
{
var result = await NetworkManager.Instance.GameService.GetPlayerAsync(playerId);
}
catch (ServiceTimeoutException)
{
// Request timed out
Debug.LogWarning("Request timed out - server may be overloaded");
}
catch (RemoteServiceException ex)
{
// Server threw an exception
Debug.LogError($"Server error ({ex.RemoteExceptionType}): {ex.Message}");
}
catch (ServiceConnectionException)
{
// Connection lost
Debug.LogError("Connection lost");
// Trigger reconnection logic
}
catch (ServiceException ex)
{
// Other RPC errors
Debug.LogError($"RPC error: {ex.Message}");
}
Assets/Scripts/Networking/RpcHelper.cs
using System;
using System.Threading.Tasks;
using UnityEngine;
using DotBoxD.Services.Exceptions;
public static class RpcHelper
{
public static async Task<T> SafeCallAsync<T>(
Func<Task<T>> call,
T defaultValue = default,
Action<Exception> onError = null)
{
try
{
return await call();
}
catch (ServiceTimeoutException ex)
{
Debug.LogWarning($"RPC timeout: {ex.Message}");
onError?.Invoke(ex);
return defaultValue;
}
catch (RemoteServiceException ex)
{
Debug.LogError($"Server error: {ex.Message}");
onError?.Invoke(ex);
return defaultValue;
}
catch (ServiceConnectionException ex)
{
Debug.LogError($"Connection error: {ex.Message}");
onError?.Invoke(ex);
return defaultValue;
}
catch (Exception ex)
{
Debug.LogError($"Unexpected error: {ex}");
onError?.Invoke(ex);
return defaultValue;
}
}
public static async Task SafeCallAsync(
Func<Task> call,
Action<Exception> onError = null)
{
try
{
await call();
}
catch (Exception ex)
{
Debug.LogError($"RPC error: {ex.Message}");
onError?.Invoke(ex);
}
}
}
// Usage:
var serverInfo = await RpcHelper.SafeCallAsync(
() => NetworkManager.Instance.GameService.GetServerInfoAsync(),
defaultValue: new ServerInfo { ServerName = "Unknown" },
onError: ex => ShowErrorPopup(ex.Message)
);

For IL2CPP builds, MessagePack needs AOT code generation hints:

Assets/Scripts/AOT/MessagePackAOTSetup.cs
using MessagePack;
using MessagePack.Resolvers;
using DotBoxD.Codecs.MessagePack;
using UnityEngine;
public static class MessagePackAOTSetup
{
public static MessagePackRpcSerializer Serializer { get; private set; }
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Initialize()
{
// Directly root the generated RPC registry so IL2CPP cannot strip it.
_ = DotBoxD.Services.Generated.DotBoxDGenerated.Services;
// Replace YourGameGeneratedResolver with the resolver produced by this
// application's MessagePack source-generator configuration.
// Pass it to DotBoxD directly; changing MessagePack's global defaults is insufficient.
Serializer = MessagePackRpcSerializer.CreateWithResolver(YourGameGeneratedResolver.Instance);
}
}

YourGameGeneratedResolver is a placeholder, not a DotBoxD type. Annotate transported DTOs for MessagePack’s source generator and replace it with the resolver produced by the shared project’s build. Inspect the IL2CPP output to confirm that every transported DTO has a static formatter. Do not fall back to ContractlessStandardResolver; it is reflection-capable and does not establish AOT compatibility.

Create Assets/link.xml to prevent code stripping:

<linker>
<!-- Preserve DotBoxD -->
<assembly fullname="DotBoxD.Services" preserve="all"/>
<assembly fullname="DotBoxD.Transports.Tcp" preserve="all"/>
<assembly fullname="DotBoxD.Codecs.MessagePack" preserve="all"/>
<!-- Preserve your shared library -->
<assembly fullname="YourGame.Shared" preserve="all"/>
<!-- Preserve MessagePack -->
<assembly fullname="MessagePack" preserve="all"/>
<!-- Preserve System.Buffers for ArrayPool -->
<assembly fullname="System.Buffers" preserve="all"/>
</linker>

RPC callbacks may not be on the main thread. Use a dispatcher:

Assets/Scripts/Util/MainThreadDispatcher.cs
using System;
using System.Collections.Concurrent;
using UnityEngine;
public class MainThreadDispatcher : MonoBehaviour
{
public static MainThreadDispatcher Instance { get; private set; }
private readonly ConcurrentQueue<Action> _actions = new();
private void Awake()
{
Instance = this;
DontDestroyOnLoad(gameObject);
}
private void Update()
{
while (_actions.TryDequeue(out var action))
{
action?.Invoke();
}
}
public void Enqueue(Action action)
{
_actions.Enqueue(action);
}
public static void RunOnMainThread(Action action)
{
Instance?.Enqueue(action);
}
}
// Usage in async code:
var result = await service.GetPlayerAsync(id);
MainThreadDispatcher.RunOnMainThread(() => {
// Safe to access Unity APIs here
playerObject.transform.position = new Vector3(result.PositionX, ...);
});

Batch multiple requests to reduce overhead:

public class RequestBatcher
{
private readonly List<PlayerInput> _pendingInputs = new();
private readonly object _lock = new();
private float _lastSendTime;
private const float SendInterval = 0.05f; // 20 times per second
public void QueueInput(PlayerInput input)
{
lock (_lock)
{
_pendingInputs.Add(input);
}
}
public void Update()
{
if (Time.time - _lastSendTime < SendInterval) return;
PlayerInput[] toSend;
lock (_lock)
{
if (_pendingInputs.Count == 0) return;
toSend = _pendingInputs.ToArray();
_pendingInputs.Clear();
}
// Send batched inputs
NetworkManager.Instance.SendAsync(() =>
NetworkManager.Instance.GameService.SendBatchedInputAsync(toSend));
_lastSendTime = Time.time;
}
}

Always pass cancellation tokens for proper cleanup:

public class GameClient : MonoBehaviour
{
private CancellationTokenSource _cts;
private void OnEnable()
{
_cts = new CancellationTokenSource();
}
private void OnDisable()
{
_cts?.Cancel();
_cts?.Dispose();
}
private async Task FetchDataAsync()
{
try
{
var data = await NetworkManager.Instance.GameService
.GetPlayerAsync(playerId, _cts.Token);
}
catch (OperationCanceledException)
{
// Component was disabled, ignore
}
}
}

Always verify connection before making calls:

public async Task<bool> TrySendInput(PlayerInput input)
{
if (!NetworkManager.Instance.IsConnected)
{
Debug.LogWarning("Cannot send input: not connected");
return false;
}
try
{
await NetworkManager.Instance.GameService.SendInputAsync(input);
return true;
}
catch
{
return false;
}
}

”TypeLoadException” or “MissingMethodException” in IL2CPP

Section titled “”TypeLoadException” or “MissingMethodException” in IL2CPP”

Cause: Code stripping removed required types.

Solution:

  1. Add types to link.xml
  2. Ensure MessagePack AOT generation is set up
  3. Use [Preserve] attribute on model classes

Cause: Server not running or wrong port/host.

Solution:

  1. Verify server is running: netstat -an | grep <port>
  2. Check firewall settings
  3. Use correct IP (not localhost for device builds)

Cause: Serialization mismatch or protocol error.

Solution:

  1. Ensure same MessagePack version on client and server
  2. Rebuild shared library and update Unity DLLs
  3. Check for model property mismatches

Cause: CancellationToken cancelled too early.

Solution:

  1. Don’t pass disposed/cancelled tokens
  2. Check component lifecycle (OnDisable cancelling tokens)

Cause: Network issues or server overload.

Solution:

  1. Use connection health monitoring
  2. Implement request timeouts
  3. Check for blocking operations on server

Enable detailed logging:

// Add to NetworkManager
#if UNITY_EDITOR || DEVELOPMENT_BUILD
private void LogRpcCall(string method, object request = null)
{
Debug.Log($"[RPC] {method}" + (request != null ? $": {JsonUtility.ToJson(request)}" : ""));
}
#endif

Implement your own transport for platforms like Steam or Epic:

public class SteamTransport : ITransport
{
private CSteamID _serverId;
private SteamConnection _connection;
public IRpcChannel Connection => _connection;
public bool IsConnected => _connection?.IsConnected ?? false;
public async Task ConnectAsync(CancellationToken ct = default)
{
// Implement Steam networking connection
var result = await SteamNetworking.ConnectP2P(_serverId);
_connection = new SteamConnection(result);
}
public ValueTask DisposeAsync()
{
_connection?.Dispose();
return default;
}
}

Register multiple services on the same server:

// Shared
[RpcService] public interface IGameService { ... }
[RpcService] public interface IChatService { ... }
[RpcService] public interface IInventoryService { ... }
// Server: provide every service on each accepted peer
await using var host = RpcHost
.Listen(serverTransport, serializer)
.ForEachPeer(peer =>
{
peer.ProvideGameService(gameService);
peer.ProvideChatService(chatService);
peer.ProvideInventoryService(inventoryService);
});
// Client: get a proxy per service from the same peer
var game = peer.GetGameService();
var chat = peer.GetChatService();
var inventory = peer.GetInventoryService();

While DotBoxD currently supports request/response patterns, you can implement push notifications using a polling pattern or extend the protocol:

// Polling approach
public class NotificationPoller : MonoBehaviour
{
private async void Start()
{
while (NetworkManager.Instance.IsConnected)
{
var notifications = await NetworkManager.Instance.GameService
.PollNotificationsAsync(_lastNotificationId);
foreach (var notification in notifications)
{
ProcessNotification(notification);
_lastNotificationId = notification.Id;
}
await Task.Delay(100); // Poll every 100ms
}
}
}

DotBoxD VersionUnity Version.NET ServerMessagePack
Current preview2021.3+10.0+3.1.7