Real-Time Robot Digital Twin
Lately, I've been playing with OPC-UA subscriptions to mirror a real robot's live state in its 3dverse Digital Twin. In this devlog, I'll walk through how I built a Node.js app that runs both an OPC-UA client and a Livelink headless client. The OPC-UA client receives updates of the robot state and forwards them via Livelink to synchronize its 3dverse Digital Twin.
Introduction
The flowchart below shows how the robot's state is mirrored from the OPC-UA server to the 3dverse digital twin:
The goal is the state synchronizaiton to be soft real-time. This is pretty simple and fast to achieve.
The OPC-UA protocol is not the only option and may not be the best choice depends on the use case and the robot manufacturer. However it's the protocol I've been playing with and I also tried MQTT. I'm gonna focus on OPC-UA because its configuration might be a bit tricky to get a soft real time data stream.
Importing a robot's model into 3dverse is straightforward. Whatever is the source software - such as Siemens NX or SolidWorks - we just need an export of the model in one of our supported formats (OBJ, STL, FBX, GLTF, and so on).
Digital Twin Synchronization
With the Livelink headless client and the OPC-UA client in place, implementing the real-time sync of the Digital Twin is straightforward:
- The client connects to a remote 3dverse session hosting the robot's Digital Twin.
- It continuously receives OPC-UA updates of the robot's state and forwards them as entity updates to the 3dverse engine.
- Any non-headless client connected to the same session can then visualize the robot's motion in real time.
The key points of the implementation are:
- Connect to the OPC-UA server
- Subscribe to the OPC-UA values with the appropriate configuration to ensure a fast enough update cycle.
- Associate each OPC-UA value with its corresponding entity in the 3dverse scene.
- Through Livelink:
- Connect to the 3dverse session.
- Retrieve the mapped entities.
- Update them whenever their corresponding OPC-UA monitored items change.
The minimal sample
For a single entity, the code might look like this:
//------------------------------------------------------------------------------
import { Livelink, Entity } from "@3dverse/livelink";
import {
ClientSubscription,
OPCUANodeClient,
UserTokenType,
TimestampsToReturn,
MonitoredDataValue
} from "node-opcua";
//------------------------------------------------------------------------------
// Create and connect the Livelink client to the 3dverse session.
// Fetch the entity to sync with.
async function startLivelinkClient() {
// The user or API token
const token = "";
// The asset UUID of the scene accessible by the token
const scene_id = "";
const instance = await Livelink.join_or_start({
token,
scene_id,
is_transient: true,
is_headless: true,
});
await instance.startHeadlessClient();
const entity = await instance.findEntity({ entity_id: "" });
// Important for performance
entity.auto_broadcast = false;
return entity;
}
//------------------------------------------------------------------------------
// Monitor the attribute changes of the OPC-UA item.
// Update the 3dverse entity whenever the value changes.
function onOpcuaSubscriptionStarted(subscription: ClientSubscription) {
const namespace = "";
const itemKey = "";
const nodeId = `ns=${namespace};s=${itemKey}`;
// Important options, see further in this article.
const monitoredItemOptions = {
samplingInterval: 40,
discardOldest: true,
queueSize: 1
};
const item = await subscription.monitor(
{
nodeId,
attributeId: AttributeIds.Value,
},
monitoredItemOptions,
TimestampsToReturn.Neither,
);
item.on("changed", (dataValue: DataValue) => {
// Update the X position coordinate of the 3dverse entity
entity.local_transform.position[0] = dataValue.value.value as number;
})
}
//------------------------------------------------------------------------------
// Connect to the OPC-UA server and subscribe to publications
async function startOpcuaClient(entity: Entity) {
const client = OPCUANodeClient.create({ endpointMustExist: false, requestedSessionTimeout: 10000 });
await client.connect("opc.tcp://my.opcua.server:4840");
const userIdentityInfo = {
type: UserTokenType.UserName,
userName: "username",
password: "password",
};
const session = await client.createSession(userIdentityInfo);
// Important options, see further in this article.
const subscriptionOptions = {
requestedPublishingInterval: 50,
requestedLifetimeCount: 60,
requestedMaxKeepAliveCount: 100,
maxNotificationsPerPublish: 100,
publishingEnabled: true,
priority: 10,
}
const subscription = ClientSubscription.create(session, subscriptionOptions);
subscription.on("started", () => onOpcuaSubscriptionStarted(subscription));
}
//------------------------------------------------------------------------------
async function main() {
const entity = await startLivelinkClient();
await startOpcuaClient(entity);
}
main();
Serial kinematic chain

When working with a serial robot arm, the scene graph hierarchy of the 3D model is critical. Each part of the robot (base, arms, end effector, etc.) is connected to the previous one, just like real mechanical joints.
For instance, a 3 DOF robot arm with kinematic would look like this:
In the 3dverse scene graph, that relationship looks like this:
Base
└── Arm 1
└── Arm 2
└── End-Effector
Why this hierarchy matters? In 3dverse, each entity has its own transform, defined relative to its parent. That means:
- If you move or rotate the Base, everything above it (Arm 1, Arm 2, End-Effector) moves with it.
- If you move Arm 1, its children (Arm 2 and End-Effector) follow.
- If you move Arm 2, only the End-Effector moves with it.
In short, each entity depends on the transform of its parent, just like in the real mechanism.
You might want to read the "How to Export Model for Scripted Animation" for more details.
Parallel kinematic chain

Parallel kinematic chains are more complex than serial chains. For instance, multiple “arms” work together to control a single platform. For example, a simplified Delta robot kinematic chart looks like this:
Unlike a serial chain:
- The scene graph hierarchy alone cannot determine the transforms of the end-effector or the forearms.
- Each forearm and the end-effector platform depend on the position/orientation of all the upper arms simultaneously.
- Moving one upper arm affects the end-effector, but not in a simple parent-child way.
Think of it as a “cooperative mechanism”: multiple legs push and pull the platform together, so you cannot rely on hierarchical propagation.
There are two main ways to compute the correct positions:
- Compute the end-effector and forearm transforms from the upper-arm positions using inverse or forward kinematics to update the transform of their entities manually in 3dverse.
- Use the the 3dverse physics engine to define the appropriate joints and constraints, and let it automatically compute the correct end-effector and forearm transforms.
OPC-UA configuration
Nextt, let's dive into OPC-UA, and more specifically node-opcua, a Node.js library implementing the OPC-UA stack.
Before getting into the details, it's worth explaining why I combined Livelink.js with node-opcua. The robot streams its state over OPC-UA, so I needed a Node.js headless client capable of receiving these updates in real time.
This could have also been achieved with MQTT (likely more efficiently) or any other protocol capable of streaming the robot's state at a high enough frequency (gRPC for instance). Sometimes OPC-UA is the only choice because it is a standard protocol widely supported in industrial robotics.
An OPC-UA client can retrieve updates from the server in two ways:
- Polling: regularly reading values from the server.
- Subscriptions: receiving notifications whenever the server publishes a new value.
For smooth playback of robot movements, or any fast-changing signals, subscriptions are essential.
So far, I've used node-opcua exclusively inside a headless Livelink.js client. This client leverages OPC-UA notifications to update the transforms of entities in the 3dverse scene in real-time.
Subscription
A subscription acts as a channel where the client sends requests at the publishing interval. Although this may sound inefficient, it actually reduces network and CPU load compared to repeated reads because it decouples sampling from delivery.
Here's an example of a subscription configuration in node-opcua:
const subscription = await session.createSubscription({
requestedPublishingInterval: 50,
requestedLifetimeCount: 100,
requestedMaxKeepAliveCount: 10,
maxNotificationsPerPublish: 50,
publishingEnabled: true,
priority: 10,
});
Breakdown:
-
requestedPublishingInterval: 50 millisecondsInterval between server checks for monitored items. Note that node-opcua clamps the minimum internally (e.g., 50 ms → ~20 FPS). -
requestedLifetimeCount: 100 publish requestsNumber of missed client publish requests before the server deletes the subscription. Example: 50 ms × 100 missing requests = 5 seconds. -
requestedMaxKeepAliveCount: 10 publishing intervalsMaximum number of intervals without data changes before the server sends a keep-alive message to let the client know the subscription is still active. -
maxNotificationsPerPublish: 50 monitored itemsMaximum number of monitored items included per notification. Set this slightly higher than the number of items you are monitoring. -
publishingEnabled: trueAllows you to create the subscription in a paused state and start it later. -
priority: 10Server uses this value to decide which subscriptions to favor under heavy load.
Monitored Item
A monitored item is an OPC-UA item that the client requests to watch within a subscription.
Here's an example configuration in node-opcua:
const nodeId = `ns=${namespace};s=${itemKey}`;
const timestampToReturn = TimestampsToReturn.Neither;
const item = await subscription.monitor(
{
nodeId,
attributeId: AttributeIds.Value,
},
{
samplingInterval: 40, // ms
discardOldest: true,
queueSize: 1,
},
timestampsToReturn,
);
Breakdown:
-
samplingInterval: 40 millisecondsHow often the server samples the item attribute from the device. -
timestampsToReturn: TimestampsToReturn.NeitherSkip timestamps to reduce overhead unless needed for debugging. Possible values:
export declare enum TimestampsToReturn {
Source = 0,
Server = 1,
Both = 2,
Neither = 3,
Invalid = 4
}
-
queueSize: 1Maximum number of notifications the server stores for a monitored item if the client hasn't retrieved them yet. -
discardOldest: trueDrop old samples as soon as new ones arrive.
Why all this Matters
Rather than having the client constantly poll the server, the server itself samples value changes and publishes updates. This approach:
- Reduces network and CPU load
- Minimizes latency
- Improves the fidelity of the updates
By carefully aligning publishing interval, sampling interval, and queue size, the client can receive smooth updates that accurately reflect the robot's state in soft real-time.
Fine-tuning these OPC-UA parameters isn't optional. Otherwise, you may encounter issues such as:
- Receiving values too slowly.
- Receiving outdated values due to a large queue.
- Overloading the server with unnecessary samples.
Going further
A few things I still want to investigate in this setup:
- Fork the node-opcua repository to lower the hard-coded 50 ms limit on publishing interval.
- Try
TimestampsToReturn.Bothto track potential desynchronization between device sampling and server publishing. - Experiment with higher loads of monitored items and higher throughput to validate the system's scalability.
Beyond OPC-UA
When streaming time-critical sensor data (such as wheel encoder positions), choosing the right communication protocol is crucial. Each technology offers a different trade-off between latency, jitter, and determinism.
For soft real-time digital twin telemetry, MQTT is often a better choice than OPC-UA. MQTT is lightweight, cloud-friendly, and well-suited for frequent, real-time updates. OPC-UA, on the other hand, shines in industrial interoperability and monitoring. It becomes suitable for low-latency updates on a TSN-enabled LAN. That said, OPC-UA Pub/Sub over UDP can achieve higher throughput than MQTT over TCP in some scenarios.
For critical loopsor situations requiring higher throughput, other protocols like DDS, CoAP, or ZeroMQ may provide lower latency, more deterministic delivery, or finer-grained QoS guarantees compared to MQTT or OPC-UA.
OPC-UA and MQTT for Soft Real-Time
On a clean LAN with no congestion, OPC-UA and MQTT achieve the following update cycles:
- 50 ms (20 Hz) -- Reliable over TCP; latency jitter is negligible.
- 33 ms (30 Hz) -- Reliable over TCP; jitter is usually within acceptable limits.
- 16 ms (62 Hz) -- Possible, but with caveats:
- TCP retransmission and buffering can occasionally introduce significant jitter.
- UDP avoids head-of-line blocking, making it better for high-frequency updates. Standard MQTT runs only over TCP, so only OPC-UA Pub/Sub can take advantage of this.
- For critical control loops, occasional jitter or packet loss must be accounted for with techniques such as interpolation or buffering..
At these frequencies, OPC-UA or MQTT are well-suited for soft real-time.
This article has focused on forwarding OPC-UA data from a local LAN server to the 3dverse cloud rendering engine over a non-deterministic WAN. However, LAN hardware sometimes demands minimal latency and deterministic delivery, which leads to technologies such as:
- Time-Sensitive Networking (TSN).
- ROS 2 DDS
- Industrial Ethernet and Fieldbuses e.g. EtherCAT, Profinet IRT, Powerlink, or others.
TSN: Deterministic Networking
When cycle times approach 10 ms or less, jitter tolerance tightens and standard Ethernet becomes unreliable. Time-Sensitive Networking (TSN) isn't a new protocol per se, but a set of IEEE 802.1 extensions implemented in network hardware and control software like Network switches, NICs (Network Interface Cards), etc. Those IEEE 802.1 extensions are:
- Time synchronization (802.1AS) ensures all devices share a precise common clock.
- Scheduled traffic (802.1Qbv) allows switches to reserve exact time slots for critical packets.
- Traffic prioritization and preemption guarantee that OPC UA Pub/Sub messages are always delivered on time.
With OPC-UA Pub/Sub over TSN, deterministic 1-5 ms update cycles become feasible, making OPC-UA suitable for motion control and other time-critical tasks.
ROS 2 and DDS in Robotics
The Robot Operating System (ROS 2) built on Data Distribution Service (DDS) is a strong candidate for high-frequency streaming, offering QoS settings such as:
- Best-effort vs reliable delivery
- Deadline constraints
- History depth and liveliness
Properly configured, DDS over UDP multicast can deliver sub-10 ms updates and balance latency versus reliability. For robotics middleware where multiple nodes (controllers, sensors, planners) need timely data, ROS 2/DDS is often more practical than OPC-UA or MQTT.
Industrial Ethernet and Fieldbuses
For the highest demands - such as closed-loop motor control at kHz rates - industrial Ethernet solutions are the gold standard:
- EtherCAT
- Profinet IRT
- POWERLINK
- and others
These technologies use special scheduling, hardware acceleration, and often bypass the OS networking stack entirely. This allows them toguaratee cycle times in the 1-4 ms range with microsecond jitter, making them ideal for ultra-low-latency, deterministic control.
Summary
- 50 ms and 33 ms cylcles: OPC-UA or MQTT over TCP is sufficient.
- 16 ms cycles: OPC-UA or MQTT can still work, but OPC-UA over UDP might be a better choice to reduce jitter.
- <10 ms deterministic cycles: a TSN-capable network or an industrial Ethernet fieldbus might is required to quarantee timing.