Week 3 · embedded programming

A neural network that learns on a $5 board

This week we worked with a QPAD: a small board with a Seeed XIAO RP2040, six capacitive touch pads and an OLED display. I wanted to do something with neural networks, but rather than load a model trained on my laptop, I wanted to see whether I could get the RP2040 to learn on its own. So I made a tiny swimmer game where the network starts from random weights and trains on the microcontroller while you play.

Assembled blue QPAD-XIAO circuit board with OLED and Seeed XIAO RP2040, lying on a wooden table
The assembled QPAD-XIAO: six touch pads, an OLED and a Seeed XIAO RP2040.

What the datasheet told me

Coredual Cortex-M0+, up to 133 MHz
Memory264 kB SRAM, program in 2 MB external flash
Used hereGPIO for touch, I²C for the OLED, USB for serial

A few things in the RP2040 datasheet mattered for the design. It has no hardware floating-point unit, so the network calculations run in software; that helped keep me to a 44-parameter model. It has two processor cores and hardware spinlocks, so I put training on one core and the interface on the other. And since there's no dedicated capacitive-touch peripheral, the pads are read by discharging them and timing how long they take to charge through a pull-up. Touching a pad changes its capacitance, which changes that timing.

Building the board

We used a syringe to apply solder paste, checked the placement under the microscope, put the XIAO in position and reflowed the joints with hot air. The castellated pads are closely spaced, so this was easier to get consistent than trying to solder every connection with an iron. The OLED went on afterward.

Digital microscope examining solder paste on R4 and R5 resistor pads of the blue QPAD PCB
Inspecting the small resistor pads under the microscope before reflow.
Seeed XIAO RP2040 placed on the QPAD PCB on the workbench, with solder-paste syringe nearby
The XIAO in position on the PCB, ready for hot-air reflow.
Reverse side of the finished QPAD with a QR code and a printed debugging quote
The reverse side of the board, including the QR code and a particularly appropriate debugging quote.

Exactly what I did at the bench

StepDo this
1Clean the QPAD PCB with isopropanol. Dispense solder paste from the syringe onto every pad of the XIAO footprint and the resistor pads. Less than you think; a dot about the size of the pad.
2Check under the digital microscope. Bridges between castellations get wiped and redone now, not after reflow.
3Place the XIAO RP2040 with tweezers, USB-C facing the board edge, castellations centered on the pads. Place the two resistors.
4Hot air: about 300 °C, low airflow, circling 2 to 3 cm above the board until the paste goes shiny and the part settles. About 40 seconds. Let it cool untouched.
5Inspect again under the microscope. Reflow any dull joint with a fine iron tip.
6Solder the 4-pin header for the OLED (GND, VCC, SCL, SDA) with an iron, seat the SSD1306 module, plug in USB.

Reproduce the firmware

IDEArduino IDE 2.x
Board corearduino-pico by Earle Philhower. Boards Manager URL: https://github.com/earlephilhower/arduino-pico/releases/download/global/package_rp2040_index.json. Board: Seeed XIAO RP2040
LibrariesAdafruit SSD1306, Adafruit GFX (Library Manager)
DisplaySSD1306 128×64, I²C address 0x3C, on the XIAO's default Wire pins (D4 = SDA, D5 = SCL)
Touch padsraw GPIO {3, 4, 2, 27, 1, 26} = pads Q0..Q5. No touch peripheral: each pad is driven low, released to the pull-up, and the charge time is counted
FlashingHold BOOT, tap RESET, release BOOT; the XIAO shows up as a USB drive and the IDE uploads. After the first upload it reflashes normally

If you’re starting with a fresh board, flash touch_test_rp2040.ino first. It prints the six raw readings and their baseline in the serial monitor at 115200 baud. Check that touching each pad changes its reading by more than the MARGIN value of 12. If one barely responds, inspect its solder joint. Once the pads work, flash the swimmer sketch.

// touch_test_rp2040.ino: how a pad is read, no hardware touch sensing needed
const uint8_t PADS[6] = { 3, 4, 2, 27, 1, 26 };   // Q0..Q5
const int SAMPLES = 32;   // averaged reads per pad
const int MARGIN  = 12;   // counts above baseline that count as a touch

uint16_t readPad(uint8_t pin) {
  pinMode(pin, OUTPUT); digitalWrite(pin, LOW); delayMicroseconds(50);  // discharge
  pinMode(pin, INPUT_PULLUP);
  uint16_t n = 0;
  while (digitalRead(pin) == LOW && n < 2000) n++;                        // charge time
  return n;
}

What it does

The game is simple: there's a little swimmer on the OLED, and you move its target using the touch pads. The network has four inputs, six hidden units and two outputs (4 → 6 → 2). It gets the target offset and velocity as inputs, then chooses thrust in x and y. For training, it simulates 28 time steps, calculates a loss and backpropagates through the simulated motion and the network. So the learning really is happening on the RP2040, rather than on a computer somewhere else.

I considered a walking robot, but contacts with the ground make gradients tricky. Swimming with smooth drag and thrust was an easier way to demonstrate differentiable simulation on such a small chip. It's closely related to the methods I use in my research, just scaled way down.

OLED showing the small swimmer icon in the play area
The swimmer game running on the OLED. The touch pads let me move the target.
QPAD input move target / mode GPIO touch sensing Core 0 read touch pads run visible swimmer draw OLED + serial telemetry Core 1 forward rollout loss computation BPTT / gradient update publish updated weights 4 → 6 → 2 network inputs: dx, dy, vx, vy outputs: x-thrust, y-thrust Differentiable physics drag + thrust + position 28-step rollout reverse gradients through dynamics + network
Core 0 handles people (touch, display, serial). Core 1 handles learning. They share weights behind a mutex.
SettingValueWhy
Network4 → 6 → 2, tanh, 44 parameterssmall enough for software float on an M0+
Rollout horizon28 stepslong enough to reach the target, short enough to backprop in one update
Physicsv' = DRAG·v + THRUST·a, x' = x + DT·v', all smoothdifferentiable, no contacts
Lossdistance to target + overshoot penalty + thrust costclose, gentle, cheap
Updateplain gradient descent with clipping, one BPTT step per 180 mspaced so you can see it learn
Corescore 0: touch, OLED, serial. core 1: training. Weights shared behind a mutex_tdisplay never stutters

One step of the simulation

State sₜ x, y, vx, vy Neural net aₓ, aᵧ Smooth physics drag + thrust State sₜ₊₁ next state repeat for 28 steps

And backwards

Forward: s₀ s₁ ... s₂₈ Backward: ∂L/∂θ step 1 ... terminal loss gradient clipping → θ ← θ − α∇θL
Forward 28 steps, then gradients flow back from the final loss, get clipped, and update the weights.
QPAD OLED reading TRAIN BP 25 and L 0.15
Training on the RP2040: step 25, displayed loss 0.15.
QPAD OLED displaying L 0.12 during the swimmer training run
Another training-screen reading shows loss 0.12.

The loss encourages the swimmer to reach its target without overshooting or using more thrust than it needs. Nothing is pretrained or saved between resets: pressing B starts from new random weights.

Controls

PadGPIODoes
left27move target left
right1move target right
down2move target down
up26move target up
A3start training, or freeze the weights to test
B4throw away the brain and start from random
ModeWhat you see
RANDOMUntrained swimmer flailing. Learning is off on purpose.
TRAINCore 1 does one backprop update at a time and the swimmer visibly gets better.
TESTWeights frozen. Move the target somewhere new and see whether it generalizes.
QPAD-OLED running RANDOM mode on the handheld board
RANDOM mode lets me see what the untrained controller does before switching training on.

The bug that made it too good

I ran into a funny problem with the first version: after resetting it, the swimmer seemed to know what it was doing immediately. The chip was training so quickly that you could barely see the untrained behaviour. I changed the controls so B resets the weights and pauses training, while A starts learning. I also limited updates to one every 180 ms so you can actually watch the process.


void loop1() {
  if (!trainingEnabled) {
    delay(5);
    return;
  }

  // One BPTT step at a time so learning is actually visible on the OLED.
  float l = trainOneBPTTStep();

  mutex_enter_blocking(&mtx);
  trainStep += 1;
  trainLoss = l;
  memcpy(visibleTheta, theta, sizeof(theta));
  mutex_exit(&mtx);

  // Deliberately pace training. Without this, the RP2040 can converge
  // before you have time to see the random policy behave badly.
  delay(180);
}

Code

Forward pass through the network


void networkForward(
  const float *th,
  const float in[N_IN],
  float hidden[N_H],
  float out[N_OUT]
) {
  for (int h = 0; h < N_H; h++) {
    float z = th[idxB1(h)];
    for (int i = 0; i < N_IN; i++) {
      z += th[idxW1(h, i)] * in[i];
    }
    hidden[h] = tanhf(z);
  }

  for (int o = 0; o < N_OUT; o++) {
    float z = th[idxB2(o)];
    for (int h = 0; h < N_H; h++) {
      z += th[idxW2(o, h)] * hidden[h];
    }
    out[o] = tanhf(z);
  }
}

Start of the training step

float trainOneBPTTStep() {
  float tx, ty, cx, cy;

  mutex_enter_blocking(&mtx);
  tx = targetX;
  ty = targetY;
  cx = currentX;
  cy = currentY;
  mutex_exit(&mtx);

  // Small randomized starting condition makes the learned controller useful
  // from more than one exact initial state.
  st[0][0] = 0.20f + 0.10f * (urand(rngState) - 0.5f);
  st[0][1] = 0.45f + 0.10f * (urand(rngState) - 0.5f);
  st[0][2] = 0.08f * (urand(rngState) - 0.5f);
  st[0][3] = 0.08f * (urand(rngState) - 0.5f);

  float loss = 0.0f;

  // ---------------- forward rollout ----------------
  for (int t = 0; t < HORIZON; t++) {
    float x  = st[t][0];
    float y  = st[t][1];
    float vx = st[t][2];
    float vy = st[t][3];

    ins[t][0] = tx - x;
    ins[t][1] = ty - y;
    ins[t][2] = vx;
    ins[t][3] = vy;

    networkForward(theta, ins[t], hid[t], act[t]);

    float nvx = DRAG * vx + THRUST * act[t][0] + cx;
    float nvy = DRAG * vy + THRUST * act[t][1] + cy;

    float nx = x + DT * nvx;
    float ny = y + DT * nvy;

    st[t + 1][0] = nx;
    st[t + 1][1] = ny;
    st[t + 1][2] = nvx;
    st[t + 1][3] = nvy;

    float dx = x - tx;
    float dy = y - ty;
  // ... full sketch continues with the backward pass

A true reset


void resetLearning() {
  // B means a *true* reset:
  //   1. stop learning
  //   2. randomize all weights
  //   3. freeze those random weights
  //   4. let you watch the untrained swimmer
  //
  // Press A when you want training to begin.
  trainingEnabled = false;
  delay(10);

  randomizePolicy();

  mutex_enter_blocking(&mtx);
  memcpy(frozenTheta, theta, sizeof(frozenTheta));
  memcpy(visibleTheta, theta, sizeof(visibleTheta));
  mutex_exit(&mtx);

  randomDemoMode = true;
  testMode = true;

  resetVisible();
}

Before the swimmer

Before the swimmer, I built a little lunar lander game. It used an evolution strategy: try variations of the network weights and keep the ones that worked better, rather than calculate gradients. I also made a YOU vs AI mode. That version was fun, but the swimmer let me demonstrate backpropagation through a simulation, which I found more interesting.

QPAD screen showing the instructions for an earlier YOU vs AI game
The earlier YOU vs AI game, before I moved to the swimmer and backpropagation.

Files

touch_test_rp2040.inopad bring-up: raw counts and baseline over serial
qpad_swimmer_bptt.inothe swimmer: network, differentiable rollout, BPTT, dual-core display

I used Arduino IDE with the arduino-pico core and Adafruit SSD1306 for the display. AI assistants helped with the firmware while I experimented with a few game ideas, including a swarm demo and the lander, before settling on the swimmer.