A Unity digital twin brings real machine data into a game engine, where you get physically based rendering, VR and AR support and a mature editor for building detailed scenes. In this tutorial you will connect a Unity scene to MQTT with the MQTTnet library, animate a pump from live telemetry, publish start and stop commands back to the device and then secure the connection with TLS. You will test it against the public broker at broker.connectmq.com using a small Python simulator.
When Unity Is the Right Tool for a Digital Twin
Browser engines such as Three.js are the easiest way to put a twin in front of many people. Unity shines when the twin is an application in its own right:
| Need | Unity | Browser (Three.js / Babylon.js) |
|---|---|---|
| Operator training, VR or AR headsets | Strong, built-in XR support | Possible via WebXR, less tooling |
| Very detailed scenes, physics, lighting | Strong | Good, limited by browser budgets |
| Zero-install access for many users | Needs a build per platform | Strong, just a URL |
| Raw MQTT over TCP | Yes, on desktop and mobile builds | No, WebSocket only |
| Team skills | C# and the Unity editor | JavaScript and web tooling |
If your twin must open from a link in any browser, start with the Three.js digital twin tutorial instead. If it is a control room application, a training simulator or a headset experience, Unity is a strong choice. For the concepts behind both, see our guide to digital twin technology.
What You Will Build
A pump in a Unity scene that:
- Subscribes to
connectmq/demo/unity-7f3a/pump1/telemetryand readsrpm,temperatureandrunningfrom JSON. - Spins its impeller at the reported speed and shifts the body color from blue to red as it heats up.
- Publishes
STARTandSTOPcommands toconnectmq/demo/unity-7f3a/pump1/cmdfrom UI buttons. - Reconnects automatically when the network drops.
Replace unity-7f3a with your own random suffix so your topics are unique on the shared public broker.
Step 1: Add MQTTnet to the Project
MQTTnet is a mature, open source .NET MQTT client that runs on Unity's .NET Standard 2.1 profile. This tutorial uses the 4.3.x API.
- In Project Settings > Player > Other Settings, set Api Compatibility Level to .NET Standard 2.1.
- Download the
MQTTnet4.3.x package from NuGet (the.nupkgfile is a zip archive), extractlib/netstandard2.1/MQTTnet.dlland place it inAssets/Plugins/. Alternatively, install it with the NuGetForUnity package manager. - If you build with IL2CPP (mobile and most consoles), add
Assets/link.xmlso code stripping does not remove types MQTTnet loads by reflection:
<linker>
<assembly fullname="MQTTnet" preserve="all"/>
</linker>
Note that MQTTnet 5 renamed several types (for example MqttFactory became MqttClientFactory). If you upgrade later, adjust the factory calls; the overall structure of the script stays the same.
Step 2: Simulate the Pump
Run this Python script on any computer to produce telemetry and react to commands. It uses paho-mqtt 2.x (pip install "paho-mqtt>=2.0").
import json
import math
import time
import paho.mqtt.client as mqtt
PREFIX = "connectmq/demo/unity-7f3a/pump1"
state = {"running": True}
def on_connect(client, userdata, flags, reason_code, properties):
print("Simulator connected:", reason_code)
client.subscribe(f"{PREFIX}/cmd", qos=1)
def on_message(client, userdata, msg):
cmd = msg.payload.decode().strip().upper()
if cmd in ("START", "STOP"):
state["running"] = cmd == "START"
print("Command:", cmd)
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="unity-sim-7f3a")
client.on_connect = on_connect
client.on_message = on_message
client.connect("broker.connectmq.com", 1883, keepalive=30)
client.loop_start()
t = 0.0
temperature = 30.0
while True:
t += 0.5
if state["running"]:
rpm = 1450 + 120 * math.sin(t / 5)
temperature = min(temperature + 0.4, 85.0)
else:
rpm = 0.0
temperature = max(temperature - 0.8, 25.0)
payload = {"rpm": round(rpm), "temperature": round(temperature, 1),
"running": state["running"]}
client.publish(f"{PREFIX}/telemetry", json.dumps(payload), qos=0)
time.sleep(0.5)
Two messages per second stays well within the public broker's 10 messages per second per client.
Step 3: The Unity Digital Twin Script
Create Assets/Scripts/PumpTwin.cs. Attach it to an empty GameObject, then drag your impeller Transform and the pump body Renderer into the inspector fields. A cylinder for the body and a flattened cube as the impeller blade are enough to start.
using System;
using System.Collections.Concurrent;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MQTTnet;
using MQTTnet.Client;
using MQTTnet.Formatter;
using MQTTnet.Protocol;
using UnityEngine;
[Serializable]
public class PumpTelemetry
{
public float rpm;
public float temperature;
public bool running;
}
public class PumpTwin : MonoBehaviour
{
[Header("Broker")]
public string host = "broker.connectmq.com";
public int port = 1883;
public bool useTls = false;
public string username = "";
public string password = "";
public string clientId = "";
public string topicPrefix = "connectmq/demo/unity-7f3a/pump1";
[Header("Scene")]
public Transform impeller;
public Renderer pumpBody;
public Color coldColor = new Color(0.2f, 0.5f, 0.9f);
public Color hotColor = new Color(0.9f, 0.2f, 0.15f);
public Color offlineColor = Color.gray;
private IMqttClient _client;
private CancellationTokenSource _cts;
private readonly ConcurrentQueue<string> _inbox = new ConcurrentQueue<string>();
private PumpTelemetry _target = new PumpTelemetry { temperature = 20f };
private float _rpm, _temperature = 20f, _lastMessageTime = -999f;
private async void Start()
{
if (string.IsNullOrEmpty(clientId))
clientId = "unity-" + Guid.NewGuid().ToString("N").Substring(0, 8);
_cts = new CancellationTokenSource();
_client = new MqttFactory().CreateMqttClient();
// Runs on a background thread: only enqueue, never touch Unity objects here.
_client.ApplicationMessageReceivedAsync += e =>
{
var seg = e.ApplicationMessage.PayloadSegment;
if (seg.Count > 0)
_inbox.Enqueue(Encoding.UTF8.GetString(seg.Array, seg.Offset, seg.Count));
return Task.CompletedTask;
};
_client.DisconnectedAsync += async e =>
{
if (_cts.IsCancellationRequested) return;
Debug.LogWarning($"MQTT disconnected: {e.Reason}. Retrying in 3 s");
await Task.Delay(3000);
if (!_cts.IsCancellationRequested) await ConnectAsync();
};
await ConnectAsync();
}
private async Task ConnectAsync()
{
var builder = new MqttClientOptionsBuilder()
.WithTcpServer(host, port)
.WithClientId(clientId)
.WithProtocolVersion(MqttProtocolVersion.V500)
.WithCleanSession()
.WithKeepAlivePeriod(TimeSpan.FromSeconds(30));
if (!string.IsNullOrEmpty(username)) builder.WithCredentials(username, password);
if (useTls) builder.WithTlsOptions(o => o.UseTls());
try
{
await _client.ConnectAsync(builder.Build(), _cts.Token);
var subscribe = new MqttFactory().CreateSubscribeOptionsBuilder()
.WithTopicFilter(f => f
.WithTopic($"{topicPrefix}/telemetry")
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtMostOnce))
.Build();
await _client.SubscribeAsync(subscribe, _cts.Token);
Debug.Log("MQTT connected and subscribed");
}
catch (Exception ex)
{
Debug.LogWarning($"MQTT connect failed: {ex.Message}");
}
}
private void Update()
{
// Drain messages on the main thread.
while (_inbox.TryDequeue(out var json))
{
try
{
_target = JsonUtility.FromJson<PumpTelemetry>(json);
_lastMessageTime = Time.time;
}
catch (Exception ex)
{
Debug.LogWarning($"Bad payload: {ex.Message}");
}
}
float k = 1f - Mathf.Exp(-Time.deltaTime * 4f); // frame-rate independent easing
_rpm = Mathf.Lerp(_rpm, _target.rpm, k);
_temperature = Mathf.Lerp(_temperature, _target.temperature, k);
if (impeller != null)
impeller.Rotate(Vector3.up, _rpm / 60f * 360f * Time.deltaTime * 0.1f, Space.Self);
if (pumpBody != null)
{
bool stale = Time.time - _lastMessageTime > 5f;
float t = Mathf.InverseLerp(25f, 85f, _temperature);
pumpBody.material.color = stale ? offlineColor : Color.Lerp(coldColor, hotColor, t);
}
}
// Hook these to UI Buttons (OnClick) in the inspector.
public void SendStart() => _ = PublishCommand("START");
public void SendStop() => _ = PublishCommand("STOP");
private async Task PublishCommand(string command)
{
if (_client == null || !_client.IsConnected)
{
Debug.LogWarning("Not connected, command not sent");
return;
}
var message = new MqttApplicationMessageBuilder()
.WithTopic($"{topicPrefix}/cmd")
.WithPayload(command)
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)
.Build();
await _client.PublishAsync(message, _cts.Token);
}
private async void OnDestroy()
{
_cts?.Cancel();
if (_client != null && _client.IsConnected)
await _client.DisconnectAsync();
_client?.Dispose();
}
}
Press Play with the simulator running. The impeller spins up, the body warms from blue to red, and clicking a Stop button wired to SendStop() makes the simulator stop the pump; within a second the twin slows the impeller and the body starts cooling. If you stop the simulator entirely, the body turns grey after five seconds.
How the Unity MQTT Integration Works
Threading is the number one gotcha. MQTTnet raises ApplicationMessageReceivedAsync on a thread-pool thread, but almost every Unity API (transforms, materials, Debug.DrawLine, instantiation) must be called from the main thread. Calling them from the handler throws errors or, worse, fails intermittently. The script avoids this by putting raw payloads into a ConcurrentQueue and processing them in Update().
Easing hides the network rhythm. Telemetry arrives twice per second, but Unity renders 60 or more frames per second. Lerping towards the latest target makes the motion continuous. The exponential factor keeps the smoothing identical regardless of frame rate.
Commands use QoS 1, telemetry QoS 0. A lost telemetry sample is replaced half a second later, while a lost stop command is a real problem. Read MQTT QoS levels explained for the details.
The device is the source of truth. The twin never changes its own state when you press Stop. It waits for the pump to report running: false. That keeps the twin honest if the device rejects or misses a command, a principle covered further in digital twin state synchronization.
Scaling Up the Unity Digital Twin
One script per machine works for a demo, but a plant has many assets. A cleaner structure for larger scenes:
- One connection manager per application, a singleton that owns the
IMqttClient, subscribes with a wildcard such asplant1/#and routes messages by topic. - A binding component per asset (
TwinBinding) with a topic suffix and a reference to the parts it drives. The manager looks up bindings in a dictionary keyed by topic. - ScriptableObjects for configuration, so broker settings and topic prefixes differ between development and production builds without code changes.
- Retained messages on state topics, so the twin shows the right state the moment it starts.
Mapping fields onto meshes, materials and animations is the same problem regardless of engine; binding IoT data to a 3D model covers the patterns in detail.
Platform Notes and Troubleshooting
- WebGL builds cannot open TCP sockets, and MQTTnet's WebSocket transport relies on .NET APIs that are not available in Unity WebGL. For a browser build, call a JavaScript MQTT client such as MQTT.js through a
.jslibplugin, or build the browser version with a web engine. - Android needs the INTERNET permission, which Unity adds automatically when networking code is detected; double-check it if connections silently fail on device.
- Connect failed: NotAuthorized means wrong credentials or client ID. Connect failed: timeout usually means the port is blocked; test with
nc -vz broker.connectmq.com 8883. - Disconnect loop every few seconds means two instances share a client ID, for example the editor and a standalone build. Leave
clientIdempty in development to get a random one.
Securing the Connection
The public broker is anonymous and readable by everyone, so use it only for tests. For a real plant, create a private broker with a managed MQTT broker service and connect over TLS. With ConnectMQ, the portal gives you a generated username, password and client ID; in the inspector set Port to 8883, tick Use Tls and fill in YOUR_USERNAME, YOUR_PASSWORD and YOUR_CLIENT_ID. The broker's certificate is issued by Let's Encrypt, so .NET validates it against the operating system's trust store with no extra files. Keep credentials out of source control by loading them from a config file or environment variable at runtime.
Get Started with ConnectMQ
A Unity digital twin needs a broker that is always reachable from the plant and from every operator workstation. ConnectMQ provides exactly that as a hosted service: create a free broker at app.connectmq.com on the Serverless plan, no credit card required, with MQTT 5, TLS and up to 25 connections. Its in-browser MQTT client and live topic explorer make it easy to check what your devices publish before you bind it in Unity. You can also keep prototyping on the free public MQTT broker until you are ready.
Frequently Asked Questions
Which MQTT library works best in Unity?
MQTTnet is the most widely used option for desktop and mobile Unity builds because it supports MQTT 3.1.1 and 5.0, TLS and async APIs on .NET Standard 2.1. For WebGL builds you need a JavaScript client bridged through a plugin.
Why do I get errors when I update objects in the message handler?
The handler runs on a background thread and most Unity APIs are main-thread only. Queue messages in the handler and apply them in Update().
Can a Unity digital twin control real equipment?
Yes, by publishing commands to topics the device subscribes to. Use QoS 1, confirm destructive actions in the UI and have the device publish its real state back so the twin reflects what actually happened.
Does this work with VR headsets?
Yes. The MQTT script is independent of the rendering setup, so the same component works in a scene configured for XR, as long as the headset build target supports the networking APIs used.



