DotBoxD Unity Integration Guide
A comprehensive guide to integrating DotBoxD into your Unity project for type-safe, high-performance client-server communication.
Table of Contents
Section titled “Table of Contents”- Overview
- Prerequisites
- Project Setup
- Defining Service Contracts
- Server Implementation
- Unity Client Setup
- Connection Management
- Error Handling
- IL2CPP and AOT Considerations
- Best Practices
- Troubleshooting
- Advanced Topics
Overview
Section titled “Overview”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.
Key Features for Unity
Section titled “Key Features for Unity”- 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
Architecture
Section titled “Architecture”┌─────────────────────────────────────────────────────────────────┐│ Shared Library ││ [RpcService] IGameService + Models (PlayerId, PlayerState) │└─────────────────────────────────────────────────────────────────┘ │ │ ▼ ▼┌─────────────────────────────┐ ┌─────────────────────────────┐│ Unity Client │ │ .NET Server ││ ┌───────────────────────┐ │ │ ┌───────────────────────┐ ││ │ GameServiceProxy (gen)│ │ │ │GameServiceDispatcher │ ││ └───────────────────────┘ │ │ │ (generated) │ ││ ┌───────────────────────┐ │ │ └───────────────────────┘ ││ │ RpcPeer │ │ │ ┌───────────────────────┐ ││ └───────────────────────┘ │ │ │ RpcHost │ ││ ┌───────────────────────┐ │ │ └───────────────────────┘ ││ │ TcpTransport │◄─┼───┼─►│ TcpServerTransport │ ││ └───────────────────────┘ │ │ └───────────────────────┘ │└─────────────────────────────┘ └─────────────────────────────┘Prerequisites
Section titled “Prerequisites”Unity Requirements
Section titled “Unity Requirements”- Unity 2021.3 LTS or newer (for .NET Standard 2.1 support)
- Scripting Backend: Mono or IL2CPP
- API Compatibility Level: .NET Standard 2.1
Server Requirements
Section titled “Server Requirements”- .NET 10.0 or newer for the server and Pushdown convenience host used in this guide
NuGet Packages
Section titled “NuGet Packages”MessagePack3.1.7 (the repository-tested version)
Project Setup
Section titled “Project Setup”Step 1: Solution Structure
Section titled “Step 1: Solution Structure”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.slnStep 2: Shared Library Project
Section titled “Step 2: Shared Library Project”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>Step 3: Build DLLs for Unity
Section titled “Step 3: Build DLLs for Unity”Create a build script to copy DLLs to Unity:
#!/bin/bashUNITY_PLUGINS="unity/YourGameClient/Assets/Plugins"
dotnet build src/YourGame.Shared/YourGame.Shared.csproj -c Release
# Copy DotBoxD librariescp 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 librarycp 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"Defining Service Contracts
Section titled “Defining Service Contracts”Basic Service Interface
Section titled “Basic Service Interface”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);}Model Classes
Section titled “Model Classes”Models must be serializable by MessagePack. Use attributes for best compatibility:
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; }}Custom Method Names (Optional)
Section titled “Custom Method Names (Optional)”[RpcService(Name = "Game")]public interface IGameService{ [RpcMethod(Name = "Info")] Task<ServerInfo> GetServerInfoAsync(CancellationToken ct = default);}Server Implementation
Section titled “Server Implementation”Service Implementation
Section titled “Service Implementation”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);}Server Startup
Section titled “Server Startup”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.");Unity Client Setup
Section titled “Unity Client Setup”NetworkManager Component
Section titled “NetworkManager Component”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}"); } }}Usage in Game Scripts
Section titled “Usage in Game Scripts”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"; }}UI Integration
Section titled “UI Integration”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; } }}Connection Management
Section titled “Connection Management”Automatic Reconnection
Section titled “Automatic Reconnection”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; }}Connection Health Monitoring
Section titled “Connection Health Monitoring”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; }}Error Handling
Section titled “Error Handling”Exception Types
Section titled “Exception Types”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}");}Wrapper for Safe Calls
Section titled “Wrapper for Safe Calls”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));IL2CPP and AOT Considerations
Section titled “IL2CPP and AOT Considerations”MessagePack AOT Configuration
Section titled “MessagePack AOT Configuration”For IL2CPP builds, MessagePack needs AOT code generation hints:
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); }}Generate MessagePack Formatters
Section titled “Generate MessagePack Formatters”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.
Link.xml for IL2CPP
Section titled “Link.xml for IL2CPP”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>Best Practices
Section titled “Best Practices”1. Main Thread Dispatching
Section titled “1. Main Thread Dispatching”RPC callbacks may not be on the main thread. Use a dispatcher:
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, ...);});2. Request Batching
Section titled “2. Request Batching”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; }}3. Cancellation Token Usage
Section titled “3. Cancellation Token Usage”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 } }}4. Connection State Checks
Section titled “4. Connection State Checks”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; }}Troubleshooting
Section titled “Troubleshooting”Common Issues
Section titled “Common Issues””TypeLoadException” or “MissingMethodException” in IL2CPP
Section titled “”TypeLoadException” or “MissingMethodException” in IL2CPP”Cause: Code stripping removed required types.
Solution:
- Add types to
link.xml - Ensure MessagePack AOT generation is set up
- Use
[Preserve]attribute on model classes
”Connection refused” error
Section titled “”Connection refused” error”Cause: Server not running or wrong port/host.
Solution:
- Verify server is running:
netstat -an | grep <port> - Check firewall settings
- Use correct IP (not
localhostfor device builds)
Timeout on all requests
Section titled “Timeout on all requests”Cause: Serialization mismatch or protocol error.
Solution:
- Ensure same MessagePack version on client and server
- Rebuild shared library and update Unity DLLs
- Check for model property mismatches
”Operation cancelled” immediately
Section titled “”Operation cancelled” immediately”Cause: CancellationToken cancelled too early.
Solution:
- Don’t pass disposed/cancelled tokens
- Check component lifecycle (OnDisable cancelling tokens)
High latency / slow responses
Section titled “High latency / slow responses”Cause: Network issues or server overload.
Solution:
- Use connection health monitoring
- Implement request timeouts
- Check for blocking operations on server
Debug Logging
Section titled “Debug Logging”Enable detailed logging:
// Add to NetworkManager#if UNITY_EDITOR || DEVELOPMENT_BUILDprivate void LogRpcCall(string method, object request = null){ Debug.Log($"[RPC] {method}" + (request != null ? $": {JsonUtility.ToJson(request)}" : ""));}#endifAdvanced Topics
Section titled “Advanced Topics”Custom Transport
Section titled “Custom Transport”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; }}Multiple Services
Section titled “Multiple Services”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 peerawait 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 peervar game = peer.GetGameService();var chat = peer.GetChatService();var inventory = peer.GetInventoryService();Server-to-Client Notifications (Future)
Section titled “Server-to-Client Notifications (Future)”While DotBoxD currently supports request/response patterns, you can implement push notifications using a polling pattern or extend the protocol:
// Polling approachpublic 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 } }}Version Compatibility
Section titled “Version Compatibility”| DotBoxD Version | Unity Version | .NET Server | MessagePack |
|---|---|---|---|
| Current preview | 2021.3+ | 10.0+ | 3.1.7 |