A conveyor digital twin shows at a glance whether a line is moving, how fast, and where it is stuck. In this tutorial you will build one in the browser with Three.js: rollers spin and parcels travel at the real belt speed reported over MQTT, a jam signal stops the parcels and turns the belt red, and operators can send start, stop and reset commands back to the line. A Python simulator publishes realistic conveyor data to broker.connectmq.com, so you can build and test everything before connecting a PLC.
What Makes a Conveyor a Good Digital Twin Candidate
Conveyors are everywhere in manufacturing, warehousing and logistics, and they fail in visible, costly ways: a jammed carton at a merge point can stop everything upstream. The signals are usually already available in the PLC, and they map naturally onto motion:
| Signal | Typical source | Unit | Twin behaviour |
|---|---|---|---|
| Belt speed | Drive feedback or encoder | m/s | Rollers rotate, parcels move |
| Running | Motor contactor or drive status | boolean | Belt color, motion on/off |
| Jam | Photo-eye blocked longer than a threshold | boolean plus zone | Belt turns red, parcels stop at the jam zone |
| Parcel count | Photo-eye at discharge | count | Throughput counter |
| Motor current | Drive | A | Optional, early warning of overload |
Because the visual behaviour follows directly from physics (a roller of radius r turns at v / r radians per second), a conveyor twin is also a good way to learn how to drive animation from real units rather than arbitrary numbers. For the wider picture, see our digital twin technology guide.
Topic Layout
Pick a unique prefix; the example uses connectmq/demo/conv-7f3a:
| Topic | Direction | Payload |
|---|---|---|
.../line1/conv1/telemetry |
Line to twin | {"speed":0.8,"running":true,"jam":false,"jamZone":null,"count":1532,"current":4.1} |
.../line1/conv1/cmd |
Twin to line | START, STOP, RESET |
.../line1/conv1/status |
Line, retained, last will | online / offline |
Sending one combined telemetry object, rather than a topic per signal, guarantees the twin never sees an inconsistent mix (for example running: true from one message and speed: 0 from an older one). The trade-offs between combined and per-signal topics are discussed in MQTT for digital twins.
Step 1: The Conveyor Simulator
The simulator models a three-zone conveyor running at 0.8 m/s, counts parcels, occasionally jams in a random zone and obeys commands. It uses paho-mqtt 2.x (pip install "paho-mqtt>=2.0").
import json
import random
import time
import paho.mqtt.client as mqtt
P = "connectmq/demo/conv-7f3a/line1/conv1"
NOMINAL_SPEED = 0.8 # m/s
state = {"running": True, "jam": False, "jamZone": None, "speed": 0.0, "count": 0}
def on_connect(client, userdata, flags, reason_code, properties):
print("connected:", reason_code)
client.publish(f"{P}/status", "online", qos=1, retain=True)
client.subscribe(f"{P}/cmd", qos=1)
def on_message(client, userdata, msg):
cmd = msg.payload.decode().strip().upper()
print("command:", cmd)
if cmd == "START" and not state["jam"]:
state["running"] = True
elif cmd == "STOP":
state["running"] = False
elif cmd == "RESET":
state["jam"], state["jamZone"] = False, None
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="conv-sim-7f3a")
client.will_set(f"{P}/status", "offline", qos=1, retain=True)
client.on_connect = on_connect
client.on_message = on_message
client.connect("broker.connectmq.com", 1883, keepalive=30)
client.loop_start()
while True:
if state["running"] and not state["jam"] and random.random() < 0.01:
state["jam"], state["jamZone"] = True, random.choice([1, 2, 3])
state["running"] = False # the PLC stops the belt on a jam
target = NOMINAL_SPEED if state["running"] else 0.0
state["speed"] += (target - state["speed"]) * 0.5 # ramp like a drive would
if state["speed"] > 0.05 and random.random() < state["speed"] / 1.6:
state["count"] += 1
payload = {
"speed": round(state["speed"], 3),
"running": state["running"],
"jam": state["jam"],
"jamZone": state["jamZone"],
"count": state["count"],
"current": round(1.0 + state["speed"] * 4 + random.uniform(-0.1, 0.1), 2),
}
client.publish(f"{P}/telemetry", json.dumps(payload), qos=0, retain=True)
time.sleep(0.5)
Two messages per second is comfortable on the public broker, which allows up to 10 messages per second per client.
Step 2: Build the Conveyor Digital Twin Scene
Save as index.html and serve with npx serve .. Rollers are rendered with an InstancedMesh, which keeps a long conveyor cheap to draw.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Conveyor Digital Twin</title>
<style>
body { margin: 0; overflow: hidden; background: #0f172a; font-family: system-ui, sans-serif; }
#ui { position: absolute; top: 10px; left: 10px; color: #fff; background: rgba(0,0,0,.6);
padding: 10px 12px; border-radius: 6px; font-size: 14px; }
#ui button { margin-right: 6px; margin-top: 8px; }
</style>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/",
"mqtt": "https://unpkg.com/mqtt@5.10.1/dist/mqtt.esm.js"
} }
</script>
</head>
<body>
<div id="ui">
<div id="txt">Connecting...</div>
<button data-cmd="START">Start</button><button data-cmd="STOP">Stop</button><button data-cmd="RESET">Reset jam</button>
</div>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import mqtt from "mqtt";
const P = "connectmq/demo/conv-7f3a/line1/conv1";
const LENGTH = 12, WIDTH = 1.2, ROLLER_R = 0.06, ROLLER_PITCH = 0.25, ZONES = 3;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0f172a);
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);
camera.position.set(4, 5, 9);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
new OrbitControls(camera, renderer.domElement);
scene.add(new THREE.HemisphereLight(0xffffff, 0x334155, 1.3));
const sun = new THREE.DirectionalLight(0xffffff, 1.2); sun.position.set(3, 10, 4); scene.add(sun);
// Frame
const frameMat = new THREE.MeshStandardMaterial({ color: 0x64748b });
for (const z of [-WIDTH / 2 - 0.05, WIDTH / 2 + 0.05]) {
const rail = new THREE.Mesh(new THREE.BoxGeometry(LENGTH, 0.15, 0.08), frameMat);
rail.position.set(0, 0.8, z); scene.add(rail);
}
// Rollers as instances
const count = Math.floor(LENGTH / ROLLER_PITCH);
const rollerGeo = new THREE.CylinderGeometry(ROLLER_R, ROLLER_R, WIDTH, 16);
rollerGeo.rotateX(Math.PI / 2); // axis along Z
const rollerMat = new THREE.MeshStandardMaterial({ color: 0x94a3b8, metalness: 0.6, roughness: 0.4 });
const rollers = new THREE.InstancedMesh(rollerGeo, rollerMat, count);
const dummy = new THREE.Object3D();
const rollerX = (i) => -LENGTH / 2 + ROLLER_PITCH / 2 + i * ROLLER_PITCH;
scene.add(rollers);
// Zone markers (light up red on jam)
const zoneMats = [];
for (let z = 0; z < ZONES; z++) {
const m = new THREE.MeshStandardMaterial({ color: 0x1e293b, emissive: 0x000000 });
const strip = new THREE.Mesh(new THREE.BoxGeometry(LENGTH / ZONES - 0.1, 0.02, 0.1), m);
strip.position.set(-LENGTH / 2 + (z + 0.5) * (LENGTH / ZONES), 0.89, WIDTH / 2 + 0.05);
scene.add(strip); zoneMats.push(m);
}
// Parcels
const parcelMat = new THREE.MeshStandardMaterial({ color: 0xc08a4a });
const parcels = [];
for (let i = 0; i < 8; i++) {
const p = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.4, 0.6), parcelMat);
p.position.set(-LENGTH / 2 + i * 1.5, 0.8 + ROLLER_R + 0.2, 0);
scene.add(p); parcels.push(p);
}
// ---------- State ----------
const s = { speed: 0, running: false, jam: false, jamZone: null, count: 0, online: false, at: 0 };
let shownSpeed = 0, rollerAngle = 0;
// ---------- MQTT ----------
const client = mqtt.connect("wss://broker.connectmq.com:8084/mqtt", {
clientId: "conv-twin-" + Math.random().toString(16).slice(2, 10),
protocolVersion: 5,
});
const dec = new TextDecoder();
client.on("connect", () => client.subscribe(`${P}/#`, { qos: 1 }));
client.on("message", (topic, payload) => {
const text = dec.decode(payload);
if (topic.endsWith("/status")) { s.online = text === "online"; return; }
if (!topic.endsWith("/telemetry")) return;
try { Object.assign(s, JSON.parse(text), { at: performance.now() }); } catch {}
});
document.querySelectorAll("button").forEach((b) =>
b.addEventListener("click", () => client.publish(`${P}/cmd`, b.dataset.cmd, { qos: 1 })));
// ---------- Animation ----------
const clock = new THREE.Clock();
const txt = document.getElementById("txt");
renderer.setAnimationLoop(() => {
const dt = Math.min(clock.getDelta(), 0.1);
const stale = performance.now() - s.at > 3000;
const v = stale ? 0 : s.speed;
shownSpeed += (v - shownSpeed) * (1 - Math.exp(-dt * 6));
// Rollers: angular velocity = v / r
rollerAngle -= (shownSpeed / ROLLER_R) * dt;
for (let i = 0; i < count; i++) {
dummy.position.set(rollerX(i), 0.8, 0);
dummy.rotation.set(0, 0, rollerAngle);
dummy.updateMatrix();
rollers.setMatrixAt(i, dummy.matrix);
}
rollers.instanceMatrix.needsUpdate = true;
// Parcels move at belt speed, wrap at the end
for (const p of parcels) {
p.position.x += shownSpeed * dt;
if (p.position.x > LENGTH / 2) p.position.x -= LENGTH;
}
// Jam visualisation
zoneMats.forEach((m, i) => {
const jammed = s.jam && s.jamZone === i + 1;
m.emissive.setHex(jammed ? 0xff2222 : 0x000000);
m.emissiveIntensity = jammed ? 0.6 + 0.4 * Math.sin(performance.now() / 150) : 0;
});
rollerMat.color.setHex(!s.online || stale ? 0x475569 : s.jam ? 0xdc2626 : 0x94a3b8);
txt.textContent = `${s.online ? "Online" : "Offline"} | ${shownSpeed.toFixed(2)} m/s | ` +
`${s.jam ? `JAM in zone ${s.jamZone}` : s.running ? "Running" : "Stopped"} | ` +
`count ${s.count} | ${s.current ?? "-"} A${stale ? " | no data" : ""}`;
renderer.render(scene, camera);
});
addEventListener("resize", () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
</script>
</body>
</html>
Start the simulator, open the page and watch the parcels travel at 0.8 m/s. Within a minute or two, a jam occurs: the belt decelerates, the rollers turn red and the affected zone strip pulses. Click Reset jam, then Start, and the line ramps back up.
Driving Motion from Real Units
The most important line in the twin is rollerAngle -= (shownSpeed / ROLLER_R) * dt. Because the scene uses metres and the roller radius matches the model, the surface speed of the rollers equals the parcel speed, which equals the measured belt speed. Viewers subconsciously notice when rollers spin "too fast" for the parcels; physically correct motion is what makes a twin feel trustworthy.
The speed is still eased, because real drives ramp up and down and telemetry arrives only twice per second. The easing factor is short (about a sixth of a second), so a sudden stop still looks like a stop. Note that the simulator also stops the belt on a jam, just as a PLC would: the twin does not decide to stop parcels on its own, it shows what the line reports.
Conveyor Digital Twin Checklist for Real Lines
When you replace the simulator with real PLC data, check these points:
- Speed source. Prefer drive feedback or an encoder over the setpoint. A belt slipping on a worn drum runs slower than commanded, and the twin should show it.
- Jam definition. A jam is typically a photo-eye blocked longer than the time a parcel needs to pass. Implement it in the PLC or gateway, not in the browser, so all consumers agree.
- Zone numbering. Agree on zone numbers with the controls engineer and label them physically.
- Command safety. Remote start of a conveyor is a safety question. Many sites allow only stop and reset from a twin, or require local confirmation. Never bypass the machine's safety system.
- Gateway. A PLC gateway or Node-RED flow usually reads the tags and publishes the JSON shown above; Node-RED with Modbus is a common route.
- Update rate. Two to five messages per second per conveyor is plenty for visualisation. Faster rates add load without visible benefit.
Troubleshooting
Parcels move but rollers look frozen or strobe. At high speeds the roller rotation per frame can approach a full segment of the cylinder, which creates a wagon-wheel effect. Add a visible stripe texture or reduce the segment count so the motion reads correctly.
The twin keeps moving after the line stops. Your gateway is probably publishing the speed setpoint instead of feedback, or it stopped publishing entirely. The stale-data check zeroes the speed after three seconds without messages, which is why the status line shows "no data".
Buttons do nothing. Check that the simulator or gateway subscribes to the exact cmd topic, and watch it with mosquitto_sub to confirm the command arrives.
The page connects, then drops every few seconds. Two tabs or two viewers share a client ID. The random suffix in the example prevents this on the public broker; on a private broker where client IDs are tied to issued credentials, give each viewer its own credentials.
Extending the Twin
- Build a whole line by repeating the conveyor with different prefixes and adding merges and diverters; configuration-driven bindings from binding IoT data to a 3D model keep this manageable.
- Add KPI tiles for throughput per hour, jam count per shift and availability next to the 3D view, as shown in the digital twin dashboard tutorial.
- Replace boxes with a glTF model of your actual conveyor, using the structure from building a web based 3D digital twin.
- Combine it with a robot or palletiser to build a cell, similar to the scene in ConnectMQ's 3D digital twin web demo, which includes a conveyor alongside a CNC machine, robot arm, furnace, tank and pump.
Running It in Production
Line data should not travel over a public broker. Create a private broker with credentials and TLS, then add username, password and the issued clientId to the mqtt.connect options (YOUR_USERNAME, YOUR_PASSWORD, YOUR_CLIENT_ID), and use port 8883 with TLS in the gateway. A fully managed MQTT service such as ConnectMQ handles the broker side, including secure WebSocket on port 8084 for browser twins, so your team focuses on the PLC integration and the visualisation.
Get Started with ConnectMQ
Build your conveyor digital twin on the public broker today, then move it to your own private broker on ConnectMQ. The free Serverless plan, no credit card needed, supports up to 25 connections and includes alert rules with an alerts log, so a jam can also raise a threshold alert in the portal. Create your free broker at app.connectmq.com. To keep experimenting first, use the public MQTT test broker.
Frequently Asked Questions
How fast should a conveyor publish telemetry for a digital twin?
Two to five messages per second is usually enough. The twin interpolates between messages, so higher rates add network load with little visual benefit.
Should jam detection happen in the twin or in the PLC?
In the PLC or gateway. The twin should display the jam state it receives so that every system, from dashboards to alerts, sees the same event.
Can I control the conveyor from the digital twin?
Technically yes, by publishing commands the gateway forwards to the PLC. Whether you should depends on your site's safety rules; stop and reset are common, while remote start often requires local confirmation.
How do I show many conveyors without slowing the browser?
Use instanced meshes for rollers and parcels, share materials and keep one MQTT connection with a wildcard subscription for the whole line.



