Last episode I left you holding a freshly laser-cut coaster and promised that next time we'd give our physical work a pulse. I also told you to bring a spare USB cable. Well, here we are :-). Today the thing your code makes doesn't just sit on a shelf looking pretty - it glows, it shifts, it breathes. We're going to take a generative sketch, the exact kind we've been writing since episode 1, and pour it out of the screen and into a strip of actual lights you can hang on your wall.
This is genuinly one of the most satisfying leaps in the whole series, because the gap between "pixels on a canvas" and "physical lights in a room" turns out to be tiny. A pixel is a colour at an x,y. An LED is a colour at an x,y. The whole episode is really just about building the little bridge between those two worlds. Allez, let's wire some light.
First, the star of the show. The old-school way to do LEDs was: one wire per light, and you control brightness by how much power you push down each wire. Want 60 lights? Sixty wires. Want them in colour? Three wires each. It's a nightmare and it doesn't scale past a handful.
Addressable LEDs throw that out. The most common one is the WS2812B, sold under the friendly name NeoPixel. Each one is a tiny RGB light with a tiny chip baked into it, and they chain together on a single data line. You send a stream of colours down that one wire - colour for LED 0, colour for LED 1, colour for LED 2 - and each chip grabs its colour and passes the rest along to its neighbour. One data pin, hundreds of independently-coloured lights. That's the magic word: addressable. Every single light has its own address and its own colour.
// the mental model of an addressable strip: it's literally an array of colours.
// index 0 is the first physical LED, index 1 the next, and so on down the wire.
// this is the SAME data structure as a row of pixels. that's the whole secret.
const strip = [
{ r: 255, g: 0, b: 0 }, // LED 0 -> red
{ r: 0, g: 255, b: 0 }, // LED 1 -> green
{ r: 0, g: 0, b: 255 }, // LED 2 -> blue
// ...one entry per physical light
];
If that array looks familiar, it should. It's the same shape as the pixel data we read back in episode 10, just one-dimensional instead of a grid. Once you see an LED strip as "an array of colours I get to fill in every frame", you already know how to drive it. Everything else is plumbing.
You can buy these as flexible strips (30, 60, or 144 LEDs per metre, adhesive-backed, cut to any length), as rings, or as matrices - 8x8, 16x16, even 32x32 grids where each pixel is a real point of light. A 16x16 matrix is my favourite starter, because your creative coding sketch rendered at 16x16 becomes a glowing physical panel. Cellular automata, noise fields, a little scrolling message - they all look gorgeous as actual light.
Here's the bit that trips people up at first, so let me say it plainly. Your p5 sketch runs on your computer. But your computer has no pin that can speak the WS2812B's very precise timing protocol - that needs microsecond accuracy a busy laptop can't promise. So we use a helper: a cheap microcontroller (an Arduino, or an ESP32) sits between your computer and the lights.
The division of labour is clean:
// who does what in an LED art setup:
//
// YOUR COMPUTER (p5 / node) THE MICROCONTROLLER (arduino / esp32)
// -------------------------- ------------------------------------
// runs the generative sketch receives a stream of colours
// decides every LED's colour writes them to the LEDs with exact timing
// sends colours over a wire --> drives the physical strip
//
// two brains: one creative, one a careful courier. they talk over serial or wifi.
Your computer is the artist - it thinks up the colours. The microcontroller is the courier - it takes that list of colours and delivers it to the lights with the precise timing the chips demand. They talk over a wire (USB serial) or over the network (WiFi). That's the entire architecture. Once that clicks, the rest is detail.
Let me show you the courier's job first, because it's short. On the Arduino you use a library called FastLED (or Adafruit's NeoPixel library - same idea). It hides all that nasty timing and gives you a simple array to fill.
// arduino sketch: the "courier". reads colours from serial, shows them.
#include
#define NUM_LEDS 60
#define DATA_PIN 6
CRGB leds[NUM_LEDS]; // an array of colours - same shape as our p5 strip!
void setup() {
FastLED.addLeds<WS2812B, DATA_PIN, GRB>(leds, NUM_LEDS);
Serial.begin(115200); // open the pipe to the computer
}
void loop() {
// wait for a full frame: 3 bytes (r,g,b) per LED
if (Serial.available() >= NUM_LEDS * 3) {
for (int i = 0; i < NUM_LEDS; i++) {
leds[i].r = Serial.read();
leds[i].g = Serial.read();
leds[i].b = Serial.read();
}
FastLED.show(); // push the whole array to the physical strip
}
}
That's basically the whole microcontroller program for the entire episode. It does nothing creative at all - it just listens for NUM_LEDS * 3 bytes (red, green, blue for each light), drops them into the leds array, and calls show() to light them up. Notice leds is an array of colours, exactly like our JavaScript strip. The courier and the artist are speaking the same language - a list of RGB values.
Now the fun side. On your computer, you generate colours however you like, then squirt them down the serial port as raw bytes. In the browser p5 can't touch serial directly for security reasons, so the easiest route is a tiny Node.js bridge using the serialport package. Here's the courier's pen-pal.
// node.js: open the serial port to the arduino. one line of setup.
const { SerialPort } = require("serialport");
const port = new SerialPort({ path: "/dev/ttyUSB0", baudRate: 115200 });
const NUM_LEDS = 60;
// send one frame: flatten our colour array into raw r,g,b,r,g,b... bytes
function sendFrame(strip) {
const buf = Buffer.alloc(NUM_LEDS * 3);
for (let i = 0; i < NUM_LEDS; i++) {
buf[i * 3] = strip[i].r; // exactly the order the arduino reads
buf[i * 3 + 1] = strip[i].g;
buf[i * 3 + 2] = strip[i].b;
}
port.write(buf); // off it goes down the wire
}
See how sendFrame mirrors the Arduino's read loop perfectly? We write r, g, b for each light in order; the Arduino reads r, g, b for each light in order. As long as both sides agree on NUM_LEDS and the byte order, the colours arrive intact. This little handshake is the heart of every computer-to-LED project on earth.
Now we need something to put in that strip array every frame. Let me generate a slow, drifting noise pattern - our old friend Perlin noise from episode 12 - so the strip looks like a living, shifting ribbon of light.
// fill the strip with a slowly drifting noise pattern.
// `t` advances each frame so the whole thing breathes and flows.
const { createNoise2D } = require("simplex-noise");
const noise2D = createNoise2D();
function noiseStrip(num, t) {
const strip = [];
for (let i = 0; i < num; i++) {
// sample noise along the strip's length, drifting over time
const n = (noise2D(i * 0.08, t) + 1) / 2; // 0..1
strip.push(hsvToRgb(n, 0.9, 1)); // map value -> hue
}
return strip;
}
// drive it at ~30fps
let t = 0;
setInterval(() => {
const strip = noiseStrip(NUM_LEDS, t);
sendFrame(strip);
t += 0.01;
}, 33);
Run that with the Arduino plugged in, and a 60-LED strip becomes a slow river of colour, the hue rolling along its length like weather. The frame rate matters here: at 115200 baud, pushing 60 LEDs x 3 bytes = 180 bytes per frame, you can comfortably hit 30fps. For a 300-LED strip you're sending 900 bytes a frame and it starts to chug, which is your cue to either drop the frame rate or move to WiFi (coming up). The serial pipe has a width, and big strips fill it fast.
I snuck a hsvToRgb in there, because hue is so much nicer to animate than raw RGB - remember episode 7, where we learned that HSV lets you spin through colour by turning one dial? Same trick, now driving real light.
// HSV -> RGB so we can animate by HUE (one dial) instead of three.
// straight from the colour-theory toolkit in episode 7.
function hsvToRgb(h, s, v) {
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const u = v * (1 - (1 - f) * s);
let r, g, b;
switch (i % 6) {
case 0: r = v; g = u; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = u; break;
case 3: r = p; g = q; b = v; break;
case 4: r = u; g = p; b = v; break;
case 5: r = v; g = p; b = q; break;
}
return { r: Math.round(r*255), g: Math.round(g*255), b: Math.round(b*255) };
}
Here's the wrinkle that catches everyone, and it's a beautiful problem. Your generative sketches think in 2D - x and y. But an LED strip is a 1D line of lights. And when you bend that strip back and forth to fill a panel, the wiring usually runs in a serpentine: row 0 goes left-to-right, row 1 goes right-to-left, row 2 left-to-right again, zig-zagging like a snake. So physical LED number 17 might be sitting at grid position (6, 1), not where you'd naively guess.
The fix is to build a coordinate map once: for every LED index, store its real (x, y) position on the panel. Then you can sample your nice 2D sketch at each light's true location and never think about the wiring again.
// build a map: ledIndex -> {x, y} for a serpentine-wired matrix.
// even rows run left->right, odd rows run right->left (the zig-zag).
function serpentineMap(cols, rows) {
const map = [];
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
// on odd rows the physical order is reversed
const realX = (y % 2 === 0) ? x : (cols - 1 - x);
map.push({ x: realX, y: y });
}
}
return map; // map[ledIndex] = its true position on the panel
}
With that map in hand, driving a matrix from a 2D pattern is gorgeous. You compute your image in proper x,y space - where everything is sane - and then for each LED you just look up where it actually lives and grab the colour there.
// drive a matrix: evaluate a 2D pattern at each LED's TRUE position.
// the serpentine wiring is completely hidden behind the map.
function matrixFrame(map, cols, rows, t) {
const strip = [];
for (const led of map) {
// a radial noise field, evaluated in clean grid space
const dx = led.x - cols / 2;
const dy = led.y - rows / 2;
const dist = Math.sqrt(dx*dx + dy*dy);
const n = (noise2D(dist * 0.2, t) + 1) / 2;
strip.push(hsvToRgb((n + t) % 1, 0.9, 1));
}
return strip;
}
This map idea is the single most useful concept in physical LED art. Strips get wired in spirals, rings, scattered across a sculpture, even in 3D - and as long as you build a map from LED index to physical position, your 2D (or 3D) sketch doesn't have to care. The map absorbs all the messiness of the real world. Build the map once, then code as if the wiring were perfect.
Want to hear the laziest, most powerful trick in the whole episode? You don't even have to write a special LED version of your art. Just render your normal p5 sketch onto a tiny canvas - the same resolution as your LED layout - and read the pixels straight back out. Your existing sketch becomes the light controller, untouched.
// render any p5 sketch at LED resolution, then read it back as a strip.
// this means ANY sketch you've ever made can drive lights, for free.
function canvasToStrip(map) {
loadPixels(); // p5: grab the canvas pixel data
const strip = [];
for (const led of map) {
const i = (led.y * width + led.x) * 4; // 4 bytes per pixel: r,g,b,a
strip.push({ r: pixels[i], g: pixels[i+1], b: pixels[i+2] });
}
return strip;
}
Think about what that means. Every single sketch we've built across 108 episodes - the particle systems from episode 11, the flow fields, the Game of Life, the reaction-diffusion patterns - all of them can drive a physical light panel with this one function. You render at 16x16 instead of 1600x1600, read the pixels, and send them to the strip. The sketch has no idea it's controlling real lights. That's the dream of this whole "out of the screen" chapter: the same code, pointed at the physical world.
Serial over USB is great for a strip on your desk, but the cable is a leash. The moment you want lights across the room, or several installations at once, you switch to an ESP32 - a microcontroller with WiFi built in for a couple of euros. Instead of bytes down a cable, you fire colour data as UDP packets over your network. No wire, and you can drive many panels from one computer.
// node.js: send a frame to an ESP32 over the network instead of a cable.
// UDP is perfect here - it's fast and we don't care about a dropped frame.
const dgram = require("dgram");
const sock = dgram.createSocket("udp4");
const ESP_IP = "192.168.1.50";
const ESP_PORT = 4210;
function sendFrameUDP(strip) {
const buf = Buffer.alloc(strip.length * 3);
for (let i = 0; i < strip.length; i++) {
buf[i*3] = strip[i].r; buf[i*3+1] = strip[i].g; buf[i*3+2] = strip[i].b;
}
sock.send(buf, ESP_PORT, ESP_IP); // straight onto the wifi, no leash
}
Notice it's almost identical to the serial version - same flatten-to-bytes loop, different transport. That's deliberate. Once your art produces "an array of colours", how you ship them (cable, WiFi, even the professional ArtNet / sACN protocols that pro stage-lighting rigs use) is a swappable detail. UDP is lovely for this because a single dropped frame just means one slightly-late update - nobody notices, and you keep the speed. We don't need the careful re-sending of TCP when we're throwing 30 frames a second at a light.
Right, serious face for a minute, because this is the one place LED art can go properly wrong. LEDs are hungry. A single WS2812B at full white draws about 60 milliamps. That sounds tiny. Now do the maths for a 300-LED strip all blazing white:
// power budget for a strip at full white. ALWAYS do this before you build.
function maxAmps(numLeds, maPerLed = 60) {
return (numLeds * maPerLed) / 1000; // back to amps
}
console.log(maxAmps(60)); // 3.6 A - a chunky USB charger
console.log(maxAmps(300)); // 18 A - a SERIOUS 5V power supply, not USB!
Eighteen amps at 5 volts is ninety watts pouring through a thin strip. Your laptop's USB port can give you maybe half an amp - try to run a big strip off it and you'll get a sad brown flicker, a hot cable, or a dead port. So: budget your power, buy a proper 5V supply rated above your worst case, and for long strips inject power at several points along the strip (not just one end) or the far LEDs go dim and tint red as the voltage sags. A simple, kind habbit is to cap global brightness in software so you can never hit that scary full-white number by accident.
// scale every colour down by a brightness factor before sending.
// keeps you under your power budget AND saves your eyeballs - raw LEDs
// at full tilt are blinding. 0.3 is a friendly living-room level.
function dim(strip, brightness = 0.3) {
return strip.map(c => ({
r: Math.round(c.r * brightness),
g: Math.round(c.g * brightness),
b: Math.round(c.b * brightness),
}));
}
I'm labouring this because it's the bit the tutorials skip and then people wonder why their lovely build browns out halfway down. Respect the amps and everything else is play.
One more craft secret before the exercise, and it's the difference between "string of christmas lights" and "art". A bare WS2812B is a tiny, brutally bright point source - look straight at one and you see a dot, not a glow. The trick every LED artist uses is diffusion: put something cloudy between the LED and your eye so the light spreads into a soft, continuous wash.
// femdev's diffusion cheat sheet (not code, just notes-as-code :-)
const diffusion = {
frostedAcrylic: "the classic - a sheet of frosted perspex in front. clean, even glow",
pingPongBalls: "one ball over each LED - cheap, makes lovely fat soft pixels",
bakingPaper: "literally parchment paper - free, surprisingly gorgeous",
printed3DShells:"diffuser caps you print yourself - total control of the shape",
fabric: "thin white cotton over a strip - warm, cosy, hides the dots",
// rule of thumb: more GAP between LED and diffuser = softer, blurrier light
};
The maths-y reason it works: diffusion is basically a blur kernel applied in the real world. Each harsh point spreads into its neighbours, and your eye reads the overlapping glows as one smooth gradient - exactly like the blur we did in pixel-land, except now photons are doing the averaging for free. The bigger the gap between LED and diffuser, the wider the spread and the softer the result. A 16x16 matrix behind frosted acrylic stops looking like a circuit board and starts looking like a little window into another world.
Let me tie a bow on it by pulling in something from way back. In episode 19 we made sound-reactive visuals - we analysed audio into frequency bands and drove graphics with them. Point that exact same analysis at an LED panel and you get a physical audio visualiser hanging on your wall, pulsing to whatever's playing. No new ideas, just our old audio code aimed at light.
// audio-reactive strip: bass drives the base colour, treble adds sparkle.
// `bands` is the FFT output from our episode-19 audio analyser.
function audioStrip(num, bands, t) {
const bass = bands.low; // 0..1, the thump
const treble = bands.high; // 0..1, the shimmer
const strip = [];
for (let i = 0; i < num; i++) {
// bass swells the whole strip's brightness and hue...
let v = 0.2 + bass * 0.8;
let h = (t + bass * 0.3) % 1;
// ...treble randomly sparkles individual LEDs white
if (Math.random() < treble * 0.3) { v = 1; }
strip.push(hsvToRgb(h, 1 - (v > 0.95 ? 1 : 0), v));
}
return strip;
}
The bass makes the whole panel breathe and shift hue; the treble flickers little white sparkles across it like sunlight on water. Diffuse it behind frosted acrylic, hang it over your desk, put some music on - and you've got a piece of generative art that lives in your room and responds to it. That's the moment the whole "out of the screen" chapter has been building toward: code that doesn't just make a picture, it makes an atmosphere.
Time to build. Your mission: drive a strip of 60 LEDs with a generative noise pattern, mounted behind a diffuser as a piece of ambient light art. Here's the full computer-side loop, pulling together everything above.
// the complete sketch-side controller for your light ribbon.
// generate -> dim for safety -> send. swap noiseStrip for ANY pattern you like.
const NUM = 60;
let time = 0;
function tick() {
let strip = noiseStrip(NUM, time); // the generative pattern
strip = dim(strip, 0.35); // stay under the power budget
sendFrame(strip); // (or sendFrameUDP for wireless)
time += 0.008; // slow drift = calm, ambient mood
}
setInterval(tick, 33); // ~30fps
The build, step by step: flash the FastLED courier sketch onto an Arduino, wire the strip's data pin to pin 6 and (critically) power the strip from a proper 5V supply, not the Arduino. Run the Node controller above. You should see colour rolling down the strip. Then mount it - tape it behind a strip of frosted acrylic or even baking paper, stand it on a shelf, and watch it breathe.
Stretch goals, in rising order of "ooh":
noiseStrip for your favourite old sketch via the canvasToStrip pixel trick - resurrect a particle system from episode 11 as physical light.serpentineMap to run a 2D pattern on it.sendFrameUDP, then put the panel across the room with no cable in sight.Do at least the basic ribbon if you possibly can. There's a specific kind of joy the first time a pattern you wrote rolls down a real strip of light in your hands - it's the same feeling as holding your first plot or your first laser-cut piece, but this one moves. The screen has never felt further away.
sendFrame writes them, the Arduino's loop reads them. As long as both agree on the count and the order, the colours arrive intact. At 115200 baud you get a comfy 30fps for ~60-300 LEDsloadPixels, send them as a strip. Every sketch from all 108 previous episodes can drive real light for free, untouchedSo that's three machines now that take our code into the physical world - the plotter drew it, the laser cut it, and now light animates it. And notice the theme holding steady: almost nothing today was new maths. Noise, HSV colour, pixel reading, a coordinate map - all things we already owned, pointed at photons instead of a canvas. The strip is an array of colours, and we've been filling arrays of colours since episode 1.
But a flat panel of light is just the start. Once your code can drive lights and read sensors, it can throw images onto whole buildings, wrap them around your body, or live inside a little computer that costs less than lunch and runs your art forever. We're going to keep walking deeper into the room. Bring your curiosity - and maybe charge up that ESP32 :-).
Sallukes! Thanks for reading.
X