Robotic Ribbons — FLOWMOTION
A kinetic installation of 6 robotic arms, each animating a ribbon through coordinated motion and synchronized lighting. Commissioned by Florian Goerlitz for the Light Art Festival in Heidenheim, Germany. Designed, engineered, and built at MotionLab Berlin.
Table of Contents
- Project Overview
- Motion Design & Concept
- Mechanical Design
- Electronics Architecture
- Software Architecture
- Show Design: Motion & Lighting Timecode
- Transportation & Setup
- Lessons Learned & Next Steps
- Credits
1. Project Overview
The piece was inspired by synchronized ribbon dancers — the way a
group of performers can move as a single organism, each ribbon tracing
its own path through space while contributing to a shared visual field.
FLOWMOTION translates that choreographic logic into a machine: six
robotic arms, each carrying a ribbon, moving in spherical coordinates
under a unified control system.
2. Motion Design & Concept
2.1 Starting Point — What Motion Do We Want?
We started with understanding the types of circular and rhythmic motions people have used to control ribbons. How does the human arm work in coordination with a ribbon's natural movement?
The design process began with an analysis of how humans dance with ribbons. We started to understand the relationship between how a human arm navigates a ribbon which then naturally flows through the air. We wanted the movement to be continuous, three-dimensional sweeps. The goal was to translate that quality into motorized motion: organic, wave-like, but also powerful enough to make sharp changes.
2.2 Identifying the Right Degrees of Freedom
Early explorations considered several mechanical configurations, but a spherical two-motor design was chosen because it directly maps to the mathematics of smooth orbital motion and flow patterns. It also allowed for a robust configuration of powerful motors for controlled motion.
The following animations were made in Desmos 3D to test what kind of motion was possible along a unit sphere using this spherical coordinate motor system. All of these animations were used by programming different functions onto the theta and phi angles.
|
Rotation ![]() |
Figure 8 ![]() |
|
High Frequency ![]() |
Complex Motion ![]() |
2.3 Spherical Coordinate Model
Each robot arm's position is defined by two angles:
- Theta (θ) — azimuthal rotation in the XZ plane, around the Y axis. Range: 0–360°, continuous and wrapping. Controlled by velocity (rotations per second), driving a stepper motor at the corresponding pulse rate.
- Phi (φ) — elevation from Y+ (straight up). Range: −135° to +135°. Controlled by direct angle, sent as a PWM command to the servo.
Y+ = phi 0° (arm pointing straight up)
XZ plane = phi 90° (arm horizontal)
Y- = phi ±135° (servo hardware limit)
Theta: azimuth in XZ plane, 0–360°, wraps continuously
Cartesian mapping:
y = cos(phi)
x = sin(phi) * sin(theta)
z = sin(phi) * cos(theta)
theta = atan2(x, z)
The choice of velocity control for theta (rather than position) was deliberate: it allows the stepper to spin continuously without needing to track absolute position, and makes wave behaviors — where robots rotate at slightly different speeds or with phase offsets — natural to express.

Spherical coordinate system diagram
2.4 TouchDesigner Motion Controller Preview
Exploration of TouchDesigner UI for motion controller. Constant velocity on theta motor, sine wave placed on phi motor with variable parameters. In the following video I explore the different patterns that can emerge from altering the sine wave parameters for each of the robots.
3. Mechanical Design
3.1 Robot Arm Structure
Each robot consists of:
- A stepper motor (theta axis) — continuous azimuthal rotation, driven by a DM860 driver at 800 pulses/revolution
- A servo motor (phi axis) — 270° range, 25.2V supply, direct angle control
- A rigid arm of length 1 m — 12mm wide hollow aluminum tubing
- A ribbon attachment point at the tip with bearing for natural rotation during flight
- A proximity sensor to establish a known reference position (theta = 315°) on startup — sensor signal voltage-divided from 25.2V to ~4.2V via a 1MΩ / 200kΩ divider for safe Arduino input
Note on ribbon behavior: at low speed near phi ≈ 0° (arm pointing straight up), the ribbon can become caught around the arm. This is a known mechanical constraint that shaped how behaviors approach the vertical.

Design Sketch
Mechanism

Mechanism Innards

Full Length Robots
3.2 Physical Parts List (Per Robot)
| Part | Specification | Notes |
|---|---|---|
| Stepper motor | NEMA 34, 8.5 Nm holding torque | Theta axis |
| Stepper driver | DM860 | 800 ppr DIP switch setting required |
| Servo motor | 270° servo, 25.2V supply | Phi axis, −135° to +135° |
| Arm | 1 m aluminum rod, 12 mm diameter, 2 mm wall thickness | |
| Homing sensor | Metal proximity sensor | 25.2V supply, signal divided to ~4.2V |
| Power module | 60V stepper supply + 25.2V DC-DC buck | One per robot |
| Ribbon | 75 mm width, 4 m length | |
| Mounting hardware | M6 rods and nuts |
3.4 Design Files
[TODO: Link CAD files, 3D print files, and laser cut files.]
4. Electronics System Architecture
4.1 System Overview
The system starts with TouchDesigner running on a MiniPC as the central brain where the ribbon motion is designed and sequenced. TD sends out live motor values via serial to an Arduino which connects to each robot's power box. This power box holds a voltage transformer and a motor driver which powers each robot. Each robot also sends back sensor data to the Arduino to monitor its position.

Hardware and electronics overview diagram
The electronics form a two-tier hierarchy: a central
control computer runs all show logic and communicates
to a single Arduino, which fans out to six
robot nodes via individual Ethernet links. Lighting is
handled by a dedicated ESP32 DMX controller on the same
network.

4.2 Control Computer → Arduino (Serial)
TouchDesigner communicates with the Arduino over USB serial. Per-robot data sent each frame:
| Data | Encoding | Notes |
|---|---|---|
| Theta velocity | (-theta_velocity × 800) + 100000 |
100000 = stopped; >100000 = forward; <100000 = reverse |
| Phi angle | Degrees, range −135 to +125 | Direct servo angle |
| LED color | R, G, B values | Per-robot RGB fixture |
The offset encoding for theta means the Arduino receives a single unsigned integer that encodes both direction and speed — values above 100000 are forward rotation, values below are reverse, and the magnitude of the offset is the pulse rate.
The Arduino sends 'HOMED' over serial when the homing sequence completes.
4.3 Arduino → Robot Nodes (Ethernet)
Each of the six robots has its own dedicated Ethernet connection from the Arduino, allowing independent simultaneous command delivery. The Ethernet carries two data lines to the stepper motor, one to the servo, and one back from the homing sensor. Ethernet was chosen for its twisted-pair wiring, which rejects noise and keeps signal integrity over the cable runs between the central box and each robot.
4.4 Motor Control
Stepper Motor — Theta Axis (DM860 Driver)
The DM860 accepts standard PUL / DIR / ENA signals with built-in optocouplers.
Critical: the Arduino ground must be fully isolated from the 60V system to protect the DM860 optocouplers.
| Signal | Notes |
|---|---|
| PUL | Pulse input — rate sets velocity |
| DIR | Direction |
| ENA | Enable (active low) |
| Resolution | 800 pulses/revolution (DM860 DIP switch) |
| Supply voltage | 60V |
Servo Motor — Phi Axis
| Parameter | Value |
|---|---|
| Control | Standard PWM |
| Supply voltage | 25.2V (DC-DC buck, non-isolated from 60V rail) |
| Range | −135° to +135° |
Homing Sensor
| Parameter | Value |
|---|---|
| Supply voltage | 25.2V |
| Signal conditioning | Voltage divider: 1MΩ + 200kΩ → ~4.2V at Arduino input |
| Homing reference | theta = 315° |
4.5 Power Architecture
Each robot has its own dedicated power module, isolating load and simplifying fault isolation.
| Rail | Voltage | Powers | Notes |
|---|---|---|---|
| Main supply | 60V | DM860 stepper driver | |
| DC-DC buck | 25.2V | Servo motor, homing sensor | Non-isolated from 60V rail |
| Logic supply | 5V isolated | Arduino Mega 2560 | Must be fully isolated from 60V |
4.6 Lighting — ESP32 DMX Controller
Each robot has one RGB DMX fixture (3 channels: R, G, B). A dedicated ESP32 receives Art-Net UDP from TouchDesigner and outputs DMX512 via a MAX485 RS-485 transceiver.
| Component | Detail |
|---|---|
| Microcontroller | ESP32 |
| Library | Dmx_ESP32 |
| Protocol in | Art-Net (UDP) |
| Protocol out | DMX512 via MAX485 transceiver |
| Channels used | 18 total (6 robots × 3 RGB channels) |
The Dmx_ESP32 library was adopted after resolving
persistent DMX break-timing issues with the initial
implementation, which caused fixture flicker and dropout. Correct break
timing is handled automatically by the library.
5. Software Architecture
5.1 Stack Overview
| Layer | Tool / Language |
|---|---|
| Show control & orchestration | TouchDesigner (Python extensions) |
| Motion math | Python (spherical coordinates, phase accumulators) |
| Serial communication | TouchDesigner Serial DAT |
| Microcontroller firmware | Arduino C++ (Mega 2560) |
| Lighting output | Art-Net DAT → ESP32 → DMX512 |
| Inter-machine communication for hand control | OSC over direct Ethernet |
5.2 TouchDesigner Node Structure
/project1
/controller_comp Base COMP — RobotControllerEXT
/sequencer_comp Base COMP — ShowSequencerEXT
/recorder_comp Base COMP — MotionRecorderEXT
/Robot1 .../Robot6 Base COMP — RobotEXT
const_robot Constant CHOP — motor output values for each robot
The Execute DAT calls all three extensions in order every frame:
op('sequencer_comp').ext.ShowSequencerEXT.Update() # sets params first
op('controller_comp').ext.RobotControllerEXT.Update() # computes motion
op('recorder_comp').ext.MotionRecorderEXT.Update() # captures state last5.3 RobotEXT — Per-Robot State Container
RobotEXT is a pure data container — one instance per
robot arm. It tracks current position, velocity, and hardware limits,
and writes motor commands to a Constant CHOP each frame.
State
| Attribute | Unit | Default |
|---|---|---|
theta | degrees | 0.0 (range 0–360°) |
phi | degrees | 90.0 (range −135° to +135°) |
theta_velocity | rps | 0.0 |
phi_velocity | deg/s | 0.0 |
Hardware Limits
| Limit | Default | Notes |
|---|---|---|
pulses_per_revolution | 800 | Must match DM860 DIP switch |
max_theta_velocity | 1.5 rps | |
max_phi_velocity | 540.0 deg/s | Equivalent to 1.5 rps |
max_theta_acceleration | 2.3 rps/s | 0 = unlimited |
max_phi_acceleration | 1.0 deg/s² | 0 = unlimited |
CHOP Output
Each frame, PushToCHOP() writes three values to the robot's Constant CHOP:
const0value = theta (deg)
const1value = phi (deg)
const2value = (-theta_velocity × 800) + 100000
100000 = stopped
> 100000 = forward rotation
< 100000 = reverse rotation
Key Methods
SetState(theta, phi, ledMatrix, dt=None)
# If dt > 0: derives velocity from delta/dt, applies accel clamping.
# If dt is None or 0: snaps to position, zeroes velocity.
Halt()
# Zeros velocity and pushes to CHOP.
# Always use this from outside — never assign r.theta_velocity directly
# (TD extension promotion does not intercept attribute assignment)5.4 RobotControllerEXT — Motion Orchestration
RobotControllerEXT owns all motion logic: behavior
evaluation, weighted blending, smooth transitions, stop/resume ramps,
and the homing sequence.
Update Loop (Every Frame)
1. Skip if dt < 0.001 (double-cook guard)
2. Read rotation axis from CHOP if quaternion behavior active
3. Advance homing state machine if running
4. If paused: return early
5. Scale effective_dt by stop/resume ramp factor
6. Accumulate internal show time
7. Exponential-lag smooth all behavior parameters (τ ≈ 1/6 s)
8. Evaluate all active behaviors; blend by normalized weight
9. Apply blended states to each RobotEXT via SetState + PushToCHOP
Behavior System
Multiple behaviors can run simultaneously. Each has an independent weight and parameters that are exponentially smoothed each frame before evaluation:
alpha = 1 - exp(-6.0 * dt)
smoothed = smoothed + (target - smoothed) * alphaBlending is normalized weighted: if sine has weight 0.7 and wave has weight 0.3,
the output is a 70/30 blend of what each behavior would produce. This enables smooth crossfades with no discontinuities.
Built-in Behaviors
| Behavior | Description |
|---|---|
sine | Theta at constant velocity; phi oscillates sinusoidally with per-robot phase offsets |
wave | Both theta and phi oscillate sinusoidally from absolute show time |
noise | Same as wave at higher frequency, smaller amplitude — good for subtle organic perturbation |
circular | All robots trace circles in a tilted plane using per-robot angle accumulators |
quaternion | Rotates arm vectors around an arbitrary 3D axis (Rodrigues' formula); supports live CHOP input for the rotation axis |
circle_zy | Traces a circle on the unit sphere in a plane offset from the ZY axis |
figure8 | Lissajous figure-8: theta at frequency f, phi at 2f |
figure9 | Inverse Lissajous: theta at 2f, phi at f |
flip | Binary phi toggle — all robots snap to ±135° |
stepper_speed_control | Theta spins continuously; phi set directly with cascading delay ripple between robots |
stepper_direct_control | Direct position control for both axes with independent cascading delays |
Behaviors involving continuous accumulation (sine, circular, figure8, etc.)
maintain per-robot phase accumulators so parameter changes — e.g. changing frequency mid-show —
are seamless rather than causing a position jump.
Fade System
ctrl.fadeTo('wave', duration=2.0)Transitions use cubic ease-in-out interpolation. For sine and circular,
the endpoint is predicted duration seconds ahead, so the incoming behavior lands without a visible seam.
On completion, all other weights are zeroed and the target is set to 1.0.
Stop / Resume
ctrl.stop() # Smooth decel over decel_duration (default 0.5s)
ctrl.resume() # Smooth accel back up
ctrl.hardStop() # Instant — zeroes all velocities immediatelyStop and resume work by scaling effective_dt over the ramp window,
so velocity reaches zero naturally through the same acceleration clamping used during normal motion.
5.5 Homing Sequence
On startup (or whenever the installation needs to re-reference), homing runs through a four-state machine:
None → 'decelling' → 'waiting' → 'resuming' → None
- All robots decelerate to a stop
- Controller pulses a configurable trigger op to activate the Arduino homing routine
- Each robot drives toward its homing sensor; on contact, theta is zeroed and the reference is set to 315°
- Arduino sends
'HOMED'over serial when all robots are referenced - Controller resumes motion, restoring each robot's saved phi value
ctrl.startHoming(
trigger_op = 'button_home',
trigger_par = 'Pulse',
serial_dat = 'serial1',
done_string = 'HOMED'
)5.6 MotionRecorderEXT — Live Motion Capture
The recorder captures live robot states into named clips at a configurable sample rate (default 30fps), then registers each clip as a dynamic behavior on the controller — meaning recorded motion can be weighted, blended, and faded identically to any built-in behavior.
rec.startRecording('take_1') # begin capture
rec.stopRecording() # finalize and register on controller
ctrl.fadeTo('take_1', 2.0) # crossfade into recorded motion
rec.setClipSpeed('take_1', 0.5) # half speed playback
rec.setClipLoop('take_1', False) # one-shot (holds last frame)
rec.saveClips('show/clips.json') # persist to disk
rec.loadClips('show/clips.json') # restore and re-register on loadThis was particularly useful during choreography development — interesting moments discovered through live manipulation could be captured immediately and played back precisely.
5.7 ShowSequencerEXT — Timecoded Show Playback
The show is authored as a Table DAT — one row per segment — which the sequencer reads to drive the controller over time. The sequencer must update before the controller each frame so parameters land in the same frame they're intended for.
Table Format
time_start |
duration |
behavior |
blend_start |
blend_end |
params_start |
params_end |
|---|---|---|---|---|---|---|
| 0.0 | 10.0 | sine | theta_velocity=0.1 bias_phi=-90 |
theta_velocity=0.3 bias_phi=-45 |
||
| 10.0 | 4.0 | sine/wave | 0.0 | 1.0 | theta_velocity=0.2 | |
| 14.0 | 8.0 | figure8 | frequency=0.25 amplitude_theta=60 |
frequency=0.5 amplitude_theta=30 |
behavior— single name (sine) or crossfade pair (sine/wave); in crossfade mode,blend_start/blend_endcontrol the weight of the second behavior across the segment durationparams_start/params_end— space-separatedkey=valuepairs; the sequencer interpolates linearly between them overdurationseconds- The sequencer fully owns weight state during playback — it zeros all behavior weights every frame
- Segments are sorted by
time_startautomatically on load
Playback Controls
seq.loadFromDAT('sequencer_table')
seq.play() # start / resume
seq.pause() # freeze; controller holds current state
seq.seek(30.0) # jump to 30s; immediately applies that segment's state
seq.setLoop(True)
seq.seq_time # current playhead position in seconds (read-only)5.8 Key TouchDesigner Implementation Notes
These patterns were non-obvious and important to get right:
- Frame-persistent state —
scriptOp.store()/fetch()used for values that must survive TD cook cycles; instance variables alone are not reliable - Absolute time —
absTime.secondsused for show timing;me.time.secondsresets on component resets and is unsuitable for a running installation - Extension promotion — methods on
RobotEXTare promoted to the operator level (r.Halt()works), but attribute assignment (r.theta_velocity = x) does not go through the extension — always use setter methods - Double-cook guard — TD fires Execute DATs multiple times per frame with dt≈0;
the guard
if dt < 0.001: returnprevents the second cook from zeroing velocities
5.9 Source Files
| File | Role |
|---|---|
DATS/RobotEXT.py | Per-robot state container and CHOP output |
DATS/RobotControllerEXT.py | All motion logic: behaviors, blending, fading, stop/resume, homing |
DATS/MotionRecorderEXT.py | Records robot motion into named clips; registers clips as behaviors |
DATS/ShowSequencerEXT.py | Timecoded show: Table DAT-driven parameter sequences |
6. Show Design: Motion & Lighting Timecode
6.1 Approach
Both motion and lighting are driven from a single timecoded Table DAT sequence in TouchDesigner,
read by ShowSequencerEXT. The sequencer owns a single internal clock and drives
RobotControllerEXT with interpolated behavior parameters each frame — including both motor targets
and LED colors. Because everything shares one clock and one update loop, motion and lighting are structurally
guaranteed to stay in sync across playback, seek, pause, and loop operations.
6.2 Motion Authoring
Motion is authored by writing segments into the sequencer_table Table DAT.
Each segment specifies which behavior is active, its parameters at the start and end of the segment
(with linear interpolation between), and optionally a crossfade to a second behavior.
time_start |
duration |
behavior |
params_start |
params_end |
|---|---|---|---|---|
| 0.0 | 8.0 | sine |
theta_velocity=0.1 bias_phi=-90 |
theta_velocity=0.2 bias_phi=-60 |
| 8.0 | 4.0 | sine/wave |
theta_velocity=0.2 |
|
| 12.0 | 10.0 | wave |
frequency_phi=0.5 amplitude_phi=30 |
frequency_phi=1.5 amplitude_phi=60 |
| 22.0 | 6.0 | figure8 |
frequency=0.25 amplitude_theta=60 |
frequency=0.5 amplitude_theta=30 |
Certain moments in the show were developed through live performance — moving the robots in real time
through parameter manipulation — and captured using MotionRecorderEXT. These clips were then
incorporated into the sequence as named behaviors alongside the procedural ones.
6.3 Lighting
Each robot has one RGB DMX fixture, addressed as 3 consecutive DMX channels. Lighting is controlled by the same Table DAT / ShowSequencer system as motion — there is no separate lighting timeline.
Every behavior function returns a led: [r, g, b] value per robot alongside
theta and phi. These values flow through the same normalized weighted blend
as the motion state, so lighting crossfades automatically whenever behaviors crossfade.
Lighting cues are authored directly in the sequencer_table using the same
params_start / params_end syntax:
time_start |
duration |
behavior |
params_start |
params_end |
|---|---|---|---|---|
| 0.0 | 8.0 | sine |
led_r=0 led_g=50 led_b=255 |
led_r=0 led_g=100 led_b=255 |
| 8.0 | 4.0 | wave |
led_r=255 led_g=80 led_b=0 |
led_r=255 led_g=255 led_b=0 |
6.4 Show Sections
The show was built through a sequence of timecoded commands. In this short example, we first start the show by cutting the lights and homing all of the motors. Then the music starts and robot one shoots to life along with its lights, then after 5 seconds, robot 2 joins. We did this for about 300 different timecodes along a 7 minute song to create a choreographed light-controlled show.
| Time | Section | Motion Behavior | Lighting | Notes |
|---|---|---|---|---|
| 0:00 | Startup / Homing | All robots home at theta=315° | Dark | Silent calibration |
| 0:05 | Song begins | Only Robot 1 moves | Only Robot 1 lights up | |
| 0:10 | Robot 2 joins | Robot 2 light joins | ||
| … | ~300 timecoded cues over 7 minutes | |||
7. Transportation & Setup



8. Lessons Learned & Next Steps
This project was a full end-to-end build under real installation pressure — hardware, electronics, firmware, and software all developed in parallel against a hard ship date. Below are the post-mortems from each layer.
8.1 Hardware
Vulnerability: stepper-to-servo coupling screw
The most consequential mechanical issue was the M_ screw connecting the 12mm stepper motor axle into the servo motor flange mount. Under repeated rotation, this screw would occasionally snap. When it did, the servo motor wires became the only thing supporting the rotational load — causing them to twist, short, and in several cases blow out digital pins on the Arduino.
The root cause is mechanical: without a tight interference fit between the axle and the flange bore, all rotational strain concentrates on the fastener. The correct fix is proper milling of both the flange and the axle to achieve a tight press or keyed fit that transfers torque through the metal geometry rather than the screw. This was identified during the build and during the installation we drilled through and expanded the holes, filling it with a larger bolt. This came after failures that resulted in several hours of repair time.
Next time: prioritize machined fits for any joint that sees continuous cyclic load. A screw should be a safety fastener, not the primary torque path.
Wiring to the servo motor
Because the servo wires travel a very short, constrained distance, they were extremely difficult to repair after the coupling screw broke — the failure cascade (screw snaps → wires take load → wires rip or short) often happened faster than it could be caught, and repair required significant disassembly.
Next time: use breakaway connectors — connectors designed to release under tension
— on the servo wiring. If the coupling fails, the connector separates cleanly before the wires are damaged
or a short reaches the electronics. In addition, if the first hardware fastener issue was fixed, this issue wouldn't be a problem.
Takeaway: beware of cascading failures.
8.2 Electronics
No per-robot isolation
The current architecture has all six robots sharing data lines back to a single Arduino with no electrical isolation between them. When a robot node fails — especially where a mechanical failure causes a short or voltage spike — the fault can propagate up the data line and damage the Arduino, potentially taking down the entire installation.
Next time: add optocouplers on every data line between the Arduino and the robot nodes.
An optocoupler breaks the electrical path entirely — a short or voltage spike on the robot side can't reach
the Arduino. The failure mode becomes "that robot stops working" rather than "the whole system goes down."
Takeaway: beware of cascading failures.
8.3 Software
Developing inside a larger TD network without full context
The Python extension classes (RobotEXT, RobotControllerEXT,
ShowSequencerEXT, MotionRecorderEXT) were developed using Claude Code,
which worked well for the self-contained logic. The difficulty was that a significant amount of the
TouchDesigner network — the wiring, the CHOPs that feed into the extensions, the DATs that read out of them
— existed outside the codebase and therefore outside Claude's context. Claude Code couldn't see or
reason about the full system, which meant bugs at the boundary between the Python extensions and the TD
network were harder to catch and took longer to debug.
Next time: have Claude Code implement unit tests for every extension class that
simulate the TD environment — mocking absTime, scriptOp.store/fetch, CHOP reads
and writes — and verify that each public method produces the exact expected output. This would have caught
boundary bugs much earlier, shortened the debugging cycle considerably, and produced a more robust system
overall. Closing the loop on AI-assisted development requires giving the AI a way to verify its own work
against the real integration surface.
9. Credits
| Role | Name |
|---|---|
| Concept, artistic direction, design, fabrication | Florian Goerlitz |
| Artistic direction, design, engineering, fabrication | Owen Ackerman |
| Development studio | MotionLab Berlin |
Exhibitions:
Light Art Festival, Heidenheim, Germany — 4/24–5/2/2026
Aux Actes Citoyens Theatre Festival, Tomblaine, France — 5/9–16/2026
Documentation last updated: 6/15/2026



