A robot arm digital twin is a live 3D replica of an arm that moves exactly as the real one does, driven by joint angle telemetry rather than a canned animation. In this guide you will stream joint angles over MQTT, animate a jointed 3D model in the browser with Three.js, and send move commands back to the arm with acknowledgements. Everything runs against a real broker in a few minutes.
If you want the wider context first, the digital twin technology guide explains where this kind of 3D twin fits among other twin types.
Why a Robot Arm Is the Ideal First 3D Twin
Robot arms are a perfect starting point for 3D twins because their state is small and well defined. A six-axis arm is fully described by six joint angles plus a few flags such as gripper state, program running, and fault code. That is a tiny payload, yet when you apply those angles to a kinematic chain in 3D, the result is immediately understandable to anyone walking past the screen: the twin reaches, rotates and grips just like the cell on the shop floor.
That compactness matters for transport. You can publish the full pose several times per second over MQTT without straining the network, and every subscriber, whether a 3D viewer, a historian or an analytics script, gets the same stream from the broker.
Architecture of the Robot Arm Digital Twin
The design uses three roles connected through one broker:
- Robot gateway: reads joint positions from the controller (or simulates them) and publishes the pose. It also subscribes to command topics and forwards accepted commands to the controller.
- 3D viewer: a browser page that subscribes to the pose over Secure WebSocket and applies each joint angle to the matching node of a 3D model.
- Operator or application: publishes commands such as "move to pose" or "open gripper" and listens for acknowledgements.
| Topic | Direction | Payload | QoS | Retained |
|---|---|---|---|---|
.../robot1/joints |
Gateway to viewers | {"j1":12.5,...,"j6":-3.0,"ts":...} |
0 | No |
.../robot1/state |
Gateway to viewers | {"mode":"auto","gripper":"closed"} |
1 | Yes |
.../robot1/status |
Gateway (Last Will) | online / offline |
1 | Yes |
.../robot1/cmd/move |
Operator to gateway | {"id":"c-42","target":{...}} |
1 | No |
.../robot1/cmd/ack |
Gateway to operator | {"id":"c-42","result":"accepted"} |
1 | No |
Joint telemetry uses QoS 0 because a newer pose always supersedes an older one; losing one frame is harmless. Commands and acknowledgements use QoS 1 so they are delivered at least once, and the command id lets the gateway ignore duplicates. If you need a refresher on that trade-off, see MQTT QoS levels explained.
For this tutorial we use the public MQTT test broker at broker.connectmq.com with a unique topic prefix. Remember that the public area is readable by everyone, so never send real production data there.
Step 1: Publish Joint Angles from the Gateway
The script below simulates a six-axis arm that eases toward target angles, publishes its pose five times per second, keeps a retained state message, and accepts move commands. Replace ra-7f3k with your own random ID.
import json
import random
import time
import paho.mqtt.client as mqtt
BROKER = "broker.connectmq.com"
PREFIX = "connectmq/demo/ra-7f3k/cell1/robot1"
CLIENT_ID = f"ra-7f3k-gateway-{random.randint(1000, 9999)}"
joints = {f"j{i}": 0.0 for i in range(1, 7)}
targets = dict(joints)
LIMITS = {"j1": (-170, 170), "j2": (-90, 90), "j3": (-120, 120),
"j4": (-180, 180), "j5": (-110, 110), "j6": (-360, 360)}
seen_commands = set()
def on_connect(client, userdata, flags, reason_code, properties):
print("Connected:", reason_code)
client.publish(f"{PREFIX}/status", "online", qos=1, retain=True)
client.publish(f"{PREFIX}/state", json.dumps({"mode": "auto", "gripper": "open"}),
qos=1, retain=True)
client.subscribe(f"{PREFIX}/cmd/move", qos=1)
def on_message(client, userdata, msg):
try:
cmd = json.loads(msg.payload)
cmd_id = cmd["id"]
except (ValueError, KeyError):
return
if cmd_id in seen_commands:
return # QoS 1 duplicate, already handled
seen_commands.add(cmd_id)
result = "accepted"
for name, value in cmd.get("target", {}).items():
low, high = LIMITS.get(name, (0, 0))
if name not in LIMITS or not low <= float(value) <= high:
result = f"rejected: {name} out of range"
break
if result == "accepted":
targets.update({k: float(v) for k, v in cmd["target"].items()})
client.publish(f"{PREFIX}/cmd/ack", json.dumps({"id": cmd_id, "result": result}), qos=1)
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=CLIENT_ID,
protocol=mqtt.MQTTv5)
client.will_set(f"{PREFIX}/status", "offline", qos=1, retain=True)
client.on_connect = on_connect
client.on_message = on_message
client.connect(BROKER, 1883, keepalive=30)
client.loop_start()
try:
while True:
for name in joints:
joints[name] += (targets[name] - joints[name]) * 0.15
payload = {k: round(v, 2) for k, v in joints.items()}
payload["ts"] = int(time.time() * 1000)
client.publish(f"{PREFIX}/joints", json.dumps(payload), qos=0)
time.sleep(0.2) # 5 Hz keeps us well under the public broker rate limit
except KeyboardInterrupt:
client.publish(f"{PREFIX}/status", "offline", qos=1, retain=True)
client.loop_stop()
client.disconnect()
Install the library with pip install "paho-mqtt>=2.0" and run the script. Watch the stream from another terminal:
mosquitto_sub -h broker.connectmq.com -p 1883 -i ra-7f3k-watch -t "connectmq/demo/ra-7f3k/cell1/robot1/#" -v
On a real cell, replace the easing loop with reads from the controller interface your robot vendor provides, and forward accepted commands to it. Keep the range check in the gateway regardless: the twin must never be the thing that pushes an axis past its limit.
Step 2: Build the 3D Robot Arm Digital Twin in the Browser
A robot arm in Three.js is a chain of nested groups. Each joint is a group that rotates around one axis, and every child inherits the transforms of its parents, which is exactly how forward kinematics works. If you load a glTF model exported from CAD instead of primitives, the same idea applies: find the node for each joint by name and rotate it. The guide to binding IoT data to a 3D model covers naming conventions for those nodes.
Create a project with npm install three mqtt and a bundler such as Vite, then use this module as the entry point:
import * as THREE from "three";
import mqtt from "mqtt";
const PREFIX = "connectmq/demo/ra-7f3k/cell1/robot1";
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1e1f24);
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
camera.position.set(3, 2.5, 3);
camera.lookAt(0, 1, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 2.5));
const mat = new THREE.MeshStandardMaterial({ color: 0xf28c28 });
function link(length, radius = 0.08) {
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, length), mat);
mesh.position.y = length / 2;
return mesh;
}
// Kinematic chain: each joint is a Group nested inside its parent.
const joints = {};
const axes = { j1: "y", j2: "z", j3: "z", j4: "y", j5: "z", j6: "y" };
const lengths = { j1: 0.4, j2: 0.9, j3: 0.7, j4: 0.2, j5: 0.2, j6: 0.1 };
let parent = scene;
for (const name of Object.keys(axes)) {
const g = new THREE.Group();
g.add(link(lengths[name]));
parent.add(g);
joints[name] = g;
const tip = new THREE.Group();
tip.position.y = lengths[name];
g.add(tip);
parent = tip;
}
const target = {};
const client = mqtt.connect("wss://broker.connectmq.com:8084/mqtt", {
clientId: "ra-7f3k-viewer-" + Math.random().toString(16).slice(2, 8),
protocolVersion: 5,
});
client.on("connect", () => client.subscribe(`${PREFIX}/#`, { qos: 1 }));
client.on("message", (topic, payload) => {
if (topic.endsWith("/joints")) Object.assign(target, JSON.parse(payload.toString()));
if (topic.endsWith("/status")) document.title = "Robot1: " + payload.toString();
});
const toRad = THREE.MathUtils.degToRad;
renderer.setAnimationLoop(() => {
for (const [name, axis] of Object.entries(axes)) {
if (target[name] === undefined) continue;
const g = joints[name];
g.rotation[axis] += (toRad(target[name]) - g.rotation[axis]) * 0.3; // smooth between frames
}
renderer.render(scene, camera);
});
The interpolation in the render loop matters. Telemetry arrives at 5 Hz while the screen renders at 60 Hz, so snapping straight to each new angle looks jerky. Easing toward the latest target hides the gap without lying about the real pose. For a deeper walkthrough of scenes, cameras and loaders, see the Three.js digital twin tutorial and the official Three.js documentation.
Step 3: Close the Loop with Commands
A twin that only listens is a digital shadow. To make it bidirectional, publish a command and wait for the matching acknowledgement:
mosquitto_pub -h broker.connectmq.com -p 1883 -i ra-7f3k-operator -q 1 \
-t "connectmq/demo/ra-7f3k/cell1/robot1/cmd/move" \
-m '{"id":"c-42","target":{"j1":45,"j2":-30,"j3":60,"j5":20}}'
The gateway checks limits, updates its targets, and publishes {"id":"c-42","result":"accepted"} on cmd/ack. The 3D model then swings to the new pose because it follows the telemetry, not the command. That distinction is the most important design rule for a robot arm digital twin: the viewer always renders what the robot reports, never what someone asked for. If the controller refuses a move, the twin stays put and the operator sees the rejection reason.
In production, add these safeguards:
- Gate commands behind controller-side interlocks and the robot's own safety system. MQTT commands should request moves, not bypass safety.
- Include an expiry on commands so a delayed message is not executed late. With MQTT 5, set the message expiry interval on the publish; the MQTT 5 features guide shows how.
- Use separate credentials for viewers and for operators, and log every command and acknowledgement.
Moving from Demo to Production
The public broker is for experiments: it limits clients to 10 messages per second and 64 KB per message, and anything you publish there is visible to others. For a real cell, move to a managed MQTT broker with private credentials and TLS. On ConnectMQ you create a broker in the portal, copy the generated username, password and client ID, and change two things in the code: connect with TLS on port 8883 (or WSS on 8084 for the browser) and pass the credentials.
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="YOUR_CLIENT_ID",
protocol=mqtt.MQTTv5)
client.username_pw_set("YOUR_USERNAME", "YOUR_PASSWORD")
client.tls_set() # system CA store validates the Let's Encrypt certificate
client.connect("YOUR_BROKER_HOST", 8883, keepalive=30)
Because client IDs are bound to issued credentials on ConnectMQ, give the gateway and each viewer their own identity rather than sharing one. The portal's live topic explorer is handy while commissioning: you can watch the joint stream and the acknowledgements side by side without writing extra tooling.
Troubleshooting Checklist
| Symptom | Likely cause | Fix |
|---|---|---|
| Model moves in the wrong direction | Axis sign differs between controller and 3D model | Negate the angle for that joint in the viewer mapping |
| Arm parts rotate around the wrong point | Pivot not at the joint origin | Place each mesh so the group origin sits on the joint axis |
| Viewer frozen, gateway still running | Two clients share a client ID and kick each other off | Generate a unique client ID per tab and device |
| Commands executed twice | QoS 1 redelivery without deduplication | Track command IDs in the gateway |
| Pose jumps after reconnect | Viewer has no state until the next frame | Publish a retained state topic and interpolate from it |
| Disconnects at high frame rates | Public broker rate limit | Publish at 5 Hz or less, or move to a paid plan |
Run Your Robot Arm Twin on ConnectMQ
You now have a working robot arm digital twin: a gateway streaming joint angles, a 3D viewer that follows them, and a command path with acknowledgements. Prototype on broker.connectmq.com, then move the same code to a private ConnectMQ broker when the cell goes live. Create a free Serverless broker, no credit card required, and you will have TLS endpoints and credentials in minutes.
Frequently Asked Questions
How often should a robot arm digital twin update?
Five to ten pose updates per second usually looks smooth when the viewer interpolates between frames. Faster rates add load without visible benefit for most monitoring use cases.
Can I use a CAD model instead of primitives?
Yes. Export the arm to glTF with one node per joint, load it with GLTFLoader, and rotate those nodes exactly as the groups in this tutorial. Make sure each node's pivot sits on the joint axis.
Is it safe to control a real robot over MQTT?
MQTT can carry move requests, but safety must stay in the robot controller and its safety system. Validate limits in the gateway, use expiring commands and authenticated clients, and never let the twin bypass interlocks.
Why does the viewer follow telemetry instead of commands?
Commands can be rejected, delayed or partially executed. Rendering only the reported pose keeps the twin truthful, which is the whole point of a digital twin.



