Connecting to p5.js via Serial

Bridge the Touch Board's touch events to a p5.js browser sketch using the Web Serial API for reactive visual experiences.

⏱ 24 min read p5.js serial Web Serial API browser visual bridge

The idea

Your Touch Board detects physical touch. p5.js draws reactive visuals in the browser. Connect them and you have a physical controller driving a browser canvas — touch a painted wall mural and watch particles explode on a projected screen.

Architecture

Touch Board (Arduino/USB) ──serial──► Chrome (Web Serial API) ──JS──► p5.js canvas

The Touch Board sends touch events as plain text over USB serial. Chrome’s Web Serial API reads that text. p5.js updates its visuals based on the received data.

No server, no Node.js, no drivers needed — just Chrome and the Touch Board.

Part 1: Touch Board firmware

Upload this sketch — it sends touch and release events as simple text lines:

#include <MPR121.h>
#include <Wire.h>

void setup() {
  Serial.begin(57600);
  while (!Serial);

  if (!MPR121.begin(0x5C)) {
    Serial.println("ERR:MPR121");
    while (1);
  }
  MPR121.setInterruptPin(4);
  Serial.println("READY");
}

void loop() {
  if (MPR121.touchStatusChanged()) {
    MPR121.updateTouchData();
    for (int i = 0; i < 12; i++) {
      if (MPR121.isNewTouch(i)) {
        Serial.print("T:");
        Serial.println(i);
      }
      if (MPR121.isNewRelease(i)) {
        Serial.print("R:");
        Serial.println(i);
      }
    }
  }
}

Output format: T:3\n = touch electrode 3, R:3\n = release electrode 3.

Part 2: p5.js sketch

// Note: Web Serial requires Chrome/Edge and HTTPS (or localhost)

let port, reader;
let touchStates  = new Array(12).fill(false);
let particles    = [];

// ── UI ──────────────────────────────────────────
function setup() {
  createCanvas(windowWidth, windowHeight);
  colorMode(HSB, 360, 100, 100, 100);

  let btn = createButton('Connect Touch Board');
  btn.style('position', 'fixed');
  btn.style('top', '10px');
  btn.style('left', '10px');
  btn.style('z-index', '100');
  btn.style('padding', '8px 16px');
  btn.mousePressed(connectSerial);
}

// ── Serial ──────────────────────────────────────
async function connectSerial() {
  try {
    port = await navigator.serial.requestPort();
    await port.open({ baudRate: 57600 });

    let decoder = new TextDecoderStream();
    port.readable.pipeTo(decoder.writable);
    reader = decoder.readable.getReader();

    let buffer = '';
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      buffer += value;
      let lines = buffer.split('\n');
      buffer = lines.pop();
      for (let line of lines) parseLine(line.trim());
    }
  } catch (err) {
    console.error('Serial error:', err);
  }
}

function parseLine(line) {
  if (line.startsWith('T:')) {
    let n = parseInt(line.slice(2));
    if (!isNaN(n) && n >= 0 && n < 12) {
      touchStates[n] = true;
      onTouch(n);
    }
  } else if (line.startsWith('R:')) {
    let n = parseInt(line.slice(2));
    if (!isNaN(n) && n >= 0 && n < 12) {
      touchStates[n] = false;
    }
  }
}

// ── Visuals ─────────────────────────────────────
function onTouch(electrode) {
  let x   = map(electrode, 0, 11, width * 0.1, width * 0.9);
  let hue = map(electrode, 0, 11, 0, 320);

  for (let i = 0; i < 40; i++) {
    particles.push({
      x,
      y:    height / 2,
      vx:   random(-4, 4),
      vy:   random(-8, -1),
      life: 1.0,
      hue
    });
  }
}

function draw() {
  background(240, 20, 8, 25);

  // Draw touch indicators
  for (let i = 0; i < 12; i++) {
    let x   = map(i, 0, 11, width * 0.1, width * 0.9);
    let hue = map(i, 0, 11, 0, 320);
    fill(hue, touchStates[i] ? 90 : 30, touchStates[i] ? 100 : 40);
    noStroke();
    circle(x, height * 0.8, touchStates[i] ? 40 : 20);
  }

  // Update and draw particles
  noStroke();
  for (let p of particles) {
    p.x    += p.vx;
    p.y    += p.vy;
    p.vy   += 0.15;
    p.life -= 0.02;
    fill(p.hue, 80, 95, p.life * 80);
    circle(p.x, p.y, p.life * 14);
  }
  particles = particles.filter(p => p.life > 0);
}

Running the sketch

  1. Upload the firmware to the Touch Board
  2. Open the p5.js sketch in Chrome (localhost or GitHub Pages — not file://)
  3. Click Connect Touch Board, select the Touch Board’s port
  4. Touch electrodes — watch particles appear

Adding proximity-based visuals

Read the proximity/capacitance value continuously (not just on touch events) by sending raw data:

// Add to the Arduino loop, every 50ms:
static unsigned long lastSend = 0;
if (millis() - lastSend > 50) {
  MPR121.updateAll();
  Serial.print("D:");
  for (int i = 0; i < 12; i++) {
    int delta = MPR121.getBaselineData(i) - MPR121.getFilteredData(i);
    Serial.print(constrain(delta, 0, 255));
    if (i < 11) Serial.print(",");
  }
  Serial.println();
  lastSend = millis();
}

Parse in p5.js:

} else if (line.startsWith('D:')) {
  let vals = line.slice(2).split(',').map(Number);
  proximityData = vals;  // array of 12 values, 0 = no touch, 255 = firm touch
}

Key takeaways

  • Web Serial API in Chrome connects the browser directly to the Touch Board over USB
  • Touch Board sends T:N / R:N lines; p5.js parses them and updates visual state
  • onTouch(electrode) is the hook for triggering visual events
  • The connectSerial() async loop keeps reading indefinitely once connected
  • Send raw delta values at intervals for proximity/continuous control rather than just touch events
  • Works on localhost or HTTPS — not on file:// URLs (browser security restriction)