This is the week the comms layer moved from the bench to actual rolling robots. Week 4 proved one XIAO could broadcast and another could read RSSI off it; Week 7 proved a XIAO could drive a motor platform. This week joins those two: two complete robots, each with their own XIAO + antenna + steppers, doing live RSSI-based predator/prey chase against each other.
Radio hardware
Each robot carries a Seeed XIAO ESP32-C3 wired up on a solderless breadboard with a Seeed 2.4G A-01 external whip antenna plugged into the XIAO's u.FL connector. Originally I'd planned a custom PCB to consolidate everything down, but I ended up sticking with protoboards through the final — easier to iterate, easier to debug, and using the external antenna instead of the onboard PCB antenna gave me noticeably stronger and steadier RSSI readings during range testing.
Two XIAOs on solderless breadboards with external 2.4 GHz whip antennas — the bench setup for the pre-install range test.
That photo is the bench-side range test before bolting the radios into the robot chassis — confirming the link works between two XIAOs over the room, with the antennas in the orientation they'd live in once installed.
Predator + prey demo
Two robots on the floor. One is running mvp_follower.ino (the predator — uses incoming RSSI to chase whatever's broadcasting); the other is running mvp_prey.ino (the same hardware, opposite intent — flees when the predator's RSSI gets too strong). Both robots also broadcast their own ESP-NOW beacon at 5 Hz so each is simultaneously a transmitter and a receiver. The behavior you see in the video is fully autonomous after both are powered on — no driving, no remote control, just radios reacting to each other.
How the chase actually works
The signal: RSSI gradient
Each ESP-NOW packet arrives stamped with an RSSI value in dBm — stronger when the broadcaster is close, weaker when it's far. On its own that one number doesn't tell a robot which direction to drive in, only how far it currently is. So the chase trick is:
Drive somewhere (anywhere) for a second.
Check: did the average RSSI go up or down?
If up → I'm headed the right way, keep going.
If down → I'm headed the wrong way, try a different direction.
This is gradient ascent on a signal that has no direction information, only magnitude. The robot recovers a heading by sampling the gradient through motion. The prey runs the exact same loop with the comparison sign flipped — it tries to decrease the RSSI it sees, i.e. get away.
RSSI smoothing
Raw per-packet RSSI is too noisy to act on directly (multipath, antenna orientation, a hand passing through the line of sight all swing it 10+ dB). Both sketches keep a rolling 10-sample window and average. At 5 Hz beacons, that's a 2-second sliding average — enough to wash out fast jitter without making the robot feel laggy.
The decision loop
Every 1 second the predator looks at the average RSSI and compares to what it was 1 second ago:
RSSI improved (delta > −2 dBm — a small negative tolerance to handle a slowly-moving target): stay STRAIGHT.
RSSI worsened, and we were going straight: start curving — in whichever direction worked last time (lastGoodCurve).
RSSI worsened, and we were already curving: commit to that curve direction for a few more decisions before giving up. Single-cycle alternation can't actually accomplish a U-turn — you need to let one curve direction run long enough to change the heading meaningfully before deciding it's wrong.
After 3 consecutive bad decisions in the same curve direction: flip to the opposite curve.
Continuous motion (no stop-and-think pauses)
The robot is always moving. It never stops to think — when it decides the curve direction is wrong, it just smoothly switches to a different curve while still rolling forward. Inner wheel slows by a divisor of 4× rather than stopping. Visually this reads as the robot weaving gently while it homes in, rather than the choppy "drive a bit, stop, turn, drive a bit" you'd get from a discrete-step controller.
To pull this off without losing radio packets, the stepper-driving code is a non-blocking step-pulse generator: every loop iteration, motorsTick() checks whether each motor is due for its next step pulse based on its own period (FAST_PERIOD_US or SLOW_PERIOD_US). The two motors run from independent timers, so one can be at full speed while the other coasts at quarter speed — that's where the smooth curve comes from. ESP-NOW packets continue to arrive and update RSSI during all of this.
Symmetry breaking
The final project is a 3-robot triangle (A chases B, B chases C, C chases A). If all three predators are coded to start curving right, they end up rotating outward together and the triangle collapses. To break that, every robot has an INITIAL_CURVE constant that alternates L / R / L around the triangle. The constants live in a clearly-flagged block at the top of mvp_follower.ino — same sketch, same binary, three different curve initializations.
To keep this week's two-robot demo clean: one robot was running mvp_follower.ino targeting the other's MAC, and the other was running mvp_prey.ino (which doesn't filter by source MAC — it reacts to whatever ESP-NOW beacon it hears, and there's only one beacon in the room).
Broadcast, not point-to-point
A subtle but important choice: both robots send their beacons to the broadcast address (FF:FF:FF:FF:FF:FF) rather than to a specific peer MAC. That means:
Adding a new robot doesn't require updating every other robot's peer list.
Anyone listening on the same WiFi channel hears every beacon, so each robot has full visibility into all the others.
The "who do I chase" decision is made on the receive side by filtering on info->src_addr == TARGET_MAC. That filter is what turns "I hear everyone" into "I chase only my specific target."
This scales cleanly to the 3-robot triangle without me having to register each robot as a peer of every other.
Hysteresis bands
The follower uses different thresholds from the prey (and both are different from the simple chaser in Week 4):
Follower: stays active as long as any signal is above −85 dBm. The "always close enough" cutoff isn't really a band here — once we're driving, we're driving — the gradient ascent itself decides motion.
Prey: flees when predator RSSI > −38 dBm (predator is close), stops fleeing when predator RSSI < −48 dBm (predator is far). 10 dB deadband. Hysteresis here prevents the prey from twitching on/off the instant the predator hovers at the threshold.
Source files
mvp_follower.ino — the predator, also the canonical "robot" sketch for the final project. Each of the three triangle robots runs this same file with TARGET_MAC and INITIAL_CURVE swapped.
mvp_prey.ino — the prey from this week's two-robot demo. Same hardware as the follower; same motor primitives; the decision loop is the follower's logic with the gradient-ascent comparison flipped to gradient descent.