Digital Twin

Unity Digital Twin Connected to MQTT: Live Telemetry and Commands

Connect a Unity scene to an MQTT broker with MQTTnet, animate a pump from live telemetry on the main thread, publish start and stop commands back to the device and move to TLS for production.

Unity digital twin of an industrial pump rotating and changing color from live MQTT telemetry
Run this on your own MQTT brokerGet a private, managed MQTT broker on the Free plan and start from ready-made code samples.
Try it free Code samples No card required

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:

  1. Subscribes to connectmq/demo/unity-7f3a/pump1/telemetry and reads rpm, temperature and running from JSON.
  2. Spins its impeller at the reported speed and shifts the body color from blue to red as it heats up.
  3. Publishes START and STOP commands to connectmq/demo/unity-7f3a/pump1/cmd from UI buttons.
  4. 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.

  1. In Project Settings > Player > Other Settings, set Api Compatibility Level to .NET Standard 2.1.
  2. Download the MQTTnet 4.3.x package from NuGet (the .nupkg file is a zip archive), extract lib/netstandard2.1/MQTTnet.dll and place it in Assets/Plugins/. Alternatively, install it with the NuGetForUnity package manager.
  3. If you build with IL2CPP (mobile and most consoles), add Assets/link.xml so 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:

  1. One connection manager per application, a singleton that owns the IMqttClient, subscribes with a wildcard such as plant1/# and routes messages by topic.
  2. 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.
  3. ScriptableObjects for configuration, so broker settings and topic prefixes differ between development and production builds without code changes.
  4. 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 .jslib plugin, 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 clientId empty 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.

Related articles

All articles →

Your private MQTT broker is a few clicks away

Create an account, get your broker credentials and connect your first device — no servers to install, patch or scale.