Godot + Arduino series: companion project code

Self-contained project code for the "Godot Isn't Just for Games" series:
Arduino sketches, the Python serial<->UDP bridge, and the Godot 4 projects,
one folder per article.
This commit is contained in:
tyler parker
2026-08-09 21:14:55 +00:00
commit bb1d70c620
21 changed files with 1010 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# Part 1 — Your First Arduino Round-Trip
Code for [*"Godot Isn't Just for Games: Your First Arduino Round-Trip"*](https://codebycandle.com/blog/godot-arduino-led-first-light).
A button in a Godot 4 scene reaches down a USB cable — through a Python-to-UDP
bridge — and lights a real LED on an Arduino Uno.
## Contents
```
arduino/
LedBasics/LedBasics.ino # reads L1/L0 off serial, drives the LED on D13
bridge/bridge.py # serial <-> UDP relay (run this, leave it running)
bridge/ledtest.py # quick standalone LED blink test (no Godot)
godot/
ArduinoGodot/ # Godot 4 project: one styled toggle button
```
## Wiring
D13 → 220 Ω resistor → LED long leg (anode); LED short leg (cathode) → GND.
No breadboard? The Uno's onboard LED is already on D13.
## Run it
1. **Flash** `arduino/LedBasics/LedBasics.ino` to the Uno (Arduino IDE, 115200 baud).
2. **Bridge:** `pip install pyserial`, then `python arduino/bridge/bridge.py`. Leave it running.
3. **Godot:** open `godot/ArduinoGodot/` in Godot 4, press Play, click the button.
The bridge auto-detects the board and reconnects if the cable pops out.
@@ -0,0 +1,44 @@
/*
* LedBasics.ino — Part 1: the minimal round trip.
*
* Godot sends "L1" / "L0" over USB serial (via the Python bridge) and this
* sketch toggles an LED. No sensors yet — Part 2 (JoystickBridge.ino) adds the
* joystick telemetry going the other way.
*
* PROTOCOL
* IN (host -> Uno): "L1" = LED on, "L0" = LED off (newline-terminated)
*
* WIRING
* D13 -> 220 ohm resistor -> LED anode (+, long leg)
* LED cathode (-, short leg) -> GND
* (D13 is also the Uno's onboard LED, so it flashes during every upload —
* a free "is this pin alive" check before you've wired anything.)
*/
const uint8_t PIN_LED = 13; // LED via 220 ohm resistor (also the onboard LED)
char cmdBuf[8];
uint8_t cmdLen = 0;
void setup() {
Serial.begin(115200);
pinMode(PIN_LED, OUTPUT);
digitalWrite(PIN_LED, LOW);
}
void loop() {
// Commands in from Godot: "L1" / "L0", one per line.
while (Serial.available() > 0) {
char c = Serial.read();
if (c == '\n' || c == '\r') {
if (cmdLen >= 2 && cmdBuf[0] == 'L') {
digitalWrite(PIN_LED, cmdBuf[1] == '1' ? HIGH : LOW);
}
cmdLen = 0;
} else if (cmdLen < sizeof(cmdBuf) - 1) {
cmdBuf[cmdLen++] = c;
} else {
cmdLen = 0; // overflow guard: drop the runt line
}
}
}
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""
bridge.py — serial <-> UDP relay between the Arduino Uno and Godot (Main.gd)
Godot never touches the serial port; it only speaks UDP:
* it LISTENS on 127.0.0.1:4242 for telemetry ("x,y,btn")
* it SENDS to 127.0.0.1:4243 for LED commands ("L1" / "L0")
This bridge sits in the middle:
Arduino --serial line "x,y,btn\\n"--> bridge --UDP--> Godot :4242
Godot :4243 --UDP "L1"/"L0"--> bridge --serial--> Arduino
Double-click friendly: with no arguments it auto-detects the Arduino port and
keeps the window open on exit so you can read any error. Override if needed:
python bridge.py --port COM3
python bridge.py --port COM3 --baud 115200 # baud must match the .ino
Requires pyserial: pip install pyserial
"""
import argparse
import socket
import sys
import time
try:
import serial # pyserial
from serial.tools import list_ports
except ImportError:
print("pyserial not installed. Run: pip install pyserial")
input("\nPress Enter to close...")
sys.exit(1)
TELEMETRY_ADDR = ("127.0.0.1", 4242) # Godot listens here
COMMAND_PORT = 4243 # Godot sends LED commands here
# Substrings that identify a typical Uno / clone in the port description.
PORT_HINTS = ("arduino", "ch340", "usb-serial", "usb serial", "wchusb", "cp210")
def pick_port() -> str:
"""Return a serial port: the obvious Arduino one, else the only one, else ask."""
ports = list(list_ports.comports())
if not ports:
raise SystemExit("No serial ports found. Is the Uno plugged in?")
for p in ports:
blob = f"{p.description} {p.manufacturer or ''}".lower()
if any(h in blob for h in PORT_HINTS):
print(f"auto-detected {p.device} ({p.description})")
return p.device
if len(ports) == 1:
print(f"using only port {ports[0].device} ({ports[0].description})")
return ports[0].device
print("Multiple serial ports found:")
for i, p in enumerate(ports):
print(f" [{i}] {p.device} {p.description}")
choice = input("Pick a number: ").strip()
return ports[int(choice)].device
def run(port: str, baud: int) -> None:
# UDP socket: sends telemetry to Godot AND receives LED commands from it.
# Bind to all interfaces ("") not just 127.0.0.1 — on some Windows setups a
# loopback-only bind won't see packets whose source the sender picked freely.
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
udp.bind(("", COMMAND_PORT))
udp.setblocking(False)
# Open the serial port. A short timeout keeps readline() from blocking forever.
ser = serial.Serial(port, baud, timeout=0.05)
time.sleep(2.0) # Uno auto-resets when the port opens; wait for the sketch to boot
ser.reset_input_buffer()
print(f"bridge up: {port}@{baud} -> UDP {TELEMETRY_ADDR}, <- UDP :{COMMAND_PORT}")
print("leave this window open. Ctrl+C to quit.")
try:
while True:
# 1) Arduino -> Godot: forward each complete telemetry line.
line = ser.readline() # b"x,y,btn\r\n" or b"" on timeout
if line:
text = line.strip()
if text:
udp.sendto(text, TELEMETRY_ADDR)
# 2) Godot -> Arduino: drain any pending LED commands, newest applied last.
while True:
try:
data, src = udp.recvfrom(64)
except BlockingIOError:
break
except ConnectionResetError:
# Windows: a prior sendto hit a closed port; ICMP resets recv. Ignore.
break
cmd = data.strip()
if cmd in (b"L1", b"L0"):
ser.write(cmd + b"\n")
ser.flush()
print(f"cmd {cmd.decode()} from {src} -> serial")
else:
print(f"ignored UDP {data!r} from {src}")
except KeyboardInterrupt:
pass
finally:
ser.close()
udp.close()
print("\nbridge closed.")
def main() -> None:
ap = argparse.ArgumentParser(description="Arduino <-> Godot serial/UDP bridge")
ap.add_argument("--port", help="serial port, e.g. COM3 (auto-detected if omitted)")
ap.add_argument("--baud", type=int, default=115200, help="must match JoystickBridge.ino")
args = ap.parse_args()
try:
port = args.port or pick_port()
run(port, args.baud)
except Exception as e:
# Keep the window open so double-click users can actually read the error.
print(f"\nERROR: {e}")
input("\nPress Enter to close...")
if __name__ == "__main__":
main()
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""
ledtest.py — isolate the bridge's receive path from Godot.
Sends a single LED command straight to the bridge's command port, exactly like
Godot does. If the running bridge prints "cmd L1 ... -> serial" (and the LED
lights), the bridge's receive side is fine and any failure is on Godot's end.
If the bridge stays silent even for this, the problem is the bridge/binding.
Usage (with bridge.py already running):
python ledtest.py L1 # turn LED on
python ledtest.py L0 # turn LED off
"""
import socket
import sys
msg = (sys.argv[1] if len(sys.argv) > 1 else "L1").encode()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.sendto(msg, ("127.0.0.1", 4243))
print(f"sent {msg!r} to 127.0.0.1:4243")
@@ -0,0 +1,24 @@
extends Control
# Part 1 — the minimal round trip: a Godot button that toggles an LED on the
# Arduino. Only the OUTBOUND (command) half of the link is used here; Part 2
# adds inbound telemetry from the joystick.
const COMMAND_ADDR := "127.0.0.1"
const COMMAND_PORT := 4243
@onready var led_button: Button = $Panel/LedButton
var _udp_out := PacketPeerUDP.new()
var _led_on := false
func _ready() -> void:
# To SEND with PacketPeerUDP in Godot 4, use set_dest_address — NOT
# connect_to_host, which is for receiving and silently drops outbound packets.
_udp_out.set_dest_address(COMMAND_ADDR, COMMAND_PORT)
led_button.pressed.connect(_on_led_pressed)
func _on_led_pressed() -> void:
_led_on = not _led_on
_udp_out.put_packet(("L1" if _led_on else "L0").to_utf8_buffer())
led_button.text = "LED: ON" if _led_on else "LED: OFF"
@@ -0,0 +1 @@
uid://ctgyyk0it7dpd
@@ -0,0 +1,91 @@
[gd_scene load_steps=6 format=3]
[ext_resource type="Script" path="res://LedBasics.gd" id="1_led"]
[sub_resource type="StyleBoxFlat" id="bg"]
bg_color = Color(0.043, 0.055, 0.078, 1)
[sub_resource type="StyleBoxFlat" id="btn_normal"]
bg_color = Color(0.11, 0.145, 0.204, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.2, 0.6, 1, 0.5)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="btn_hover"]
bg_color = Color(0.145, 0.196, 0.278, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.3, 0.7, 1, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="btn_pressed"]
bg_color = Color(0.2, 0.6, 1, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[node name="Main" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1_led")
[node name="Panel" type="Panel" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
theme_override_styles/panel = SubResource("bg")
[node name="Title" type="Label" parent="Panel"]
layout_mode = 0
offset_left = 40.0
offset_top = 96.0
offset_right = 480.0
offset_bottom = 130.0
theme_override_colors/font_color = Color(0.85, 0.93, 1, 1)
theme_override_font_sizes/font_size = 24
text = "GODOT + ARDUINO: LED"
horizontal_alignment = 1
vertical_alignment = 1
[node name="Subtitle" type="Label" parent="Panel"]
layout_mode = 0
offset_left = 40.0
offset_top = 132.0
offset_right = 480.0
offset_bottom = 152.0
theme_override_colors/font_color = Color(0.4, 0.55, 0.7, 1)
theme_override_font_sizes/font_size = 12
text = "· click to reach down the wire ·"
horizontal_alignment = 1
vertical_alignment = 1
[node name="LedButton" type="Button" parent="Panel"]
layout_mode = 0
offset_left = 150.0
offset_top = 220.0
offset_right = 370.0
offset_bottom = 284.0
theme_override_colors/font_color = Color(0.85, 0.93, 1, 1)
theme_override_colors/font_hover_color = Color(1, 1, 1, 1)
theme_override_colors/font_pressed_color = Color(0.043, 0.055, 0.078, 1)
theme_override_font_sizes/font_size = 18
theme_override_styles/normal = SubResource("btn_normal")
theme_override_styles/hover = SubResource("btn_hover")
theme_override_styles/pressed = SubResource("btn_pressed")
theme_override_styles/focus = SubResource("btn_normal")
text = "LED: OFF"
@@ -0,0 +1,24 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=5
[application]
config/name="ArduinoGodot"
run/main_scene="res://LedBasics.tscn"
config/features=PackedStringArray("4.7")
[display]
window/size/viewport_width=520
window/size/viewport_height=610
[dotnet]
project/assembly_name="ArduinoGodot"