Originally published on ziva.sh: https://ziva.sh/blogs/godot-modbus-tcp
Godot has no Modbus or OPC UA support built in, and the Godot Asset Library has no Modbus or OPC UA addon. Modbus TCP is simple enough that you do not need one: each request is a 7-byte header and a 5-byte command sent over TCP, which GDScript can do with StreamPeerTCP. Below is a complete client for reading coils and holding registers and writing single coils and registers, a conveyor that runs while a PLC coil is on, and how we tested both. For Siemens S7 PLCs and OPC UA, see the other options.
We tested this on Godot 4.7.2 on Linux, headless, against two independent Modbus TCP servers: pymodbus 3.16.2 and pyModbusTCP 0.3.1. All 15 checks passed on both on 7 November 2026. We did not test it against PLC hardware. Ziva.sh publishes this page.
The client
Save this as modbus_tcp_client.gd. It supports function codes 1 (read coils), 3 (read holding registers), 5 (write single coil) and 6 (write single register), one request at a time. Every call returns null on failure and sets last_error, so a lost PLC shows up as an error instead of a value that silently stops changing.
class_name ModbusTcpClient
extends RefCounted
## Minimal Modbus TCP client (FC1, FC3, FC5, FC6), one request at a time.
## Every call returns null on failure and sets last_error; Modbus exception
## replies also set last_exception_code. Successful writes return true.
##
## Calls block the calling thread until the reply arrives or timeout_ms passes.
## A LAN PLC answers in a few ms; run the client on a Thread for slow links.
const EXCEPTION_NAMES := {1: "Illegal Function", 2: "Illegal Data Address", 3: "Illegal Data Value",
4: "Server Device Failure", 6: "Server Device Busy", 10: "Gateway Path Unavailable",
11: "Gateway Target Failed To Respond"}
var unit_id := 1
var timeout_ms := 1000
var last_error := ""
var last_exception_code := 0
var _tcp := StreamPeerTCP.new()
var _transaction_id := 0
func connect_to_host(host: String, port: int = 502) -> Variant:
last_error = ""
last_exception_code = 0
_tcp = StreamPeerTCP.new()
var err := _tcp.connect_to_host(host, port)
if err != OK:
return _fail("connect to %s:%d failed: %s" % [host, port, error_string(err)])
var deadline := Time.get_ticks_msec() + timeout_ms
while Time.get_ticks_msec() < deadline:
_tcp.poll()
match _tcp.get_status():
StreamPeerTCP.STATUS_CONNECTED:
return true
StreamPeerTCP.STATUS_ERROR, StreamPeerTCP.STATUS_NONE:
return _fail("connect to %s:%d refused or failed" % [host, port])
OS.delay_msec(1)
return _fail_and_close("connect to %s:%d timed out after %d ms" % [host, port, timeout_ms])
func read_coils(address: int, count: int) -> Variant:
var reply = _transact(0x01, address, count, 2 + ceili(count / 8.0))
if reply == null:
return null
var coils: Array[bool] = []
for i in count:
coils.append((reply[2 + (i >> 3)] >> (i & 7)) & 1 == 1)
return coils
func read_holding_registers(address: int, count: int) -> Variant:
var reply = _transact(0x03, address, count, 2 + count * 2)
if reply == null:
return null
var registers: Array[int] = []
for i in count:
registers.append(reply[2 + i * 2] << 8 | reply[3 + i * 2])
return registers
func write_single_coil(address: int, on: bool) -> Variant:
return null if _transact(0x05, address, 0xFF00 if on else 0x0000, 5) == null else true
func write_single_register(address: int, value: int) -> Variant:
return null if _transact(0x06, address, value, 5) == null else true
func _transact(function_code: int, address: int, value: int, reply_size: int) -> Variant:
last_error = ""
last_exception_code = 0
if _tcp.get_status() != StreamPeerTCP.STATUS_CONNECTED:
return _fail("not connected; call connect_to_host() first")
if address < 0 or address > 0xFFFF or value < 0 or value > 0xFFFF:
return _fail("address %d or value/count %d is outside 0..65535" % [address, value])
_transaction_id = (_transaction_id + 1) & 0xFFFF
var pdu := PackedByteArray([function_code, address >> 8, address & 0xFF, value >> 8, value & 0xFF])
# MBAP header: transaction id, protocol id 0, byte count of unit id + PDU, unit id.
var mbap := PackedByteArray([_transaction_id >> 8, _transaction_id & 0xFF, 0, 0, 0, pdu.size() + 1, unit_id])
var err := _tcp.put_data(mbap + pdu)
if err != OK:
return _fail_and_close("send failed: %s" % error_string(err))
var deadline := Time.get_ticks_msec() + timeout_ms
var header = _read_exact(7, deadline)
if header == null:
return null
var length: int = header[4] << 8 | header[5]
if (header[0] << 8 | header[1]) != _transaction_id or header[2] != 0 or header[3] != 0 \
or header[6] != unit_id or length < 3 or length > 254:
return _fail_and_close("bad MBAP header %s for transaction %d" % [header.hex_encode(), _transaction_id])
var reply = _read_exact(length - 1, deadline)
if reply == null:
return null
if reply[0] == (function_code | 0x80):
last_exception_code = reply[1]
return _fail("Modbus exception %d (%s) for function %d at address %d" % [
reply[1], EXCEPTION_NAMES.get(reply[1], "unknown code"), function_code, address])
var valid: bool = reply == pdu if function_code >= 5 else reply.size() == reply_size and reply[1] == reply_size - 2
if reply[0] != function_code or not valid:
return _fail_and_close("unexpected reply %s to request %s" % [reply.hex_encode(), pdu.hex_encode()])
return reply
func _read_exact(byte_count: int, deadline: int) -> Variant:
while true:
_tcp.poll()
if _tcp.get_available_bytes() >= byte_count:
return _tcp.get_data(byte_count)[1]
if _tcp.get_status() != StreamPeerTCP.STATUS_CONNECTED:
return _fail("connection closed while waiting for %d bytes" % byte_count)
if Time.get_ticks_msec() >= deadline:
# The late reply would desync the next transaction, so drop the socket.
return _fail_and_close("timeout: no reply within %d ms (connection closed)" % timeout_ms)
OS.delay_msec(1)
return null
func _fail_and_close(message: String) -> Variant:
_tcp.disconnect_from_host()
return _fail(message)
func _fail(message: String) -> Variant:
last_error = message
push_error("ModbusTcpClient: " + message)
return null
Using it:
var plc := ModbusTcpClient.new()
if plc.connect_to_host("192.168.0.10", 502) == null:
push_error(plc.last_error)
return
plc.write_single_register(0, 1234) # holding register 40001
var regs = plc.read_holding_registers(0, 2) # [1234, ...]
plc.write_single_coil(3, true) # coil 00004
var coils = plc.read_coils(0, 8) # [false, false, false, true, ...]
Addresses are zero-based offsets: register 40001 in a PLC manual is offset 0, and coil 00001 is offset 0. If your values are off by one register, this is why.
A conveyor driven by a PLC coil
This Node3D polls coil 0 every 50 ms and moves a Box child along the belt while the coil is on. If the connection drops, it stops the belt rather than leaving it running on the last value it saw.
extends Node3D
## Moves $Box along the belt while PLC coil `motor_coil` is on. Polls every 50 ms.
@export var plc_host := "127.0.0.1"
@export var plc_port := 502
@export var motor_coil := 0
@export var belt_speed := 0.5 # meters per second
var plc := ModbusTcpClient.new()
var motor_on := false
func _ready() -> void:
if plc.connect_to_host(plc_host, plc_port) == null:
return
var timer := Timer.new()
timer.wait_time = 0.05
timer.timeout.connect(_poll_plc.bind(timer))
add_child(timer)
timer.start()
func _poll_plc(timer: Timer) -> void:
var coils = plc.read_coils(motor_coil, 1)
if coils == null:
timer.stop() # Comms lost: stop the belt; the client already logged why.
motor_on = false
return
motor_on = coils[0]
func _physics_process(delta: float) -> void:
if motor_on:
$Box.position.x += belt_speed * delta
Polling on a 50 ms Timer keeps PLC traffic independent of frame rate. Each call blocks until the reply arrives; a PLC on the local network answers in a few milliseconds, but a dead one blocks for up to timeout_ms, so keep the timeout short or move polling to a Thread on slow links.
How we tested it
We ran a Modbus TCP server with 16 coils and 16 holding registers on 127.0.0.1, then a headless Godot script that writes and reads back registers and coils, reads past the end of the data (which must return Modbus exception 2, Illegal Data Address), checks the connection still works after the exception, checks a 200 ms timeout against a server that never answers, and runs the conveyor scene for a few frames with coil 0 on and then off.
python3 -m venv /tmp/modbus-venv
/tmp/modbus-venv/bin/pip install pyModbusTCP==0.3.1
/tmp/modbus-venv/bin/python -c "from pyModbusTCP.server import DataBank, ModbusServer; ModbusServer(host='127.0.0.1', port=5021, data_bank=DataBank(coils_size=16, d_inputs_size=16, h_regs_size=16, i_regs_size=16)).start()" &
godot --headless --path . --import
godot --headless --path . --script res://test_modbus.gd -- --port=5021
The project holds a minimal project.godot, modbus_tcp_client.gd, conveyor.gd and conveyor.tscn (a Node3D with conveyor.gd attached and a MeshInstance3D child named Box), plus this test script:
test_modbus.gd
extends SceneTree
## Headless test: godot --headless --path . --script res://test_modbus.gd -- --port=5020
## Expects a server with 16 coils and 16 holding registers (addresses 0..15) on 127.0.0.1.
var failures := 0
func check(name: String, ok: bool, detail: Variant = "") -> void:
print("%s %s %s" % ["PASS" if ok else "FAIL", name, detail])
if not ok:
failures += 1
func _initialize() -> void:
var port := 5020
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--port="):
port = int(arg.trim_prefix("--port="))
print("Godot %s, server 127.0.0.1:%d" % [Engine.get_version_info().string, port])
var plc := ModbusTcpClient.new()
check("connect", plc.connect_to_host("127.0.0.1", port) == true, plc.last_error)
check("FC6 write register 0 = 1234", plc.write_single_register(0, 1234) == true, plc.last_error)
check("FC6 write register 1 = 0xBEEF", plc.write_single_register(1, 0xBEEF) == true, plc.last_error)
var regs = plc.read_holding_registers(0, 2)
check("FC3 read registers 0..1 == [1234, 48879]", regs == [1234, 0xBEEF], regs)
check("FC5 write coil 3 = true", plc.write_single_coil(3, true) == true, plc.last_error)
var coils = plc.read_coils(0, 8)
check("FC1 read coils 0..7 has only coil 3 on", coils == [false, false, false, true, false, false, false, false], coils)
plc.write_single_coil(3, false)
coils = plc.read_coils(0, 16)
check("FC5 coil 3 = false then FC1 read coils 0..15 all off", coils != null and coils.size() == 16 and not coils.has(true), coils)
var bad = plc.read_holding_registers(100, 1)
check("FC3 read register 100 returns exception 2", bad == null and plc.last_exception_code == 2, plc.last_error)
bad = plc.read_coils(16, 1)
check("FC1 read coil 16 returns exception 2", bad == null and plc.last_exception_code == 2, plc.last_error)
regs = plc.read_holding_registers(0, 1)
check("connection still usable after exception", regs == [1234], regs)
var silent := TCPServer.new()
silent.listen(5099, "127.0.0.1")
var mute_plc := ModbusTcpClient.new()
mute_plc.timeout_ms = 200
mute_plc.connect_to_host("127.0.0.1", 5099)
var started := Time.get_ticks_msec()
var result = mute_plc.read_coils(0, 1)
var elapsed := Time.get_ticks_msec() - started
check("timeout on silent server after ~200 ms", result == null and mute_plc.last_error.begins_with("timeout") and elapsed >= 200 and elapsed < 400, "%d ms: %s" % [elapsed, mute_plc.last_error])
check("call after timeout reports not connected", mute_plc.read_coils(0, 1) == null and mute_plc.last_error.begins_with("not connected"), mute_plc.last_error)
silent.stop()
check("connect refused reports error", ModbusTcpClient.new().connect_to_host("127.0.0.1", 5098) == null)
var conveyor: Node3D = load("res://conveyor.tscn").instantiate()
conveyor.plc_port = port
root.add_child(conveyor)
plc.write_single_coil(0, true)
await create_timer(0.5).timeout
var moved_x: float = conveyor.get_node("Box").position.x
check("conveyor box moves while coil 0 is on", moved_x > 0.15, "x=%.3f" % moved_x)
plc.write_single_coil(0, false)
await create_timer(0.2).timeout
var stopped_x: float = conveyor.get_node("Box").position.x
await create_timer(0.3).timeout
var final_x: float = conveyor.get_node("Box").position.x
check("conveyor box stops when coil 0 is off", is_equal_approx(stopped_x, final_x), "x=%.3f then %.3f" % [stopped_x, final_x])
print("RESULT: %s (%d failures)" % ["PASS" if failures == 0 else "FAIL", failures])
quit(1 if failures else 0)
The PASS and FAIL lines against pyModbusTCP 0.3.1 are below. The full log also has ERROR: ModbusTcpClient: ... lines from the checks that expect an error; those are expected. pymodbus 3.16.2 passed the same 15 checks, with the timeout at 200 ms instead of 201 ms.
Godot 4.7.2-stable (arch_linux), server 127.0.0.1:5021
PASS connect
PASS FC6 write register 0 = 1234
PASS FC6 write register 1 = 0xBEEF
PASS FC3 read registers 0..1 == [1234, 48879] [1234, 48879]
PASS FC5 write coil 3 = true
PASS FC1 read coils 0..7 has only coil 3 on [false, false, false, true, false, false, false, false]
PASS FC5 coil 3 = false then FC1 read coils 0..15 all off [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false]
PASS FC3 read register 100 returns exception 2 Modbus exception 2 (Illegal Data Address) for function 3 at address 100
PASS FC1 read coil 16 returns exception 2 Modbus exception 2 (Illegal Data Address) for function 1 at address 16
PASS connection still usable after exception [1234]
PASS timeout on silent server after ~200 ms 201 ms: timeout: no reply within 200 ms (connection closed)
PASS call after timeout reports not connected not connected; call connect_to_host() first
PASS connect refused reports error
PASS conveyor box moves while coil 0 is on x=0.183
PASS conveyor box stops when coil 0 is off x=0.208 then 0.208
RESULT: PASS (0 failures)
With no server listening, the same script exits with code 1 and FAIL lines, so a broken setup cannot pass silently.
Limits
- Function codes 1, 3, 5 and 6 only. Discrete inputs (2), input registers (4) and multiple writes (15, 16) need a few more lines each.
- No automatic reconnect: after a timeout the socket is closed and you call
connect_to_hostagain. - No TLS or authentication. Keep plain Modbus TCP on an isolated control network.
- 32-bit values split across two registers are not decoded; word order differs between PLC vendors.
- It does not work in web exports. Godot's web export docs say: "Low-level networking is not implemented due to lacking support in browsers."
- Tested on Linux desktop only, not on PLC hardware.
Siemens S7 and OPC UA
- Siemens S7. The Siemens S7 PLC Plugin on the Asset Library (MIT, version 0.1.0 for Godot 4.4) says it connects to S7-300, S7-400, S7-1200 and S7-1500 PLCs and reads and writes variables directly. We did not test it.
- OPC UA in C#. The OPC Foundation's UA .NET Standard stack, "The official OPC Foundation reference implementation of OPC UA for .NET", runs on .NET 8, which Godot's .NET build uses, and Godot's docs say "NuGet packages can be installed and used with Godot, as with any C# project." C# projects cannot be exported to the web.
- OPC UA in a GDExtension. Wrap open62541, a C implementation of OPC UA under MPL 2.0. Open Industry Project's oip-comms did this for its Godot version, which is now archived and no longer maintained.
- A bridge process. Run an OPC UA client such as Node-RED or Python's asyncua next to Godot and pass values over a WebSocket. This is the only route that also works in a web export.
We tested none of the OPC UA routes. For whether Godot is the right engine for the whole twin, see Is Godot good for digital twins?
Need the whole twin built?
A PLC link is one piece. If you need a plant, line or cell rebuilt from your drawings and checked against them, Kena Labs builds 3D simulations from a client's drawings, photos, CAD, reports and sensor data and checks them against those sources; one of its demos is a parcel line tested in 16 scenarios at once. Kena Labs is a sister company of Ziva. Book a demo with Kena Labs.
If you are building the Godot side yourself, Ziva, an AI agent inside the Godot editor, can write this kind of client and scene code for you and run the scene to check it.
FAQ
Can Godot connect to a PLC over Modbus TCP or OPC UA?
Yes, but neither is built in. Modbus TCP is a small binary protocol over TCP, so a GDScript client on StreamPeerTCP is enough; ours reads coils (function 1) and holding registers (function 3) and writes single coils and registers (functions 5 and 6), and it passed every check against two independent Modbus servers on Godot 4.7.2. For OPC UA you need a library: the OPC Foundation's .NET stack in a Godot .NET project, a GDExtension around open62541, or a separate bridge process that passes values to Godot.
Is there a Modbus or OPC UA addon for Godot?
Not on the Godot Asset Library as of November 2026; searches for Modbus and OPC UA return nothing relevant. The Asset Library does have a Siemens S7 PLC Plugin (MIT, version 0.1.0 for Godot 4.4) that reads and writes S7-300, S7-400, S7-1200 and S7-1500 variables. Open Industry Project's oip-comms GDExtension supported OPC UA and Modbus TCP, but it is archived and no longer maintained.
Does Modbus TCP work in a Godot web export?
No. Godot's web export docs say low-level networking is not implemented because browsers do not support it; only HTTP, WebSocket client and WebRTC work. For a browser-based HMI or twin, run a bridge that reads the PLC and sends values to Godot over a WebSocket.
Top comments (1)
Closing the socket on a timed-out response is the right safety choice: a late frame otherwise becomes the next transaction’s apparent reply. For the hardware test matrix, I’d add a deliberately fragmented response and a delayed response from the simulator, then assert that the client never applies a stale write acknowledgement after reconnecting. Those are the packet-boundary cases that two compliant servers can still leave unexercised.