A Unity MQTT client turns a static 3D scene into a live view of real equipment: a fan spins at the measured RPM, a tank changes color with temperature, a door opens when a sensor says so. In this tutorial you will add MQTTnet to a Unity project, subscribe to sensor topics on broker.connectmq.com, and update GameObjects safely from Unity's main thread. It is the same foundation used by browser-based and desktop digital twins.
How Live Data Reaches a Unity Scene
The flow has three parts, and the middle one is where most first attempts go wrong:
Sensor or simulator --MQTT--> Broker --MQTT--> MQTTnet (background thread)
|
ConcurrentDictionary (latest value per topic)
|
MonoBehaviour.Update() (main thread) --> Transforms, materials
Unity's API is not thread-safe. Network callbacks in MQTTnet run on thread-pool threads, and touching a Transform or Material from there throws UnityException: ... can only be called from the main thread, or worse, fails silently. The fix is to store incoming values in a thread-safe structure and apply them in Update().
For visualization, you usually only care about the latest value per topic, not every message. A dictionary keyed by topic gives you "latest value wins" for free and keeps the frame time constant even if a sensor publishes faster than your frame rate.
Adding MQTTnet to Unity
Unity's scripting runtime targets .NET Standard 2.1 (Project Settings > Player > Api Compatibility Level). Use a MQTTnet 4.x release, which ships a .NET Standard build; newer major versions may target only modern .NET and will not load in Unity.
Two ways to install:
- NuGetForUnity: install the package, open NuGet > Manage NuGet Packages, search for MQTTnet and pick a 4.x version.
- Manual: download the
.nupkgfrom nuget.org, rename it to.zip, extractlib/netstandard2.1/MQTTnet.dlland drop it intoAssets/Plugins/.
If you build with IL2CPP (required for iOS and common for Android and consoles), add a link.xml in Assets/ so the code stripper does not remove parts of MQTTnet used through reflection:
<linker>
<assembly fullname="MQTTnet" preserve="all" />
</linker>
The MQTT Manager Script
Create Assets/Scripts/MqttManager.cs. It connects over TLS, subscribes to a topic prefix, keeps the latest payload per topic and reconnects with backoff.
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using MQTTnet;
using MQTTnet.Client;
using MQTTnet.Protocol;
using UnityEngine;
public class MqttManager : MonoBehaviour
{
public static MqttManager Instance { get; private set; }
[SerializeField] private string host = "broker.connectmq.com";
[SerializeField] private int port = 8883;
[SerializeField] private bool useTls = true;
[SerializeField] private string topicPrefix = "connectmq/demo/u9n1ty";
[SerializeField] private string username = "";
[SerializeField] private string password = "";
private readonly ConcurrentDictionary<string, string> _latest = new();
private IMqttClient _client;
private CancellationTokenSource _cts;
private string _clientId;
public bool IsConnected => _client != null && _client.IsConnected;
public bool TryGet(string relativeTopic, out string payload) =>
_latest.TryGetValue($"{topicPrefix}/{relativeTopic}", out payload);
private void Awake()
{
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
DontDestroyOnLoad(gameObject);
}
private void Start()
{
// Unity APIs such as Application must be read on the main thread
_clientId = $"unity-{Application.platform}-{Guid.NewGuid().ToString("N").Substring(0, 8)}";
_cts = new CancellationTokenSource();
_client = new MqttFactory().CreateMqttClient();
_client.ApplicationMessageReceivedAsync += e =>
{
_latest[e.ApplicationMessage.Topic] = e.ApplicationMessage.ConvertPayloadToString();
return Task.CompletedTask;
};
_ = Task.Run(() => ConnectionLoop(_cts.Token));
}
private async Task ConnectionLoop(CancellationToken ct)
{
var builder = new MqttClientOptionsBuilder()
.WithTcpServer(host, port)
.WithClientId(_clientId)
.WithProtocolVersion(MQTTnet.Formatter.MqttProtocolVersion.V500)
.WithKeepAlivePeriod(TimeSpan.FromSeconds(30));
if (useTls) builder.WithTlsOptions(o => o.UseTls());
if (!string.IsNullOrEmpty(username)) builder.WithCredentials(username, password);
var options = builder.Build();
var delay = 1;
while (!ct.IsCancellationRequested)
{
try
{
if (!_client.IsConnected)
{
await _client.ConnectAsync(options, ct);
await _client.SubscribeAsync(new MqttClientSubscribeOptionsBuilder()
.WithTopicFilter(f => f.WithTopic($"{topicPrefix}/#")
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtMostOnce))
.Build(), ct);
Debug.Log("MQTT connected");
delay = 1;
}
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
Debug.LogWarning($"MQTT connect failed: {ex.Message}");
delay = Math.Min(delay * 2, 30);
}
try { await Task.Delay(TimeSpan.FromSeconds(delay), ct); }
catch (OperationCanceledException) { break; }
}
}
public Task PublishAsync(string relativeTopic, string payload, bool retain = false)
{
if (!IsConnected) return Task.CompletedTask;
var msg = new MqttApplicationMessageBuilder()
.WithTopic($"{topicPrefix}/{relativeTopic}")
.WithPayload(payload)
.WithRetainFlag(retain)
.Build();
return _client.PublishAsync(msg);
}
private async void OnDestroy()
{
if (Instance != this) return;
_cts?.Cancel();
if (_client != null && _client.IsConnected)
{
try { await _client.DisconnectAsync(); } catch { }
}
_client?.Dispose();
}
}
Notes on the choices:
- QoS 0 is right for high-rate visualization data. If one frame's value is lost, the next one replaces it a moment later. Use QoS 1 for commands and alarms.
- The client ID is built in
Start(), on the main thread, becauseConnectionLoopruns on a thread-pool thread where Unity APIs such asApplicationandSystemInfomust not be called. The random suffix keeps two editor instances or two devices from kicking each other off the broker. DontDestroyOnLoadkeeps one connection across scene loads instead of reconnecting every time.- Disconnect in
OnDestroymatters in the editor: without it, every Play/Stop cycle leaves a ghost connection until the keep-alive expires, and on a broker with a connection quota you can run out quickly.
Binding Topics to GameObjects
Now the fun part. A reusable component that spins a transform from an RPM topic:
using System.Globalization;
using UnityEngine;
public class SpinFromTopic : MonoBehaviour
{
[SerializeField] private string topic = "fan1/rpm";
[SerializeField] private Vector3 axis = Vector3.up;
private float _targetRpm, _currentRpm;
private void Update()
{
if (MqttManager.Instance != null &&
MqttManager.Instance.TryGet(topic, out var raw) &&
float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var rpm))
{
_targetRpm = rpm;
}
// Smooth toward the target so jumps between messages look natural
_currentRpm = Mathf.Lerp(_currentRpm, _targetRpm, Time.deltaTime * 2f);
transform.Rotate(axis, _currentRpm * 6f * Time.deltaTime); // rpm * 360 / 60
}
}
And one that colors a renderer from a JSON temperature payload:
using UnityEngine;
public class ColorFromTemperature : MonoBehaviour
{
[System.Serializable] private class Reading { public float temperature; }
[SerializeField] private string topic = "tank1/telemetry";
[SerializeField] private float min = 20f, max = 90f;
[SerializeField] private Gradient gradient;
private Renderer _renderer;
private string _lastRaw;
private void Awake() => _renderer = GetComponent<Renderer>();
private void Update()
{
if (MqttManager.Instance == null || !MqttManager.Instance.TryGet(topic, out var raw)) return;
if (raw == _lastRaw) return; // nothing new
_lastRaw = raw;
var reading = JsonUtility.FromJson<Reading>(raw);
var t = Mathf.InverseLerp(min, max, reading.temperature);
_renderer.material.color = gradient.Evaluate(t);
}
}
Two details worth copying: parse numbers with CultureInfo.InvariantCulture (otherwise 12.5 fails on machines with a comma decimal separator), and skip work when the payload has not changed. JsonUtility is fast and allocation-light but only handles fields on serializable classes; for nested or dynamic JSON, Newtonsoft Json (available as a Unity package) is more flexible.
Feed It with Test Data
Create an empty GameObject with MqttManager, a cylinder with SpinFromTopic, and a capsule with ColorFromTemperature. Press Play, then simulate sensors from a terminal:
# Spin the fan at 120 RPM, then 600 RPM
mosquitto_pub -h broker.connectmq.com -p 1883 -i unity-sim-1 -t connectmq/demo/u9n1ty/fan1/rpm -m 120
mosquitto_pub -h broker.connectmq.com -p 1883 -i unity-sim-1 -t connectmq/demo/u9n1ty/fan1/rpm -m 600
# Heat the tank in a loop (bash)
for t in $(seq 20 5 90); do
mosquitto_pub -h broker.connectmq.com -p 1883 -i unity-sim-2 \
-t connectmq/demo/u9n1ty/tank1/telemetry -m "{\"temperature\": $t}"
sleep 0.5
done
The fan should ease up to speed and the tank should shift along your gradient. The public broker allows 10 messages per second per client, so keep simulators below that, and remember the public area is visible to everyone. More publishing tricks are in the mosquitto_pub and mosquitto_sub examples.
Sending Commands from the Scene
A twin becomes more than a viewer when users can act on it. Hook a UI button or a click on a 3D object to MqttManager.Instance.PublishAsync("pump1/cmd", "{\"action\":\"stop\"}"), and have the real device (or your simulator) confirm by publishing its new state. Animate from the confirmed state, not from the click, so the scene never shows something the equipment did not do. For commands, use QoS 1 and consider a message expiry so a device that reconnects much later does not execute an outdated instruction.
Platform Notes for a Unity MQTT Client
| Target | Works with MQTTnet over TCP/TLS? | Notes |
|---|---|---|
| Windows, macOS, Linux standalone | Yes | Simplest target; test here first |
| Android | Yes | IL2CPP needs link.xml; allow internet access in Player settings |
| iOS | Yes | IL2CPP and link.xml; prefer TLS on 8883 |
| WebGL | No | Browsers do not allow raw TCP sockets. Use MQTT over WebSocket through a JavaScript bridge (a .jslib wrapping a browser MQTT library) to wss://broker.connectmq.com:8084/mqtt |
| VR headsets (Android based) | Yes | Same as Android; watch frame-time, keep handlers tiny |
WebGL is the one that surprises people. If your end goal is a browser-based twin, consider whether a web 3D stack fits better; the MQTT over WebSocket guide explains the browser side.
From Live Scene to Digital Twin
Animating objects from topics is step one. A useful twin also needs a consistent topic structure, retained state so the scene is correct the moment it loads, and a way to send commands back. ConnectMQ's own 3D digital twin web demo, a factory with a CNC machine, robot arm, furnace, tank, conveyor and pump, follows exactly this approach with live data over secure WebSocket. Organizing topics with ISA-95 levels (enterprise/site/area/line/cell) also makes it easy to map topics to scene hierarchy; read MQTT Sparkplug B and Unified Namespace for that structure. If you also build .NET tooling around the scene, the MQTTnet tutorial covers MQTT 5 properties and TLS in more depth.
Taking It to Production
For anything beyond a demo, connect Unity to a private broker. A managed MQTT broker such as ConnectMQ gives you TLS with a Let's Encrypt certificate, MQTT 5, and generated credentials; fill host, username and password in the inspector (or better, load them at runtime from a config file outside the build). Keep publish rights away from viewer apps where possible, so a decompiled build cannot inject fake sensor data. If you are deciding where the broker should live, the overview of what an MQTT broker does and the MQTT client libraries guide help frame the options.
Get Started with ConnectMQ
Point your Unity scene at a private broker in minutes: sign up at app.connectmq.com on the free Serverless plan, no credit card needed, and use the portal's live topic explorer to see exactly what your scene is subscribed to while you build.
Frequently Asked Questions
Which MQTT library should I use in Unity?
MQTTnet 4.x is a solid choice because it is actively used, supports MQTT 5 and TLS, and ships a .NET Standard build compatible with Unity. Avoid libraries that block the main thread or rely on APIs Unity does not support.
Why does my Unity MQTT client throw "can only be called from the main thread"?
You are touching Unity objects inside the message handler. Store the payload in a thread-safe collection and apply it in Update(), as shown in this tutorial.
Can a Unity WebGL build connect to MQTT?
Not with TCP sockets. A WebGL build must use MQTT over secure WebSocket through a JavaScript plugin, connecting to a WSS endpoint such as port 8084 with path /mqtt.
How many messages per second can a Unity scene handle?
The limit is usually your processing per message, not MQTT. With the latest-value-per-topic pattern the scene does constant work per frame regardless of message rate, so the broker plan and network become the practical limits.



